mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-24 04:59:13 +00:00
feat(core): Validate commit content for project admin role (#15687)
Co-authored-by: Guillaume Jacquart <jacquart.guillaume@gmail.com>
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ describe('SourceControlController', () => {
|
|||||||
controller = new SourceControlController(
|
controller = new SourceControlController(
|
||||||
sourceControlService,
|
sourceControlService,
|
||||||
sourceControlPreferencesService,
|
sourceControlPreferencesService,
|
||||||
|
mock(),
|
||||||
eventService,
|
eventService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,7 @@ export type ExportableFolder = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ExportedFolders = {
|
||||||
|
folders: ExportableFolder[];
|
||||||
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user