diff --git a/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts b/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts index 8488469d35..c130d2383c 100644 --- a/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts +++ b/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts @@ -19,6 +19,12 @@ export const SOURCE_CONTROL_FILE_STATUS = FileStatusSchema.Values; const FileLocationSchema = z.enum(['local', 'remote']); export const SOURCE_CONTROL_FILE_LOCATION = FileLocationSchema.Values; +const ResourceOwnerSchema = z.object({ + type: z.enum(['personal', 'team']), + projectId: z.string(), + projectName: z.string(), +}); + export const SourceControlledFileSchema = z.object({ file: z.string(), id: z.string(), @@ -29,6 +35,7 @@ export const SourceControlledFileSchema = z.object({ conflict: z.boolean(), updatedAt: z.string(), pushed: z.boolean().optional(), + owner: ResourceOwnerSchema.optional(), // Resource owner can be a personal email or team information }); export type SourceControlledFile = z.infer; diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts index 2c2fd11f47..101ba718ba 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts @@ -66,9 +66,14 @@ describe('SourceControlImportService', () => { id: 'workflow1', versionId: 'v1', name: 'Test Workflow', + owner: { + type: 'personal', + personalEmail: 'email@email.com', + }, }; fsReadFile.mockResolvedValue(JSON.stringify(mockWorkflowData)); + sourceControlScopedService.getAdminProjectsFromContext.mockResolvedValueOnce([]); const result = await service.getRemoteVersionIdsFromFiles(globalAdminContext); expect(fsReadFile).toHaveBeenCalledWith(mockWorkflowFile, { encoding: 'utf8' }); diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts index 29d151b909..e562149fe1 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts @@ -14,7 +14,7 @@ import { SourceControlService } from '@/environments.ee/source-control/source-co import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import type { SourceControlImportService } from '../source-control-import.service.ee'; -import type { ExportableCredential } from '../types/exportable-credential'; +import type { StatusExportableCredential } from '../types/exportable-credential'; import type { SourceControlWorkflowVersionId } from '../types/source-control-workflow-version-id'; describe('SourceControlService', () => { @@ -157,7 +157,7 @@ describe('SourceControlService', () => { // Pushing this is conflict free. sourceControlImportService.getRemoteCredentialsFromFiles.mockResolvedValue([]); sourceControlImportService.getLocalCredentialsFromDb.mockResolvedValue([ - mock(), + mock(), ]); // Define a variable that does only exist locally. diff --git a/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts index eea810df3d..344834d91b 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts @@ -40,7 +40,7 @@ import { SourceControlScopedService } from './source-control-scoped.service'; import type { ExportResult } from './types/export-result'; import type { ExportableCredential } from './types/exportable-credential'; import type { ExportableWorkflow } from './types/exportable-workflow'; -import type { ResourceOwner } from './types/resource-owner'; +import type { RemoteResourceOwner } from './types/resource-owner'; import type { SourceControlContext } from './types/source-control-context'; import { VariablesService } from '../variables/variables.service.ee'; @@ -99,7 +99,7 @@ export class SourceControlExportService { private async writeExportableWorkflowsToExportFolder( workflowsToBeExported: IWorkflowDb[], - owners: Record, + owners: Record, ) { await Promise.all( workflowsToBeExported.map(async (e) => { @@ -133,7 +133,7 @@ export class SourceControlExportService { }); // determine owner of each workflow to be exported - const owners: Record = {}; + const owners: Record = {}; sharedWorkflows.forEach((sharedWorkflow) => { const project = sharedWorkflow.project; @@ -154,6 +154,8 @@ export class SourceControlExportService { } owners[sharedWorkflow.workflowId] = { type: 'personal', + projectId: project.id, + projectName: project.name, personalEmail: ownerRelation.user.email, }; } else if (project.type === 'team') { @@ -256,12 +258,11 @@ export class SourceControlExportService { // 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 foldersToKeepUnchanged = context.hasAccessToAllProjects() + ? existingFolders.folders + : existingFolders.folders.filter((folder) => { + return !allowedProjects.some((project) => project.id === folder.homeProjectId); + }); const newFolders = foldersToKeepUnchanged.concat( ...folders.map((f) => ({ @@ -402,7 +403,7 @@ export class SourceControlExportService { const { name, type, data, id } = sharing.credentials; const credentials = new Credentials({ id, name }, type, data); - let owner: ResourceOwner | null = null; + let owner: RemoteResourceOwner | null = null; if (sharing.project.type === 'personal') { const ownerRelation = sharing.project.projectRelations.find( (pr) => pr.role === 'project:personalOwner', @@ -410,6 +411,8 @@ export class SourceControlExportService { if (ownerRelation) { owner = { type: 'personal', + projectId: sharing.project.id, + projectName: sharing.project.name, personalEmail: ownerRelation.user.email, }; } diff --git a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts index 7751423eb9..537131ab0a 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts @@ -41,14 +41,67 @@ import { } from './constants'; import { getCredentialExportPath, getWorkflowExportPath } from './source-control-helper.ee'; import { SourceControlScopedService } from './source-control-scoped.service'; -import type { ExportableCredential } from './types/exportable-credential'; +import type { + ExportableCredential, + StatusExportableCredential, +} from './types/exportable-credential'; import type { ExportableFolder } from './types/exportable-folders'; import type { ExportableTags } from './types/exportable-tags'; -import type { ResourceOwner } from './types/resource-owner'; +import type { StatusResourceOwner, RemoteResourceOwner } from './types/resource-owner'; import type { SourceControlContext } from './types/source-control-context'; import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id'; import { VariablesService } from '../variables/variables.service.ee'; +const findOwnerProject = ( + owner: RemoteResourceOwner, + accessibleProjects: Project[], +): Project | undefined => { + if (typeof owner === 'string') { + return accessibleProjects.find((project) => + project.projectRelations.some( + (r) => r.role === 'project:personalOwner' && r.user.email === owner, + ), + ); + } + if (owner.type === 'personal') { + return accessibleProjects.find( + (project) => + project.type === 'personal' && + project.projectRelations.some( + (r) => r.role === 'project:personalOwner' && r.user.email === owner.personalEmail, + ), + ); + } + return accessibleProjects.find( + (project) => project.type === 'team' && project.id === owner.teamId, + ); +}; + +const getOwnerFromProject = (remoteOwnerProject: Project): StatusResourceOwner | undefined => { + let owner: StatusResourceOwner | undefined = undefined; + + if (remoteOwnerProject?.type === 'personal') { + const personalEmail = remoteOwnerProject.projectRelations?.find( + (r) => r.role === 'project:personalOwner', + )?.user?.email; + + if (personalEmail) { + owner = { + type: 'personal', + projectId: remoteOwnerProject.id, + projectName: remoteOwnerProject.name, + }; + } + } else if (remoteOwnerProject?.type === 'team') { + owner = { + type: 'team', + projectId: remoteOwnerProject.id, + projectName: remoteOwnerProject.name, + }; + } + return owner; +}; + @Service() export class SourceControlImportService { private gitFolder: string; @@ -109,26 +162,21 @@ export class SourceControlImportService { if (!remote?.id) { return false; } - if (Array.isArray(accessibleProjects)) { - const owner = remote.owner; - // The workflow `remote` belongs not to a project, that the context has access to - return ( - typeof owner === 'object' && - owner?.type === 'team' && - accessibleProjects.some((project) => project.id === owner.teamId) - ); - } - return true; + return ( + context.hasAccessToAllProjects() || findOwnerProject(remote.owner, accessibleProjects) + ); }) .map((remote) => { + const project = findOwnerProject(remote.owner, accessibleProjects); return { id: remote.id, - versionId: remote.versionId, + versionId: remote.versionId ?? '', name: remote.name, parentFolderId: remote.parentFolderId, remoteId: remote.id, filename: getWorkflowExportPath(remote.id, this.workflowExportFolder), - } as SourceControlWorkflowVersionId; + owner: project ? getOwnerFromProject(project) : undefined, + }; }); return remoteWorkflowFilesParsed; @@ -178,6 +226,13 @@ export class SourceControlImportService { const localWorkflows = await this.workflowRepository.find({ relations: { parentFolder: true, + shared: { + project: { + projectRelations: { + user: true, + }, + }, + }, }, select: { id: true, @@ -187,9 +242,24 @@ export class SourceControlImportService { parentFolder: { id: true, }, + shared: { + project: { + id: true, + name: true, + type: true, + projectRelations: { + role: true, + user: { + email: true, + }, + }, + }, + role: true, + }, }, where: this.sourceControlScopedService.getWorkflowsInAdminProjectsFromContextFilter(context), }); + return localWorkflows.map((local) => { let updatedAt: Date; if (local.updatedAt instanceof Date) { @@ -203,6 +273,8 @@ export class SourceControlImportService { }); updatedAt = isNaN(Date.parse(local.updatedAt)) ? new Date() : new Date(local.updatedAt); } + const remoteOwnerProject = local.shared?.find((s) => s.role === 'workflow:owner')?.project; + return { id: local.id, versionId: local.versionId, @@ -211,13 +283,14 @@ export class SourceControlImportService { parentFolderId: local.parentFolder?.id ?? null, filename: getWorkflowExportPath(local.id, this.workflowExportFolder), updatedAt: updatedAt.toISOString(), + owner: remoteOwnerProject ? getOwnerFromProject(remoteOwnerProject) : undefined, }; - }) as SourceControlWorkflowVersionId[]; + }); } async getRemoteCredentialsFromFiles( context: SourceControlContext, - ): Promise> { + ): Promise { const remoteCredentialFiles = await glob('*.json', { cwd: this.credentialExportFolder, absolute: true, @@ -241,43 +314,79 @@ export class SourceControlImportService { if (!remote?.id) { return false; } - if (Array.isArray(accessibleProjects)) { - const owner = remote.ownedBy; - // The credential `remote` belongs not to a project, that the context has access to - return ( - typeof owner === 'object' && - owner?.type === 'team' && - accessibleProjects.some((project) => project.id === owner.teamId) - ); - } - return true; + const owner = remote.ownedBy; + // The credential `remote` belongs not to a project, that the context has access to + return ( + !owner || context.hasAccessToAllProjects() || findOwnerProject(owner, accessibleProjects) + ); }) .map((remote) => { + const project = remote.ownedBy + ? findOwnerProject(remote.ownedBy, accessibleProjects) + : null; return { ...remote, + ownedBy: project + ? { + type: project.type, + projectId: project.id, + projectName: project.name, + } + : undefined, filename: getCredentialExportPath(remote.id, this.credentialExportFolder), }; }); - return remoteCredentialFilesParsed.filter((e) => e !== undefined) as Array< - ExportableCredential & { filename: string } - >; + return remoteCredentialFilesParsed.filter( + (e) => e !== undefined, + ) as StatusExportableCredential[]; } async getLocalCredentialsFromDb( context: SourceControlContext, - ): Promise> { + ): Promise { const localCredentials = await this.credentialsRepository.find({ - select: ['id', 'name', 'type'], + relations: { + shared: { + project: { + projectRelations: { + user: true, + }, + }, + }, + }, + select: { + id: true, + name: true, + type: true, + shared: { + project: { + id: true, + name: true, + type: true, + projectRelations: { + role: true, + user: { + email: true, + }, + }, + }, + role: true, + }, + }, where: this.sourceControlScopedService.getCredentialsInAdminProjectsFromContextFilter(context), }); - return localCredentials.map((local) => ({ - id: local.id, - name: local.name, - type: local.type, - filename: getCredentialExportPath(local.id, this.credentialExportFolder), - })) as Array; + return localCredentials.map((local) => { + const remoteOwnerProject = local.shared?.find((s) => s.role === 'credential:owner')?.project; + return { + id: local.id, + name: local.name, + type: local.type, + filename: getCredentialExportPath(local.id, this.credentialExportFolder), + ownedBy: remoteOwnerProject ? getOwnerFromProject(remoteOwnerProject) : undefined, + }; + }) as StatusExportableCredential[]; } async getRemoteVariablesFromFile(): Promise { @@ -316,11 +425,11 @@ export class SourceControlImportService { const accessibleProjects = await this.sourceControlScopedService.getAdminProjectsFromContext(context); - if (Array.isArray(accessibleProjects)) { - mappedFolders.folders = mappedFolders.folders.filter((folder) => + mappedFolders.folders = mappedFolders.folders.filter( + (folder) => + context.hasAccessToAllProjects() || accessibleProjects.some((project) => project.id === folder.homeProjectId), - ); - } + ); return mappedFolders; } @@ -796,7 +905,7 @@ export class SourceControlImportService { } } - private async findOrCreateOwnerProject(owner: ResourceOwner): Promise { + private async findOrCreateOwnerProject(owner: RemoteResourceOwner): Promise { if (typeof owner === 'string' || owner.type === 'personal') { const email = typeof owner === 'string' ? owner : owner.personalEmail; const user = await this.userRepository.findOne({ @@ -834,7 +943,7 @@ export class SourceControlImportService { assertNever(owner); - const errorOwner = owner as ResourceOwner; + const errorOwner = owner as RemoteResourceOwner; throw new UnexpectedError( `Unknown resource owner type "${ typeof errorOwner !== 'string' ? errorOwner.type : 'UNKNOWN' diff --git a/packages/cli/src/environments.ee/source-control/source-control-scoped.service.ts b/packages/cli/src/environments.ee/source-control/source-control-scoped.service.ts index e0516a1fed..f522377ddc 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-scoped.service.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-scoped.service.ts @@ -37,19 +37,28 @@ export class SourceControlScopedService { } } - async getAdminProjectsFromContext(context: SourceControlContext): Promise { + async getAdminProjectsFromContext(context: SourceControlContext): Promise { if (context.hasAccessToAllProjects()) { // In case the user is a global admin or owner, we don't need a filter - return; + return await this.projectRepository.find({ + relations: { + projectRelations: { + user: true, + }, + }, + }); } return await this.projectRepository.find({ relations: { - projectRelations: true, + projectRelations: { + user: true, + }, }, select: { id: true, name: true, + type: true, }, where: this.getAdminProjectsByContextFilter(context), }); diff --git a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts index 3b26240008..2ae2eebd96 100644 --- a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts @@ -42,7 +42,7 @@ import { } from './source-control-helper.ee'; import { SourceControlImportService } from './source-control-import.service.ee'; import { SourceControlPreferencesService } from './source-control-preferences.service.ee'; -import type { ExportableCredential } from './types/exportable-credential'; +import type { StatusExportableCredential } from './types/exportable-credential'; import type { ExportableFolder } from './types/exportable-folders'; import type { ImportResult } from './types/import-result'; import { SourceControlContext } from './types/source-control-context'; @@ -663,6 +663,7 @@ export class SourceControlService { conflict: false, file: item.filename, updatedAt: item.updatedAt ?? new Date().toISOString(), + owner: item.owner, }); }); @@ -676,6 +677,7 @@ export class SourceControlService { conflict: options.direction === 'push' ? false : true, file: item.filename, updatedAt: item.updatedAt ?? new Date().toISOString(), + owner: item.owner, }); }); @@ -689,6 +691,7 @@ export class SourceControlService { conflict: true, file: item.filename, updatedAt: item.updatedAt ?? new Date().toISOString(), + owner: item.owner, }); }); @@ -719,11 +722,7 @@ export class SourceControlService { ); // only compares the name, since that is the only change synced for credentials - const credModifiedInEither: Array< - ExportableCredential & { - filename: string; - } - > = []; + const credModifiedInEither: StatusExportableCredential[] = []; credLocalIds.forEach((local) => { const mismatchingCreds = credRemoteIds.find((remote) => { return remote.id === local.id && (remote.name !== local.name || remote.type !== local.type); @@ -746,6 +745,7 @@ export class SourceControlService { conflict: false, file: item.filename, updatedAt: new Date().toISOString(), + owner: item.ownedBy, }); }); @@ -759,6 +759,7 @@ export class SourceControlService { conflict: options.direction === 'push' ? false : true, file: item.filename, updatedAt: new Date().toISOString(), + owner: item.ownedBy, }); }); @@ -772,6 +773,7 @@ export class SourceControlService { conflict: true, file: item.filename, updatedAt: new Date().toISOString(), + owner: item.ownedBy, }); }); return { diff --git a/packages/cli/src/environments.ee/source-control/types/exportable-credential.ts b/packages/cli/src/environments.ee/source-control/types/exportable-credential.ts index a15a8658b6..2ffb04d612 100644 --- a/packages/cli/src/environments.ee/source-control/types/exportable-credential.ts +++ b/packages/cli/src/environments.ee/source-control/types/exportable-credential.ts @@ -1,6 +1,6 @@ import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; -import type { ResourceOwner } from './resource-owner'; +import type { RemoteResourceOwner, StatusResourceOwner } from './resource-owner'; export interface ExportableCredential { id: string; @@ -12,5 +12,10 @@ export interface ExportableCredential { * Email of the user who owns this credential at the source instance. * Ownership is mirrored at target instance if user is also present there. */ - ownedBy: ResourceOwner | null; + ownedBy: RemoteResourceOwner | null; } + +export type StatusExportableCredential = ExportableCredential & { + filename: string; + ownedBy?: StatusResourceOwner; +}; diff --git a/packages/cli/src/environments.ee/source-control/types/exportable-workflow.ts b/packages/cli/src/environments.ee/source-control/types/exportable-workflow.ts index 7e123bfdf9..7a449071ca 100644 --- a/packages/cli/src/environments.ee/source-control/types/exportable-workflow.ts +++ b/packages/cli/src/environments.ee/source-control/types/exportable-workflow.ts @@ -1,6 +1,6 @@ import type { INode, IConnections, IWorkflowSettings } from 'n8n-workflow'; -import type { ResourceOwner } from './resource-owner'; +import type { RemoteResourceOwner } from './resource-owner'; export interface ExportableWorkflow { id: string; @@ -10,7 +10,7 @@ export interface ExportableWorkflow { settings?: IWorkflowSettings; triggerCount: number; versionId?: string; - owner: ResourceOwner; + owner: RemoteResourceOwner; parentFolderId: string | null; isArchived: boolean; } diff --git a/packages/cli/src/environments.ee/source-control/types/resource-owner.ts b/packages/cli/src/environments.ee/source-control/types/resource-owner.ts index 292ea9f181..7901060c26 100644 --- a/packages/cli/src/environments.ee/source-control/types/resource-owner.ts +++ b/packages/cli/src/environments.ee/source-control/types/resource-owner.ts @@ -1,7 +1,9 @@ -export type ResourceOwner = +export type RemoteResourceOwner = | string | { type: 'personal'; + projectId?: string; // Optional for retrocompatibility + projectName?: string; // Optional for retrocompatibility personalEmail: string; } | { @@ -9,3 +11,9 @@ export type ResourceOwner = teamId: string; teamName: string; }; + +export type StatusResourceOwner = { + type: 'personal' | 'team'; + projectId: string; + projectName: string; +}; diff --git a/packages/cli/src/environments.ee/source-control/types/source-control-workflow-version-id.ts b/packages/cli/src/environments.ee/source-control/types/source-control-workflow-version-id.ts index ab8106ac29..ccd7ee06bb 100644 --- a/packages/cli/src/environments.ee/source-control/types/source-control-workflow-version-id.ts +++ b/packages/cli/src/environments.ee/source-control/types/source-control-workflow-version-id.ts @@ -1,3 +1,5 @@ +import type { StatusResourceOwner } from './resource-owner'; + export interface SourceControlWorkflowVersionId { id: string; versionId: string; @@ -7,4 +9,5 @@ export interface SourceControlWorkflowVersionId { remoteId?: string; parentFolderId: string | null; updatedAt?: string; + owner?: StatusResourceOwner; } diff --git a/packages/cli/test/integration/environments/source-control.service.test.ts b/packages/cli/test/integration/environments/source-control.service.test.ts index 36a365a0c2..da79662f61 100644 --- a/packages/cli/test/integration/environments/source-control.service.test.ts +++ b/packages/cli/test/integration/environments/source-control.service.test.ts @@ -30,7 +30,7 @@ import { SourceControlService } from '@/environments.ee/source-control/source-co import type { ExportableCredential } from '@/environments.ee/source-control/types/exportable-credential'; import type { ExportableFolder } from '@/environments.ee/source-control/types/exportable-folders'; import type { ExportableWorkflow } from '@/environments.ee/source-control/types/exportable-workflow'; -import type { ResourceOwner } from '@/environments.ee/source-control/types/resource-owner'; +import type { RemoteResourceOwner } from '@/environments.ee/source-control/types/resource-owner'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { EventService } from '@/events/event.service'; @@ -68,7 +68,7 @@ function toExportableCredential( cred: CredentialsEntity, owner: Project | User, ): ExportableCredential { - let resourceOwner: ResourceOwner; + let resourceOwner: RemoteResourceOwner; if (owner instanceof Project) { resourceOwner = { @@ -97,7 +97,7 @@ function toExportableWorkflow( owner: Project | User, versionId?: string, ): ExportableWorkflow { - let resourceOwner: ResourceOwner; + let resourceOwner: RemoteResourceOwner; if (owner instanceof Project) { resourceOwner = {