Skip to content

Commit

Permalink
feat: Update source control to work with projects (#9202)
Browse files Browse the repository at this point in the history
  • Loading branch information
valya authored Apr 24, 2024
1 parent 437f8f3 commit 4aed020
Show file tree
Hide file tree
Showing 9 changed files with 329 additions and 69 deletions.
2 changes: 1 addition & 1 deletion packages/cli/src/databases/entities/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow';
import { Logger } from '@/Logger';
import { UserRepository } from '../repositories/user.repository';

export type ProjectType = 'personal' | 'team' | 'public';
export type ProjectType = 'personal' | 'team';

@Entity()
export class Project extends WithTimestampsAndStringId {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {

async findByCredentialIds(credentialIds: string[], role: CredentialSharingRole) {
return await this.find({
relations: { credentials: true },
relations: { credentials: true, project: { projectRelations: { user: true } } },
where: {
credentialsId: In(credentialIds),
role,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { Logger } from '@/Logger';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMapping.repository';
import type { ResourceOwner } from './types/resourceOwner';

@Service()
export class SourceControlExportService {
Expand Down Expand Up @@ -79,7 +80,7 @@ export class SourceControlExportService {

private async writeExportableWorkflowsToExportFolder(
workflowsToBeExported: WorkflowEntity[],
owners: Record<string, string>,
owners: Record<string, ResourceOwner>,
) {
await Promise.all(
workflowsToBeExported.map(async (e) => {
Expand Down Expand Up @@ -109,17 +110,37 @@ export class SourceControlExportService {
const workflows = await Container.get(WorkflowRepository).findByIds(workflowIds);

// determine owner of each workflow to be exported
const owners: Record<string, string> = {};
const owners: Record<string, ResourceOwner> = {};
sharedWorkflows.forEach((e) => {
const ownerRelation = e.project.projectRelations.find(
(pr) => pr.role === 'project:personalOwner',
);
const project = e.project;

if (!ownerRelation) {
if (!project) {
throw new ApplicationError(`Workflow ${e.workflow.display()} has no owner`);
}

owners[e.workflowId] = ownerRelation.user.email;
if (project.type === 'personal') {
const ownerRelation = project.projectRelations.find(
(pr) => pr.role === 'project:personalOwner',
);
if (!ownerRelation) {
throw new ApplicationError(`Workflow ${e.workflow.display()} has no owner`);
}
owners[e.workflowId] = {
type: 'personal',
personalEmail: ownerRelation.user.email,
};
} else if (project.type === 'team') {
owners[e.workflowId] = {
type: 'team',
teamId: project.id,
// TODO: remove when project name is not nullable
teamName: project.name!,
};
} else {
throw new ApplicationError(
`Workflow belongs to unknown project type: ${project.type as string}`,
);
}
});

// write the workflows to the export folder as json files
Expand Down Expand Up @@ -253,13 +274,32 @@ export class SourceControlExportService {
const { name, type, data, id } = sharing.credentials;
const credentials = new Credentials({ id, name }, type, data);

let owner: ResourceOwner | null = null;
if (sharing.project.type === 'personal') {
const ownerRelation = sharing.project.projectRelations.find(
(pr) => pr.role === 'project:personalOwner',
);
if (ownerRelation) {
owner = {
type: 'personal',
personalEmail: ownerRelation.user.email,
};
}
} else if (sharing.project.type === 'team') {
owner = {
type: 'team',
teamId: sharing.project.id,
// TODO: remove when project name is not nullable
teamName: sharing.project.name!,
};
}

const stub: ExportableCredential = {
id,
name,
type,
data: this.replaceCredentialData(credentials.getData()),
// TODO before RBAC merge
ownedBy: '',
ownedBy: owner,
};

const filePath = this.getCredentialsPath(id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import type { SourceControlledFile } from './types/sourceControlledFile';
import { VariablesService } from '../variables/variables.service.ee';
import { TagRepository } from '@db/repositories/tag.repository';
import { WorkflowRepository } from '@db/repositories/workflow.repository';
import { UserRepository } from '@db/repositories/user.repository';
import { Logger } from '@/Logger';
import { CredentialsRepository } from '@db/repositories/credentials.repository';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
Expand All @@ -35,6 +34,9 @@ import { WorkflowTagMappingRepository } from '@db/repositories/workflowTagMappin
import { VariablesRepository } from '@db/repositories/variables.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import type { Project } from '@/databases/entities/Project';
import type { ResourceOwner } from './types/resourceOwner';
import { assertNever } from '@/utils';
import { UserRepository } from '@/databases/repositories/user.repository';

@Service()
export class SourceControlImportService {
Expand Down Expand Up @@ -205,6 +207,8 @@ export class SourceControlImportService {
}

public async importWorkflowFromWorkFolder(candidates: SourceControlledFile[], userId: string) {
const personalProject =
await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId);
const workflowRunner = this.activeWorkflowRunner;
const candidateIds = candidates.map((c) => c.id);
const existingWorkflows = await Container.get(WorkflowRepository).findByIds(candidateIds, {
Expand All @@ -214,7 +218,6 @@ export class SourceControlImportService {
candidateIds,
{ select: ['workflowId', 'role', 'projectId'] },
);
const cachedOwnerIds = new Map<string, string>();
const importWorkflowsResult = await Promise.all(
candidates.map(async (candidate) => {
this.logger.debug(`Parsing workflow file ${candidate.file}`);
Expand All @@ -236,52 +239,26 @@ export class SourceControlImportService {
extra: { workflowId: importedWorkflow.id ?? 'new' },
});
}
// Update workflow owner to the user who exported the workflow, if that user exists
// in the instance, and the workflow doesn't already have an owner
let workflowOwnerId = userId;
const workflowOwnerPersonalProject =
await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(workflowOwnerId);
if (cachedOwnerIds.has(importedWorkflow.owner)) {
workflowOwnerId = cachedOwnerIds.get(importedWorkflow.owner) ?? userId;
} else {
const foundUser = await Container.get(UserRepository).findOne({
where: {
email: importedWorkflow.owner,
},
select: ['id'],
});
if (foundUser) {
cachedOwnerIds.set(importedWorkflow.owner, foundUser.id);
workflowOwnerId = foundUser.id;
}
}

const existingSharedWorkflowOwnerByRoleId = allSharedWorkflows.find(
(e) => e.workflowId === importedWorkflow.id && e.role === 'workflow:owner',
const isOwnedLocally = allSharedWorkflows.some(
(w) => w.workflowId === importedWorkflow.id && w.role === 'workflow:owner',
);
const existingSharedWorkflowOwnerByUserId = allSharedWorkflows.find(
(e) => e.workflowId === importedWorkflow.id && e.role === 'workflow:owner',
);
if (!existingSharedWorkflowOwnerByUserId && !existingSharedWorkflowOwnerByRoleId) {
// no owner exists yet, so create one
await Container.get(SharedWorkflowRepository).insert({
workflowId: importedWorkflow.id,
projectId: workflowOwnerPersonalProject.id,
role: 'workflow:owner',
});
} else if (existingSharedWorkflowOwnerByRoleId) {
// skip, because the workflow already has a global owner
} else if (existingSharedWorkflowOwnerByUserId && !existingSharedWorkflowOwnerByRoleId) {
// if the workflow has a non-global owner that is referenced by the owner file,
// and no existing global owner, update the owner to the user referenced in the owner file
await Container.get(SharedWorkflowRepository).update(

if (!isOwnedLocally) {
const remoteOwnerProject: Project | null = importedWorkflow.owner
? await this.findOrCreateOwnerProject(importedWorkflow.owner)
: null;

await Container.get(SharedWorkflowRepository).upsert(
{
workflowId: importedWorkflow.id,
projectId: workflowOwnerPersonalProject.id,
projectId: remoteOwnerProject?.id ?? personalProject.id,
role: 'workflow:owner',
},
{ role: 'workflow:owner' },
['workflowId', 'projectId'],
);
}

if (existingWorkflow?.active) {
try {
// remove active pre-import workflow
Expand Down Expand Up @@ -353,25 +330,14 @@ export class SourceControlImportService {
await Container.get(CredentialsRepository).upsert(newCredentialObject, ['id']);

const isOwnedLocally = existingSharedCredentials.some(
(c) => c.credentialsId === credential.id,
(c) => c.credentialsId === credential.id && c.role === 'credential:owner',
);

if (!isOwnedLocally) {
const remoteOwnerId = credential.ownedBy
? await Container.get(UserRepository)
.findOne({
where: { email: credential.ownedBy },
select: { id: true },
})
.then((user) => user?.id)
const remoteOwnerProject: Project | null = credential.ownedBy
? await this.findOrCreateOwnerProject(credential.ownedBy)
: null;

let remoteOwnerProject: Project | null = null;
if (remoteOwnerId) {
remoteOwnerProject =
await Container.get(ProjectRepository).getPersonalProjectForUser(remoteOwnerId);
}

const newSharedCredential = new SharedCredentials();
newSharedCredential.credentialsId = newCredentialObject.id as string;
newSharedCredential.projectId = remoteOwnerProject?.id ?? personalProject.id;
Expand Down Expand Up @@ -520,4 +486,40 @@ export class SourceControlImportService {

return result;
}

private async findOrCreateOwnerProject(owner: ResourceOwner): Promise<Project | null> {
const projectRepository = Container.get(ProjectRepository);
const userRepository = Container.get(UserRepository);
if (typeof owner === 'string' || owner.type === 'personal') {
const email = typeof owner === 'string' ? owner : owner.personalEmail;
const user = await userRepository.findOne({
where: { email },
});
if (!user) {
return null;
}
return await projectRepository.getPersonalProjectForUserOrFail(user.id);
} else if (owner.type === 'team') {
let teamProject = await projectRepository.findOne({
where: { id: owner.teamId },
});
if (!teamProject) {
teamProject = await projectRepository.save(
projectRepository.create({
id: owner.teamId,
name: owner.teamName,
}),
);
}

return teamProject;
}

assertNever(owner);

const errorOwner = owner as ResourceOwner;
throw new ApplicationError(
`Unknown resource owner type "${typeof errorOwner !== 'string' ? errorOwner.type : 'UNKNOWN'}" found when importing from source controller`,
);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import type { ResourceOwner } from './resourceOwner';

export interface ExportableCredential {
id: string;
Expand All @@ -10,5 +11,5 @@ 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: string | null;
ownedBy: ResourceOwner | null;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { INode, IConnections, IWorkflowSettings } from 'n8n-workflow';
import type { ResourceOwner } from './resourceOwner';

export interface ExportableWorkflow {
id: string;
Expand All @@ -8,5 +9,5 @@ export interface ExportableWorkflow {
settings?: IWorkflowSettings;
triggerCount: number;
versionId: string;
owner: string;
owner: ResourceOwner;
}
11 changes: 11 additions & 0 deletions packages/cli/src/environments/sourceControl/types/resourceOwner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export type ResourceOwner =
| string
| {
type: 'personal';
personalEmail: string;
}
| {
type: 'team';
teamId: string;
teamName: string;
};
7 changes: 7 additions & 0 deletions packages/cli/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,10 @@ export function rightDiff<T1, T2>(
return acc;
}, []);
}

/**
* Asserts that the passed in type is never.
* Can be used to make sure the type is exhausted
* in switch statements or if/else chains.
*/
export const assertNever = (value: never) => {};
Loading

0 comments on commit 4aed020

Please sign in to comment.