feat(core): Validate commit content for project admin role (#15687)

Co-authored-by: Guillaume Jacquart <jacquart.guillaume@gmail.com>
This commit is contained in:
Andreas Fitzek
2025-06-03 11:33:01 +02:00
committed by GitHub
parent 7c806ff532
commit 9607908c04
10 changed files with 867 additions and 388 deletions

View File

@@ -1,4 +1,5 @@
import type { SourceControlledFile } from '@n8n/api-types'; import type { SourceControlledFile } from '@n8n/api-types';
import { User } from '@n8n/db';
import type { SharedCredentials } from '@n8n/db'; import type { SharedCredentials } from '@n8n/db';
import type { SharedWorkflow } from '@n8n/db'; import type { SharedWorkflow } from '@n8n/db';
import type { FolderRepository } from '@n8n/db'; import type { FolderRepository } from '@n8n/db';
@@ -14,8 +15,16 @@ import fsp from 'node:fs/promises';
import type { VariablesService } from '../../variables/variables.service.ee'; import type { VariablesService } from '../../variables/variables.service.ee';
import { SourceControlExportService } from '../source-control-export.service.ee'; import { SourceControlExportService } from '../source-control-export.service.ee';
import type { SourceControlScopedService } from '../source-control-scoped.service';
import { SourceControlContext } from '../types/source-control-context';
describe('SourceControlExportService', () => { describe('SourceControlExportService', () => {
const globalAdminContext = new SourceControlContext(
Object.assign(new User(), {
role: 'global:admin',
}),
);
const cipher = Container.get(Cipher); const cipher = Container.get(Cipher);
const sharedCredentialsRepository = mock<SharedCredentialsRepository>(); const sharedCredentialsRepository = mock<SharedCredentialsRepository>();
const sharedWorkflowRepository = mock<SharedWorkflowRepository>(); const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
@@ -24,6 +33,7 @@ describe('SourceControlExportService', () => {
const workflowTagMappingRepository = mock<WorkflowTagMappingRepository>(); const workflowTagMappingRepository = mock<WorkflowTagMappingRepository>();
const variablesService = mock<VariablesService>(); const variablesService = mock<VariablesService>();
const folderRepository = mock<FolderRepository>(); const folderRepository = mock<FolderRepository>();
const sourceControlScopedService = mock<SourceControlScopedService>();
const service = new SourceControlExportService( const service = new SourceControlExportService(
mock(), mock(),
@@ -34,6 +44,7 @@ describe('SourceControlExportService', () => {
workflowRepository, workflowRepository,
workflowTagMappingRepository, workflowTagMappingRepository,
folderRepository, folderRepository,
sourceControlScopedService,
mock<InstanceSettings>({ n8nFolder: '/mock/n8n' }), mock<InstanceSettings>({ n8nFolder: '/mock/n8n' }),
); );
@@ -172,7 +183,7 @@ describe('SourceControlExportService', () => {
workflowTagMappingRepository.find.mockResolvedValue([mock()]); workflowTagMappingRepository.find.mockResolvedValue([mock()]);
// Act // Act
const result = await service.exportTagsToWorkFolder(); const result = await service.exportTagsToWorkFolder(globalAdminContext);
// Assert // Assert
expect(result.count).toBe(1); expect(result.count).toBe(1);
@@ -184,7 +195,7 @@ describe('SourceControlExportService', () => {
tagRepository.find.mockResolvedValue([]); tagRepository.find.mockResolvedValue([]);
// Act // Act
const result = await service.exportTagsToWorkFolder(); const result = await service.exportTagsToWorkFolder(globalAdminContext);
// Assert // Assert
expect(result.count).toBe(0); expect(result.count).toBe(0);
@@ -201,7 +212,7 @@ describe('SourceControlExportService', () => {
workflowRepository.find.mockResolvedValue([mock()]); workflowRepository.find.mockResolvedValue([mock()]);
// Act // Act
const result = await service.exportFoldersToWorkFolder(); const result = await service.exportFoldersToWorkFolder(globalAdminContext);
// Assert // Assert
expect(result.count).toBe(1); expect(result.count).toBe(1);
@@ -213,7 +224,7 @@ describe('SourceControlExportService', () => {
folderRepository.find.mockResolvedValue([]); folderRepository.find.mockResolvedValue([]);
// Act // Act
const result = await service.exportFoldersToWorkFolder(); const result = await service.exportFoldersToWorkFolder(globalAdminContext);
// Assert // Assert
expect(result.count).toBe(0); expect(result.count).toBe(0);

View File

@@ -31,6 +31,7 @@ describe('SourceControlController', () => {
controller = new SourceControlController( controller = new SourceControlController(
sourceControlService, sourceControlService,
sourceControlPreferencesService, sourceControlPreferencesService,
mock(),
eventService, eventService,
); );
}); });

View File

@@ -31,13 +31,17 @@ import {
getFoldersPath, getFoldersPath,
getVariablesPath, getVariablesPath,
getWorkflowExportPath, getWorkflowExportPath,
readFoldersFromSourceControlFile,
readTagAndMappingsFromSourceControlFile,
sourceControlFoldersExistCheck, sourceControlFoldersExistCheck,
stringContainsExpression, stringContainsExpression,
} from './source-control-helper.ee'; } from './source-control-helper.ee';
import { SourceControlScopedService } from './source-control-scoped.service';
import type { ExportResult } from './types/export-result'; import type { ExportResult } from './types/export-result';
import type { ExportableCredential } from './types/exportable-credential'; import type { ExportableCredential } from './types/exportable-credential';
import type { ExportableWorkflow } from './types/exportable-workflow'; import type { ExportableWorkflow } from './types/exportable-workflow';
import type { ResourceOwner } from './types/resource-owner'; import type { ResourceOwner } from './types/resource-owner';
import type { SourceControlContext } from './types/source-control-context';
import { VariablesService } from '../variables/variables.service.ee'; import { VariablesService } from '../variables/variables.service.ee';
@Service() @Service()
@@ -57,6 +61,7 @@ export class SourceControlExportService {
private readonly workflowRepository: WorkflowRepository, private readonly workflowRepository: WorkflowRepository,
private readonly workflowTagMappingRepository: WorkflowTagMappingRepository, private readonly workflowTagMappingRepository: WorkflowTagMappingRepository,
private readonly folderRepository: FolderRepository, private readonly folderRepository: FolderRepository,
private readonly sourceControlScopedService: SourceControlScopedService,
instanceSettings: InstanceSettings, instanceSettings: InstanceSettings,
) { ) {
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER); this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
@@ -214,7 +219,7 @@ export class SourceControlExportService {
} }
} }
async exportFoldersToWorkFolder(): Promise<ExportResult> { async exportFoldersToWorkFolder(context: SourceControlContext): Promise<ExportResult> {
try { try {
sourceControlFoldersExistCheck([this.gitFolder]); sourceControlFoldersExistCheck([this.gitFolder]);
const folders = await this.folderRepository.find({ const folders = await this.folderRepository.find({
@@ -231,6 +236,7 @@ export class SourceControlExportService {
id: true, id: true,
}, },
}, },
where: this.sourceControlScopedService.getFoldersInAdminProjectsFromContextFilter(context),
}); });
if (folders.length === 0) { if (folders.length === 0) {
@@ -241,19 +247,38 @@ export class SourceControlExportService {
}; };
} }
const allowedProjects =
await this.sourceControlScopedService.getAdminProjectsFromContext(context);
const fileName = getFoldersPath(this.gitFolder); const fileName = getFoldersPath(this.gitFolder);
const existingFolders = await readFoldersFromSourceControlFile(fileName);
// keep all folders that are not accessible by the current user
// if allowedProjects is undefined, all folders are accessible by the current user
const foldersToKeepUnchanged =
allowedProjects === undefined
? []
: existingFolders.folders.filter((folder) => {
return !allowedProjects.some((project) => project.id === folder.homeProjectId);
});
const newFolders = foldersToKeepUnchanged.concat(
...folders.map((f) => ({
id: f.id,
name: f.name,
parentFolderId: f.parentFolder?.id ?? null,
homeProjectId: f.homeProject.id,
createdAt: f.createdAt.toISOString(),
updatedAt: f.updatedAt.toISOString(),
})),
);
await fsWriteFile( await fsWriteFile(
fileName, fileName,
JSON.stringify( JSON.stringify(
{ {
folders: folders.map((f) => ({ folders: newFolders,
id: f.id,
name: f.name,
parentFolderId: f.parentFolder?.id ?? null,
homeProjectId: f.homeProject.id,
createdAt: f.createdAt.toISOString(),
updatedAt: f.updatedAt.toISOString(),
})),
}, },
null, null,
2, 2,
@@ -274,7 +299,7 @@ export class SourceControlExportService {
} }
} }
async exportTagsToWorkFolder(): Promise<ExportResult> { async exportTagsToWorkFolder(context: SourceControlContext): Promise<ExportResult> {
try { try {
sourceControlFoldersExistCheck([this.gitFolder]); sourceControlFoldersExistCheck([this.gitFolder]);
const tags = await this.tagRepository.find(); const tags = await this.tagRepository.find();
@@ -286,14 +311,33 @@ export class SourceControlExportService {
files: [], files: [],
}; };
} }
const mappings = await this.workflowTagMappingRepository.find(); const mappingsOfAllowedWorkflows = await this.workflowTagMappingRepository.find({
where:
this.sourceControlScopedService.getWorkflowTagMappingInAdminProjectsFromContextFilter(
context,
),
});
const allowedWorkflows = await this.workflowRepository.find({
where:
this.sourceControlScopedService.getWorkflowsInAdminProjectsFromContextFilter(context),
});
const fileName = path.join(this.gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE); const fileName = path.join(this.gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE);
const existingTagsAndMapping = await readTagAndMappingsFromSourceControlFile(fileName);
// keep all mappings that are not accessible by the current user
const mappingsToKeep = existingTagsAndMapping.mappings.filter((mapping) => {
return !allowedWorkflows.some(
(allowedWorkflow) => allowedWorkflow.id === mapping.workflowId,
);
});
await fsWriteFile( await fsWriteFile(
fileName, fileName,
JSON.stringify( JSON.stringify(
{ {
// overwrite all tags
tags: tags.map((tag) => ({ id: tag.id, name: tag.name })), tags: tags.map((tag) => ({ id: tag.id, name: tag.name })),
mappings, mappings: mappingsToKeep.concat(mappingsOfAllowedWorkflows),
}, },
null, null,
2, 2,

View File

@@ -1,10 +1,12 @@
import type { SourceControlledFile } from '@n8n/api-types'; import type { SourceControlledFile } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common'; import { Logger } from '@n8n/backend-common';
import type { TagEntity, WorkflowTagMapping } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { generateKeyPairSync } from 'crypto'; import { generateKeyPairSync } from 'crypto';
import { constants as fsConstants, mkdirSync, accessSync } from 'fs'; import { constants as fsConstants, mkdirSync, accessSync } from 'fs';
import { UserError } from 'n8n-workflow'; import { jsonParse, UserError } from 'n8n-workflow';
import { ok } from 'node:assert/strict'; import { ok } from 'node:assert/strict';
import { readFile as fsReadFile } from 'node:fs/promises';
import path from 'path'; import path from 'path';
import { License } from '@/license'; import { License } from '@/license';
@@ -16,6 +18,7 @@ import {
SOURCE_CONTROL_TAGS_EXPORT_FILE, SOURCE_CONTROL_TAGS_EXPORT_FILE,
SOURCE_CONTROL_VARIABLES_EXPORT_FILE, SOURCE_CONTROL_VARIABLES_EXPORT_FILE,
} from './constants'; } from './constants';
import type { ExportedFolders } from './types/exportable-folders';
import type { KeyPair } from './types/key-pair'; import type { KeyPair } from './types/key-pair';
import type { KeyPairType } from './types/key-pair-type'; import type { KeyPairType } from './types/key-pair-type';
import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id'; import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id';
@@ -47,6 +50,22 @@ export function getFoldersPath(gitFolder: string): string {
return path.join(gitFolder, SOURCE_CONTROL_FOLDERS_EXPORT_FILE); return path.join(gitFolder, SOURCE_CONTROL_FOLDERS_EXPORT_FILE);
} }
export async function readTagAndMappingsFromSourceControlFile(file: string): Promise<{
tags: TagEntity[];
mappings: WorkflowTagMapping[];
}> {
return jsonParse<{ tags: TagEntity[]; mappings: WorkflowTagMapping[] }>(
await fsReadFile(file, { encoding: 'utf8' }),
{ fallbackValue: { tags: [], mappings: [] } },
);
}
export async function readFoldersFromSourceControlFile(file: string): Promise<ExportedFolders> {
return jsonParse<ExportedFolders>(await fsReadFile(file, { encoding: 'utf8' }), {
fallbackValue: { folders: [] },
});
}
export function sourceControlFoldersExistCheck( export function sourceControlFoldersExistCheck(
folders: string[], folders: string[],
createIfNotExists = true, createIfNotExists = true,

View File

@@ -8,10 +8,14 @@ import {
type WorkflowTagMapping, type WorkflowTagMapping,
} from '@n8n/db'; } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { FindOptionsWhere } from '@n8n/typeorm'; import type { FindOptionsWhere } from '@n8n/typeorm';
import type { SourceControlContext } from './types/source-control-context'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import type { AuthenticatedRequest } from '@/requests';
import { SourceControlContext } from './types/source-control-context';
@Service() @Service()
export class SourceControlScopedService { export class SourceControlScopedService {
@@ -20,6 +24,19 @@ export class SourceControlScopedService {
private readonly workflowRepository: WorkflowRepository, private readonly workflowRepository: WorkflowRepository,
) {} ) {}
async ensureIsAllowedToPush(req: AuthenticatedRequest) {
if (hasGlobalScope(req.user, 'sourceControl:push')) {
return;
}
const ctx = new SourceControlContext(req.user);
const projectsWithAdminAccess = await this.getAdminProjectsFromContext(ctx);
if (projectsWithAdminAccess?.length === 0) {
throw new ForbiddenError('You are not allowed to push changes');
}
}
async getAdminProjectsFromContext(context: SourceControlContext): Promise<Project[] | undefined> { async getAdminProjectsFromContext(context: SourceControlContext): Promise<Project[] | undefined> {
if (context.hasAccessToAllProjects()) { if (context.hasAccessToAllProjects()) {
// In case the user is a global admin or owner, we don't need a filter // In case the user is a global admin or owner, we don't need a filter
@@ -73,10 +90,10 @@ export class SourceControlScopedService {
getFoldersInAdminProjectsFromContextFilter( getFoldersInAdminProjectsFromContextFilter(
context: SourceControlContext, context: SourceControlContext,
): FindOptionsWhere<Folder> | undefined { ): FindOptionsWhere<Folder> {
if (context.hasAccessToAllProjects()) { if (context.hasAccessToAllProjects()) {
// In case the user is a global admin or owner, we don't need a filter // In case the user is a global admin or owner, we don't need a filter
return; return {};
} }
// We build a filter to only select folder, that belong to a team project // We build a filter to only select folder, that belong to a team project
@@ -88,10 +105,10 @@ export class SourceControlScopedService {
getWorkflowsInAdminProjectsFromContextFilter( getWorkflowsInAdminProjectsFromContextFilter(
context: SourceControlContext, context: SourceControlContext,
): FindOptionsWhere<WorkflowEntity> | undefined { ): FindOptionsWhere<WorkflowEntity> {
if (context.hasAccessToAllProjects()) { if (context.hasAccessToAllProjects()) {
// In case the user is a global admin or owner, we don't need a filter // In case the user is a global admin or owner, we don't need a filter
return; return {};
} }
// We build a filter to only select workflows, that belong to a team project // We build a filter to only select workflows, that belong to a team project
@@ -106,10 +123,10 @@ export class SourceControlScopedService {
getCredentialsInAdminProjectsFromContextFilter( getCredentialsInAdminProjectsFromContextFilter(
context: SourceControlContext, context: SourceControlContext,
): FindOptionsWhere<CredentialsEntity> | undefined { ): FindOptionsWhere<CredentialsEntity> {
if (context.hasAccessToAllProjects()) { if (context.hasAccessToAllProjects()) {
// In case the user is a global admin or owner, we don't need a filter // In case the user is a global admin or owner, we don't need a filter
return; return {};
} }
// We build a filter to only select workflows, that belong to a team project // We build a filter to only select workflows, that belong to a team project
@@ -124,10 +141,10 @@ export class SourceControlScopedService {
getWorkflowTagMappingInAdminProjectsFromContextFilter( getWorkflowTagMappingInAdminProjectsFromContextFilter(
context: SourceControlContext, context: SourceControlContext,
): FindOptionsWhere<WorkflowTagMapping> | undefined { ): FindOptionsWhere<WorkflowTagMapping> {
if (context.hasAccessToAllProjects()) { if (context.hasAccessToAllProjects()) {
// In case the user is a global admin or owner, we don't need a filter // In case the user is a global admin or owner, we don't need a filter
return; return {};
} }
// We build a filter to only select workflows, that belong to a team project // We build a filter to only select workflows, that belong to a team project

View File

@@ -15,6 +15,7 @@ import {
} from './middleware/source-control-enabled-middleware.ee'; } from './middleware/source-control-enabled-middleware.ee';
import { getRepoType } from './source-control-helper.ee'; import { getRepoType } from './source-control-helper.ee';
import { SourceControlPreferencesService } from './source-control-preferences.service.ee'; import { SourceControlPreferencesService } from './source-control-preferences.service.ee';
import { SourceControlScopedService } from './source-control-scoped.service';
import { SourceControlService } from './source-control.service.ee'; import { SourceControlService } from './source-control.service.ee';
import type { ImportResult } from './types/import-result'; import type { ImportResult } from './types/import-result';
import { SourceControlRequest } from './types/requests'; import { SourceControlRequest } from './types/requests';
@@ -26,6 +27,7 @@ export class SourceControlController {
constructor( constructor(
private readonly sourceControlService: SourceControlService, private readonly sourceControlService: SourceControlService,
private readonly sourceControlPreferencesService: SourceControlPreferencesService, private readonly sourceControlPreferencesService: SourceControlPreferencesService,
private readonly sourceControlScopedService: SourceControlScopedService,
private readonly eventService: EventService, private readonly eventService: EventService,
) {} ) {}
@@ -164,12 +166,13 @@ export class SourceControlController {
} }
@Post('/push-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] }) @Post('/push-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
@GlobalScope('sourceControl:push')
async pushWorkfolder( async pushWorkfolder(
req: AuthenticatedRequest, req: AuthenticatedRequest,
res: express.Response, res: express.Response,
@Body payload: PushWorkFolderRequestDto, @Body payload: PushWorkFolderRequestDto,
): Promise<SourceControlledFile[]> { ): Promise<SourceControlledFile[]> {
await this.sourceControlScopedService.ensureIsAllowedToPush(req);
try { try {
await this.sourceControlService.setGitUserDetails( await this.sourceControlService.setGitUserDetails(
`${req.user.firstName} ${req.user.lastName}`, `${req.user.firstName} ${req.user.lastName}`,

View File

@@ -233,7 +233,9 @@ export class SourceControlService {
throw new BadRequestError('Cannot push onto read-only branch.'); throw new BadRequestError('Cannot push onto read-only branch.');
} }
const filesToPush = options.fileNames.map((file) => { const context = new SourceControlContext(user);
let filesToPush = options.fileNames.map((file) => {
const normalizedPath = normalizeAndValidateSourceControlledFilePath( const normalizedPath = normalizeAndValidateSourceControlledFilePath(
this.gitFolder, this.gitFolder,
file.file, file.file,
@@ -245,23 +247,39 @@ export class SourceControlService {
}; };
}); });
// only determine file status if not provided by the frontend const allowedResources = (await this.getStatus(user, {
let statusResult: SourceControlledFile[] = filesToPush; direction: 'push',
if (statusResult.length === 0) { verbose: false,
statusResult = (await this.getStatus(user, { preferLocalVersion: true,
direction: 'push', })) as SourceControlledFile[];
verbose: false,
preferLocalVersion: true, // Fallback to all allowed resources if no fileNames are provided
})) as SourceControlledFile[]; if (!filesToPush.length) {
filesToPush = allowedResources;
} }
// If fileNames are provided, we need to check if they are allowed
if (
filesToPush !== allowedResources &&
filesToPush.some(
(file) =>
!allowedResources.some((allowed) => {
return allowed.id === file.id && allowed.type === file.type;
}),
)
) {
throw new ForbiddenError('You are not allowed to push these changes');
}
let statusResult: SourceControlledFile[] = filesToPush;
if (!options.force) { if (!options.force) {
const possibleConflicts = statusResult?.filter((file) => file.conflict); const possibleConflicts = filesToPush?.filter((file) => file.conflict);
if (possibleConflicts?.length > 0) { if (possibleConflicts?.length > 0) {
return { return {
statusCode: 409, statusCode: 409,
pushResult: undefined, pushResult: undefined,
statusResult, statusResult: filesToPush,
}; };
} }
} }
@@ -307,13 +325,13 @@ export class SourceControlService {
const tagChanges = filesToPush.find((e) => e.type === 'tags'); const tagChanges = filesToPush.find((e) => e.type === 'tags');
if (tagChanges) { if (tagChanges) {
filesToBePushed.add(tagChanges.file); filesToBePushed.add(tagChanges.file);
await this.sourceControlExportService.exportTagsToWorkFolder(); await this.sourceControlExportService.exportTagsToWorkFolder(context);
} }
const folderChanges = filesToPush.find((e) => e.type === 'folders'); const folderChanges = filesToPush.find((e) => e.type === 'folders');
if (folderChanges) { if (folderChanges) {
filesToBePushed.add(folderChanges.file); filesToBePushed.add(folderChanges.file);
await this.sourceControlExportService.exportFoldersToWorkFolder(); await this.sourceControlExportService.exportFoldersToWorkFolder(context);
} }
const variablesChanges = filesToPush.find((e) => e.type === 'variables'); const variablesChanges = filesToPush.find((e) => e.type === 'variables');
@@ -324,12 +342,8 @@ export class SourceControlService {
await this.gitService.stage(filesToBePushed, filesToBeDeleted); await this.gitService.stage(filesToBePushed, filesToBeDeleted);
for (let i = 0; i < statusResult.length; i++) { // Set all results as pushed
// eslint-disable-next-line @typescript-eslint/no-loop-func statusResult.forEach((result) => (result.pushed = true));
if (filesToPush.find((file) => file.file === statusResult[i].file)) {
statusResult[i].pushed = true;
}
}
await this.gitService.commit(options.commitMessage ?? 'Updated Workfolder'); await this.gitService.commit(options.commitMessage ?? 'Updated Workfolder');

View File

@@ -6,3 +6,7 @@ export type ExportableFolder = {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
export type ExportedFolders = {
folders: ExportableFolder[];
};

View File

@@ -26,6 +26,12 @@ export async function createTag(attributes: Partial<TagEntity> = {}, workflow?:
return tag; return tag;
} }
export async function updateTag(tag: TagEntity, attributes: Partial<TagEntity>) {
const tagRepository = Container.get(TagRepository);
const updatedTag = tagRepository.merge(tag, attributes);
return await tagRepository.save(updatedTag);
}
export async function assignTagToWorkflow(tag: TagEntity, workflow: WorkflowEntity) { export async function assignTagToWorkflow(tag: TagEntity, workflow: WorkflowEntity) {
const mappingRepository = Container.get(WorkflowTagMappingRepository); const mappingRepository = Container.get(WorkflowTagMappingRepository);