Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';

const PERSONAL_OWNER_ROLE_SLUG = 'project:personalOwner';

/**
* Adds workflow:unshare and credential:unshare scopes to all custom roles that have
* the corresponding share scope (workflow:share / credential:share).
*
* This migration ensures backward compatibility after the introduction of separate unshare scopes.
* Roles that could share resources should also be able to unshare them.
* project:personalOwner is excluded because it already has unshare scopes in its base definition
* (PERSONAL_PROJECT_OWNER_SCOPES), which are synced on startup.
*
* This migration:
* 1. Ensures workflow:unshare and credential:unshare scopes exist in the scope table
* 2. Finds all roles (except project:personalOwner) with the corresponding share scope
* and grants the unshare scope to them
*
* Compatible with SQLite and PostgreSQL.
*/
export class AddUnshareScopeToCustomRoles1771500000001 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const scopeTableName = escape.tableName('scope');
const scopeSlugColumn = escape.columnName('slug');
const displayNameColumn = escape.columnName('displayName');
const descriptionColumn = escape.columnName('description');

const roleTableName = escape.tableName('role');
const roleScopeTableName = escape.tableName('role_scope');
const roleSlugColumn = escape.columnName('slug');
const roleScopeRoleSlugColumn = escape.columnName('roleSlug');
const roleScopeScopeSlugColumn = escape.columnName('scopeSlug');

// Step 1: Ensure unshare scopes exist
const insertScopeQuery = `INSERT INTO ${scopeTableName} (${scopeSlugColumn}, ${displayNameColumn}, ${descriptionColumn})
VALUES (:slug, :displayName, :description)
ON CONFLICT (${scopeSlugColumn}) DO NOTHING`;

await runQuery(insertScopeQuery, {
slug: 'workflow:unshare',
displayName: 'Unshare Workflow',
description: 'Allows removing workflow shares.',
});

await runQuery(insertScopeQuery, {
slug: 'credential:unshare',
displayName: 'Unshare Credential',
description: 'Allows removing credential shares.',
});

// Step 2: Add unshare scopes to roles that have the corresponding share scope,
// excluding project:personalOwner (gets it from startup sync)
const batchInsertQuery = `
INSERT INTO ${roleScopeTableName} (${roleScopeRoleSlugColumn}, ${roleScopeScopeSlugColumn})
SELECT DISTINCT role.${roleSlugColumn}, :unshareScope
FROM ${roleTableName} role
INNER JOIN ${roleScopeTableName} role_scope
ON role.${roleSlugColumn} = role_scope.${roleScopeRoleSlugColumn}
WHERE role.${roleSlugColumn} != :personalOwnerSlug
AND role_scope.${roleScopeScopeSlugColumn} = :shareScope
ON CONFLICT (${roleScopeRoleSlugColumn}, ${roleScopeScopeSlugColumn}) DO NOTHING
`;

await runQuery(batchInsertQuery, {
personalOwnerSlug: PERSONAL_OWNER_ROLE_SLUG,
shareScope: 'workflow:share',
unshareScope: 'workflow:unshare',
});

await runQuery(batchInsertQuery, {
personalOwnerSlug: PERSONAL_OWNER_ROLE_SLUG,
shareScope: 'credential:share',
unshareScope: 'credential:unshare',
});
}

async down({ escape, runQuery }: MigrationContext) {
const roleScopeTableName = escape.tableName('role_scope');
const roleScopeScopeSlugColumn = escape.columnName('scopeSlug');

const deleteQuery = `
DELETE FROM ${roleScopeTableName}
WHERE ${roleScopeScopeSlugColumn} = :unshareScope
`;

await runQuery(deleteQuery, { unshareScope: 'workflow:unshare' });
await runQuery(deleteQuery, { unshareScope: 'credential:unshare' });
}
}
2 changes: 2 additions & 0 deletions packages/@n8n/db/src/migrations/postgresdb/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ import { ExpandProviderIdColumnLength1770000000000 } from '../common/17700000000
import { CreateWorkflowBuilderSessionTable1770220686000 } from '../common/1770220686000-CreateWorkflowBuilderSessionTable';
import { AddScalingFieldsToTestRun1771417407753 } from '../common/1771417407753-AddScalingFieldsToTestRun';
import { MigrateExternalSecretsToEntityStorage1771500000000 } from '../common/1771500000000-MigrateExternalSecretsToEntityStorage';
import { AddUnshareScopeToCustomRoles1771500000001 } from '../common/1771500000001-AddUnshareScopeToCustomRoles';
import type { Migration } from '../migration-types';

export const postgresMigrations: Migration[] = [
Expand Down Expand Up @@ -297,4 +298,5 @@ export const postgresMigrations: Migration[] = [
CreateWorkflowBuilderSessionTable1770220686000,
AddScalingFieldsToTestRun1771417407753,
MigrateExternalSecretsToEntityStorage1771500000000,
AddUnshareScopeToCustomRoles1771500000001,
];
2 changes: 2 additions & 0 deletions packages/@n8n/db/src/migrations/sqlite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ import { ExpandProviderIdColumnLength1770000000000 } from '../common/17700000000
import { CreateWorkflowBuilderSessionTable1770220686000 } from '../common/1770220686000-CreateWorkflowBuilderSessionTable';
import { AddScalingFieldsToTestRun1771417407753 } from '../common/1771417407753-AddScalingFieldsToTestRun';
import { MigrateExternalSecretsToEntityStorage1771500000000 } from '../common/1771500000000-MigrateExternalSecretsToEntityStorage';
import { AddUnshareScopeToCustomRoles1771500000001 } from '../common/1771500000001-AddUnshareScopeToCustomRoles';
import type { Migration } from '../migration-types';

const sqliteMigrations: Migration[] = [
Expand Down Expand Up @@ -285,6 +286,7 @@ const sqliteMigrations: Migration[] = [
CreateWorkflowBuilderSessionTable1770220686000,
AddScalingFieldsToTestRun1771417407753,
MigrateExternalSecretsToEntityStorage1771500000000,
AddUnshareScopeToCustomRoles1771500000001,
];

export { sqliteMigrations };
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
"communityPackage:manage",
"communityPackage:*",
"credential:share",
"credential:unshare",
"credential:shareGlobally",
"credential:move",
"credential:create",
Expand Down Expand Up @@ -104,6 +105,7 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
"workersView:manage",
"workersView:*",
"workflow:share",
"workflow:unshare",
"workflow:execute",
"workflow:execute-chat",
"workflow:move",
Expand Down
3 changes: 2 additions & 1 deletion packages/@n8n/permissions/src/constants.ee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const RESOURCES = {
banner: ['dismiss'] as const,
community: ['register'] as const,
communityPackage: ['install', 'uninstall', 'update', 'list', 'manage'] as const,
credential: ['share', 'shareGlobally', 'move', ...DEFAULT_OPERATIONS] as const,
credential: ['share', 'unshare', 'shareGlobally', 'move', ...DEFAULT_OPERATIONS] as const,
externalSecretsProvider: ['sync', ...DEFAULT_OPERATIONS] as const,
externalSecret: ['list'] as const,
eventBusDestination: ['test', ...DEFAULT_OPERATIONS] as const,
Expand All @@ -33,6 +33,7 @@ export const RESOURCES = {
workersView: ['manage'] as const,
workflow: [
'share',
'unshare',
'execute',
'execute-chat',
'move',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const CREDENTIALS_SHARING_OWNER_SCOPES: Scope[] = [
'credential:update',
'credential:delete',
'credential:share',
'credential:unshare',
'credential:move',
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'credential:delete',
'credential:list',
'credential:share',
'credential:unshare',
'credential:shareGlobally',
'credential:move',
'community:register',
Expand Down Expand Up @@ -77,6 +78,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'workflow:delete',
'workflow:list',
'workflow:share',
'workflow:unshare',
'workflow:execute',
'workflow:execute-chat',
'workflow:move',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [
'credential:list',
'credential:move',
'credential:share',
'credential:unshare',
'project:list',
'project:read',
'project:update',
Expand Down Expand Up @@ -66,12 +67,14 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
'workflow:execute-chat',
'workflow:move',
'workflow:unpublish',
'workflow:unshare',
'credential:create',
'credential:read',
'credential:update',
'credential:delete',
'credential:list',
'credential:move',
'credential:unshare',
'project:list',
'project:read',
'folder:create',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const WORKFLOW_SHARING_OWNER_SCOPES: Scope[] = [
'workflow:delete',
'workflow:execute',
'workflow:share',
'workflow:unshare',
'workflow:move',
'workflow:execute-chat',
];
Expand Down
8 changes: 8 additions & 0 deletions packages/@n8n/permissions/src/scope-information.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,12 @@ export const scopeInformation: Partial<Record<Scope, ScopeInformation>> = {
displayName: 'Unpublish Workflow',
description: 'Allows unpublishing workflows.',
},
'workflow:unshare': {
displayName: 'Unshare Workflow',
description: 'Allows removing workflow shares.',
},
'credential:unshare': {
displayName: 'Unshare Credential',
description: 'Allows removing credential shares.',
},
};
41 changes: 28 additions & 13 deletions packages/cli/src/credentials/credentials.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { EventService } from '@/events/event.service';
import { listQueryMiddleware } from '@/middlewares';
import { userHasScopes } from '@/permissions.ee/check-access';
import { CredentialRequest } from '@/requests';
import { NamingService } from '@/services/naming.service';
import { UserManagementMailer } from '@/user-management/email';
Expand Down Expand Up @@ -319,7 +320,6 @@ export class CredentialsController {

@Licensed('feat:sharing')
@Put('/:credentialId/share')
@ProjectScope('credential:share')
async shareCredentials(req: CredentialRequest.Share) {
const { credentialId } = req.params;
const { shareWithIds } = req.body;
Expand All @@ -334,29 +334,44 @@ export class CredentialsController {
const credential = await this.credentialsFinderService.findCredentialForUser(
credentialId,
req.user,
['credential:share'],
['credential:read'],
);

if (!credential) {
throw new ForbiddenError();
}

const currentProjectIds = credential.shared
.filter((sc) => sc.role === 'credential:user')
.map((sc) => sc.projectId);
const newProjectIds = shareWithIds;

const toShare = utils.rightDiff([currentProjectIds, (id) => id], [newProjectIds, (id) => id]);
const toUnshare = utils.rightDiff([newProjectIds, (id) => id], [currentProjectIds, (id) => id]);

if (toShare.length > 0) {
const canShare = await userHasScopes(req.user, ['credential:share'], false, {
credentialId,
});
if (!canShare) {
throw new ForbiddenError();
}
}

if (toUnshare.length > 0) {
const canUnshare = await userHasScopes(req.user, ['credential:unshare'], false, {
credentialId,
});
if (!canUnshare) {
throw new ForbiddenError();
}
}

let amountRemoved: number | null = null;
let newShareeIds: string[] = [];

const { manager: dbManager } = this.sharedCredentialsRepository;
await dbManager.transaction(async (trx) => {
const currentProjectIds = credential.shared
.filter((sc) => sc.role === 'credential:user')
.map((sc) => sc.projectId);
const newProjectIds = shareWithIds;

const toShare = utils.rightDiff([currentProjectIds, (id) => id], [newProjectIds, (id) => id]);
const toUnshare = utils.rightDiff(
[newProjectIds, (id) => id],
[currentProjectIds, (id) => id],
);

const deleteResult = await trx.delete(SharedCredentials, {
credentialsId: credentialId,
projectId: In(toUnshare),
Expand Down
49 changes: 32 additions & 17 deletions packages/cli/src/workflows/workflows.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,6 @@ export class WorkflowsController {

@Licensed('feat:sharing')
@Put('/:workflowId/share')
@ProjectScope('workflow:share')
async share(req: WorkflowRequest.Share) {
const { workflowId } = req.params;
const { shareWithIds } = req.body;
Expand All @@ -714,31 +713,47 @@ export class WorkflowsController {
}

const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, req.user, [
'workflow:share',
'workflow:read',
]);

if (!workflow) {
throw new ForbiddenError();
}

const currentPersonalProjectIDs = workflow.shared
.filter((sw) => sw.role === 'workflow:editor')
.map((sw) => sw.projectId);
const newPersonalProjectIDs = shareWithIds;

const toShare = utils.rightDiff(
[currentPersonalProjectIDs, (id) => id],
[newPersonalProjectIDs, (id) => id],
);

const toUnshare = utils.rightDiff(
[newPersonalProjectIDs, (id) => id],
[currentPersonalProjectIDs, (id) => id],
);

if (toShare.length > 0) {
const canShare = await userHasScopes(req.user, ['workflow:share'], false, { workflowId });
if (!canShare) {
throw new ForbiddenError();
}
}

if (toUnshare.length > 0) {
const canUnshare = await userHasScopes(req.user, ['workflow:unshare'], false, {
workflowId,
});
if (!canUnshare) {
throw new ForbiddenError();
}
}

let newShareeIds: string[] = [];
const { manager: dbManager } = this.projectRepository;
await dbManager.transaction(async (trx) => {
const currentPersonalProjectIDs = workflow.shared
.filter((sw) => sw.role === 'workflow:editor')
.map((sw) => sw.projectId);
const newPersonalProjectIDs = shareWithIds;

const toShare = utils.rightDiff(
[currentPersonalProjectIDs, (id) => id],
[newPersonalProjectIDs, (id) => id],
);

const toUnshare = utils.rightDiff(
[newPersonalProjectIDs, (id) => id],
[currentPersonalProjectIDs, (id) => id],
);

await trx.delete(SharedWorkflow, {
workflowId,
projectId: In(toUnshare),
Expand Down
1 change: 1 addition & 0 deletions packages/cli/test/integration/ai/ai.api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ describe('POST /ai/free-credits', () => {
'credential:read',
'credential:share',
'credential:shareGlobally',
'credential:unshare',
'credential:update',
].sort(),
);
Expand Down
Loading