fix(editor): Optionally share credentials used by the workflow when moving the workflow between projects (#12524)

Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
Csaba Tuncsik
2025-02-21 11:05:37 +01:00
committed by GitHub
parent 29ae2396c9
commit 7bd83d7d33
22 changed files with 1078 additions and 136 deletions

View File

@@ -8,18 +8,28 @@ import config from '@/config';
import type { Project } from '@/databases/entities/project';
import type { User } from '@/databases/entities/user';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { License } from '@/license';
import { UserManagementMailer } from '@/user-management/email';
import type { WorkflowWithSharingsMetaDataAndCredentials } from '@/workflows/workflows.types';
import { mockInstance } from '@test/mocking';
import { mockInstance } from '../../shared/mocking';
import { affixRoleToSaveCredential, shareCredentialWithUsers } from '../shared/db/credentials';
import { createTeamProject, linkUserToProject } from '../shared/db/projects';
import {
affixRoleToSaveCredential,
getCredentialSharings,
shareCredentialWithProjects,
shareCredentialWithUsers,
} from '../shared/db/credentials';
import { createTeamProject, getPersonalProject, linkUserToProject } from '../shared/db/projects';
import { createTag } from '../shared/db/tags';
import { createAdmin, createOwner, createUser, createUserShell } from '../shared/db/users';
import { createWorkflow, getWorkflowSharing, shareWorkflowWithUsers } from '../shared/db/workflows';
import {
createWorkflow,
getWorkflowSharing,
shareWorkflowWithProjects,
shareWorkflowWithUsers,
} from '../shared/db/workflows';
import { randomCredentialPayload } from '../shared/random';
import * as testDb from '../shared/test-db';
import type { SaveCredentialFunction } from '../shared/types';
@@ -44,7 +54,6 @@ let workflowRepository: WorkflowRepository;
const activeWorkflowManager = mockInstance(ActiveWorkflowManager);
const sharingSpy = jest.spyOn(License.prototype, 'isSharingEnabled').mockReturnValue(true);
const testServer = utils.setupTestServer({
endpointGroups: ['workflows'],
enabledFeatures: ['feat:sharing', 'feat:advancedPermissions'],
@@ -95,15 +104,15 @@ describe('router should switch based on flag', () => {
});
test('when sharing is disabled', async () => {
sharingSpy.mockReturnValueOnce(false);
license.disable('feat:sharing');
await authOwnerAgent
.put(`/workflows/${savedWorkflowId}/share`)
.send({ shareWithIds: [memberPersonalProject.id] })
.expect(404);
.expect(403);
});
test('when sharing is enabled', async () => {
license.enable('feat:sharing');
await authOwnerAgent
.put(`/workflows/${savedWorkflowId}/share`)
.send({ shareWithIds: [memberPersonalProject.id] })
@@ -290,6 +299,52 @@ describe('PUT /workflows/:workflowId/share', () => {
config.set('userManagement.emails.mode', 'smtp');
});
test('should ignore sharing with owner project', async () => {
// ARRANGE
const project = ownerPersonalProject;
const workflow = await createWorkflow({ name: 'My workflow' }, project);
await authOwnerAgent
.put(`/workflows/${workflow.id}/share`)
.send({ shareWithIds: [project.id] })
.expect(200);
const sharedWorkflows = await Container.get(SharedWorkflowRepository).findBy({
workflowId: workflow.id,
});
expect(sharedWorkflows).toHaveLength(1);
expect(sharedWorkflows).toEqual([
expect.objectContaining({ projectId: project.id, role: 'workflow:owner' }),
]);
});
test('should ignore sharing with project that already has it shared', async () => {
// ARRANGE
const project = ownerPersonalProject;
const workflow = await createWorkflow({ name: 'My workflow' }, project);
const project2 = memberPersonalProject;
await shareWorkflowWithProjects(workflow, [{ project: project2 }]);
await authOwnerAgent
.put(`/workflows/${workflow.id}/share`)
.send({ shareWithIds: [project2.id] })
.expect(200);
const sharedWorkflows = await Container.get(SharedWorkflowRepository).findBy({
workflowId: workflow.id,
});
expect(sharedWorkflows).toHaveLength(2);
expect(sharedWorkflows).toEqual(
expect.arrayContaining([
expect.objectContaining({ projectId: project.id, role: 'workflow:owner' }),
expect.objectContaining({ projectId: project2.id, role: 'workflow:editor' }),
]),
);
});
});
describe('GET /workflows/new', () => {
@@ -297,7 +352,7 @@ describe('GET /workflows/new', () => {
test(`should return an auto-incremented name, even when sharing is ${
sharingEnabled ? 'enabled' : 'disabled'
}`, async () => {
sharingSpy.mockReturnValueOnce(sharingEnabled);
license.enable('feat:sharing');
await createWorkflow({ name: 'My workflow' }, owner);
await createWorkflow({ name: 'My workflow 7' }, owner);
@@ -1602,6 +1657,338 @@ describe('PUT /:workflowId/transfer', () => {
expect(workflowFromDB).toMatchObject({ active: false });
});
test('owner transfers workflow from project they are not part of, e.g. test global cred sharing scope', async () => {
// ARRANGE
const sourceProject = await createTeamProject('source project', admin);
const destinationProject = await createTeamProject('destination project', member);
const workflow = await createWorkflow({}, sourceProject);
const credential = await saveCredential(randomCredentialPayload(), { project: sourceProject });
// ACT
await testServer
.authAgentFor(owner)
.put(`/workflows/${workflow.id}/transfer`)
.send({
destinationProjectId: destinationProject.id,
shareCredentials: [credential.id],
})
.expect(200);
// ASSERT
const allWorkflowSharings = await getWorkflowSharing(workflow);
expect(allWorkflowSharings).toHaveLength(1);
expect(allWorkflowSharings[0]).toMatchObject({
projectId: destinationProject.id,
workflowId: workflow.id,
role: 'workflow:owner',
});
const allCredentialSharings = await getCredentialSharings(credential);
expect(allCredentialSharings).toHaveLength(2);
expect(allCredentialSharings).toEqual(
expect.arrayContaining([
expect.objectContaining({
projectId: sourceProject.id,
credentialsId: credential.id,
role: 'credential:owner',
}),
expect.objectContaining({
projectId: destinationProject.id,
credentialsId: credential.id,
role: 'credential:user',
}),
]),
);
});
test('admin transfers workflow from project they are not part of, e.g. test global cred sharing scope', async () => {
// ARRANGE
const sourceProject = await createTeamProject('source project', owner);
const destinationProject = await createTeamProject('destination project', owner);
const workflow = await createWorkflow({}, sourceProject);
const credential = await saveCredential(randomCredentialPayload(), { project: sourceProject });
// ACT
await testServer
.authAgentFor(admin)
.put(`/workflows/${workflow.id}/transfer`)
.send({
destinationProjectId: destinationProject.id,
shareCredentials: [credential.id],
})
.expect(200);
// ASSERT
const allWorkflowSharings = await getWorkflowSharing(workflow);
expect(allWorkflowSharings).toHaveLength(1);
expect(allWorkflowSharings[0]).toMatchObject({
projectId: destinationProject.id,
workflowId: workflow.id,
role: 'workflow:owner',
});
const allCredentialSharings = await getCredentialSharings(credential);
expect(allCredentialSharings).toHaveLength(2);
expect(allCredentialSharings).toEqual(
expect.arrayContaining([
expect.objectContaining({
projectId: sourceProject.id,
credentialsId: credential.id,
role: 'credential:owner',
}),
expect.objectContaining({
projectId: destinationProject.id,
credentialsId: credential.id,
role: 'credential:user',
}),
]),
);
});
test('member transfers workflow from personal project to team project and wf contains a credential that they can use but not share', async () => {
// ARRANGE
const sourceProject = memberPersonalProject;
const destinationProject = await createTeamProject('destination project', member);
const workflow = await createWorkflow({}, sourceProject);
const credential = await saveCredential(randomCredentialPayload(), { user: owner });
await shareCredentialWithUsers(credential, [member]);
// ACT
await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({
destinationProjectId: destinationProject.id,
shareCredentials: [credential.id],
})
.expect(200);
// ASSERT
const allWorkflowSharings = await getWorkflowSharing(workflow);
expect(allWorkflowSharings).toHaveLength(1);
expect(allWorkflowSharings[0]).toMatchObject({
projectId: destinationProject.id,
workflowId: workflow.id,
role: 'workflow:owner',
});
const allCredentialSharings = await getCredentialSharings(credential);
expect(allCredentialSharings).toHaveLength(2);
expect(allCredentialSharings).toEqual(
expect.arrayContaining([
expect.objectContaining({
projectId: ownerPersonalProject.id,
credentialsId: credential.id,
role: 'credential:owner',
}),
expect.objectContaining({
projectId: sourceProject.id,
credentialsId: credential.id,
role: 'credential:user',
}),
]),
);
});
test('member transfers workflow from their personal project to another team project in which they have editor role', async () => {
// ARRANGE
const sourceProject = memberPersonalProject;
const destinationProject = await createTeamProject('destination project');
const workflow = await createWorkflow({}, sourceProject);
const credential = await saveCredential(randomCredentialPayload(), { project: sourceProject });
await linkUserToProject(member, destinationProject, 'project:editor');
// ACT
await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({
destinationProjectId: destinationProject.id,
shareCredentials: [credential.id],
})
.expect(200);
// ASSERT
const allWorkflowSharings = await getWorkflowSharing(workflow);
expect(allWorkflowSharings).toHaveLength(1);
expect(allWorkflowSharings[0]).toMatchObject({
projectId: destinationProject.id,
workflowId: workflow.id,
role: 'workflow:owner',
});
const allCredentialSharings = await getCredentialSharings(credential);
expect(allCredentialSharings).toHaveLength(2);
expect(allCredentialSharings).toEqual(
expect.arrayContaining([
expect.objectContaining({
projectId: sourceProject.id,
credentialsId: credential.id,
role: 'credential:owner',
}),
expect.objectContaining({
projectId: destinationProject.id,
credentialsId: credential.id,
role: 'credential:user',
}),
]),
);
});
test('member transfers workflow from a team project as project admin to another team project in which they have editor role', async () => {
// ARRANGE
const sourceProject = await createTeamProject('source project', member);
const destinationProject = await createTeamProject('destination project');
const workflow = await createWorkflow({}, sourceProject);
const credential = await saveCredential(randomCredentialPayload(), { project: sourceProject });
await linkUserToProject(member, destinationProject, 'project:editor');
// ACT
await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({
destinationProjectId: destinationProject.id,
shareCredentials: [credential.id],
})
.expect(200);
// ASSERT
const allWorkflowSharings = await getWorkflowSharing(workflow);
expect(allWorkflowSharings).toHaveLength(1);
expect(allWorkflowSharings[0]).toMatchObject({
projectId: destinationProject.id,
workflowId: workflow.id,
role: 'workflow:owner',
});
const allCredentialSharings = await getCredentialSharings(credential);
expect(allCredentialSharings).toHaveLength(2);
expect(allCredentialSharings).toEqual(
expect.arrayContaining([
expect.objectContaining({
projectId: sourceProject.id,
credentialsId: credential.id,
role: 'credential:owner',
}),
expect.objectContaining({
projectId: destinationProject.id,
credentialsId: credential.id,
role: 'credential:user',
}),
]),
);
});
test('member transfers workflow from a team project as project admin to another team project in which they have editor role but cannot share the credential that is only shared into the source project', async () => {
// ARRANGE
const sourceProject = await createTeamProject('source project', member);
const destinationProject = await createTeamProject('destination project');
const ownerProject = await getPersonalProject(owner);
const workflow = await createWorkflow({}, sourceProject);
const credential = await saveCredential(randomCredentialPayload(), { user: owner });
await linkUserToProject(member, destinationProject, 'project:editor');
await shareCredentialWithProjects(credential, [sourceProject]);
// ACT
await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({
destinationProjectId: destinationProject.id,
shareCredentials: [credential.id],
})
.expect(200);
// ASSERT
const allWorkflowSharings = await getWorkflowSharing(workflow);
expect(allWorkflowSharings).toHaveLength(1);
expect(allWorkflowSharings[0]).toMatchObject({
projectId: destinationProject.id,
workflowId: workflow.id,
role: 'workflow:owner',
});
const allCredentialSharings = await getCredentialSharings(credential);
expect(allCredentialSharings).toHaveLength(2);
expect(allCredentialSharings).toEqual(
expect.arrayContaining([
expect.objectContaining({
projectId: ownerProject.id,
credentialsId: credential.id,
role: 'credential:owner',
}),
expect.objectContaining({
projectId: sourceProject.id,
credentialsId: credential.id,
role: 'credential:user',
}),
]),
);
});
test('member transfers workflow from a team project as project admin to another team project in which they have editor role but cannot share all the credentials', async () => {
// ARRANGE
const sourceProject = await createTeamProject('source project', member);
const workflow = await createWorkflow({}, sourceProject);
const credential = await saveCredential(randomCredentialPayload(), { project: sourceProject });
const ownersCredential = await saveCredential(randomCredentialPayload(), { user: owner });
const destinationProject = await createTeamProject('destination project');
await linkUserToProject(member, destinationProject, 'project:editor');
// ACT
await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({
destinationProjectId: destinationProject.id,
shareCredentials: [credential.id, ownersCredential.id],
})
.expect(200);
// ASSERT
const allWorkflowSharings = await getWorkflowSharing(workflow);
expect(allWorkflowSharings).toHaveLength(1);
expect(allWorkflowSharings[0]).toMatchObject({
projectId: destinationProject.id,
workflowId: workflow.id,
role: 'workflow:owner',
});
const allCredentialSharings = await getCredentialSharings(credential);
expect(allCredentialSharings).toHaveLength(2);
expect(allCredentialSharings).toEqual(
expect.arrayContaining([
expect.objectContaining({
projectId: sourceProject.id,
credentialsId: credential.id,
role: 'credential:owner',
}),
expect.objectContaining({
projectId: destinationProject.id,
credentialsId: credential.id,
role: 'credential:user',
}),
]),
);
const ownerCredentialSharings = await getCredentialSharings(ownersCredential);
expect(ownerCredentialSharings).toHaveLength(1);
expect(ownerCredentialSharings).toEqual([
expect.objectContaining({
projectId: ownerPersonalProject.id,
credentialsId: ownersCredential.id,
role: 'credential:owner',
}),
]);
});
test('returns a 500 if the workflow cannot be activated due to an unknown error', async () => {
//
// ARRANGE