diff --git a/packages/@n8n/db/src/migrations/common/1771500000001-AddUnshareScopeToCustomRoles.ts b/packages/@n8n/db/src/migrations/common/1771500000001-AddUnshareScopeToCustomRoles.ts new file mode 100644 index 00000000000..a10922ccefb --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1771500000001-AddUnshareScopeToCustomRoles.ts @@ -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' }); + } +} diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 3e12c2085d0..02b58e1404e 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -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[] = [ @@ -297,4 +298,5 @@ export const postgresMigrations: Migration[] = [ CreateWorkflowBuilderSessionTable1770220686000, AddScalingFieldsToTestRun1771417407753, MigrateExternalSecretsToEntityStorage1771500000000, + AddUnshareScopeToCustomRoles1771500000001, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 6357e7270a2..1286d3bab28 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -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[] = [ @@ -285,6 +286,7 @@ const sqliteMigrations: Migration[] = [ CreateWorkflowBuilderSessionTable1770220686000, AddScalingFieldsToTestRun1771417407753, MigrateExternalSecretsToEntityStorage1771500000000, + AddUnshareScopeToCustomRoles1771500000001, ]; export { sqliteMigrations }; diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index 66d8b69b443..7a3cbfa01ed 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -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", @@ -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", diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 2df484916d8..61b8b0681d5 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -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, @@ -33,6 +33,7 @@ export const RESOURCES = { workersView: ['manage'] as const, workflow: [ 'share', + 'unshare', 'execute', 'execute-chat', 'move', diff --git a/packages/@n8n/permissions/src/roles/scopes/credential-sharing-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/credential-sharing-scopes.ee.ts index eecc718e96a..a6c7e71cec4 100644 --- a/packages/@n8n/permissions/src/roles/scopes/credential-sharing-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/credential-sharing-scopes.ee.ts @@ -5,6 +5,7 @@ export const CREDENTIALS_SHARING_OWNER_SCOPES: Scope[] = [ 'credential:update', 'credential:delete', 'credential:share', + 'credential:unshare', 'credential:move', ]; diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index b42943082d3..114befedc5c 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -15,6 +15,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'credential:delete', 'credential:list', 'credential:share', + 'credential:unshare', 'credential:shareGlobally', 'credential:move', 'community:register', @@ -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', diff --git a/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts index 59c7ec6ef72..ab1b8faf2c8 100644 --- a/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts @@ -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', @@ -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', diff --git a/packages/@n8n/permissions/src/roles/scopes/workflow-sharing-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/workflow-sharing-scopes.ee.ts index 50dacfbf095..bbd2b65a10d 100644 --- a/packages/@n8n/permissions/src/roles/scopes/workflow-sharing-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/workflow-sharing-scopes.ee.ts @@ -8,6 +8,7 @@ export const WORKFLOW_SHARING_OWNER_SCOPES: Scope[] = [ 'workflow:delete', 'workflow:execute', 'workflow:share', + 'workflow:unshare', 'workflow:move', 'workflow:execute-chat', ]; diff --git a/packages/@n8n/permissions/src/scope-information.ts b/packages/@n8n/permissions/src/scope-information.ts index bda96e721e7..2d60b4e6dad 100644 --- a/packages/@n8n/permissions/src/scope-information.ts +++ b/packages/@n8n/permissions/src/scope-information.ts @@ -40,4 +40,12 @@ export const scopeInformation: Partial> = { 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.', + }, }; diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index dc11ebc9f37..a805b73684b 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -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'; @@ -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; @@ -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), diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 61672dadae3..1199a7876dd 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -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; @@ -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), diff --git a/packages/cli/test/integration/ai/ai.api.test.ts b/packages/cli/test/integration/ai/ai.api.test.ts index 5a70e1df841..ded455c6cea 100644 --- a/packages/cli/test/integration/ai/ai.api.test.ts +++ b/packages/cli/test/integration/ai/ai.api.test.ts @@ -79,6 +79,7 @@ describe('POST /ai/free-credits', () => { 'credential:read', 'credential:share', 'credential:shareGlobally', + 'credential:unshare', 'credential:update', ].sort(), ); diff --git a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts index 47f867900dd..d60fba0f0cf 100644 --- a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts @@ -12,10 +12,12 @@ import type { Project, User, ListQueryDb } from '@n8n/db'; import { GLOBAL_MEMBER_ROLE, ProjectRepository, SharedCredentialsRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import type { ProjectRole } from '@n8n/permissions'; +import { PERSONAL_SPACE_SHARING_SETTING } from '@n8n/permissions'; import { In } from '@n8n/typeorm'; import config from '@/config'; import { CredentialsService } from '@/credentials/credentials.service'; +import { SecuritySettingsService } from '@/services/security-settings.service'; import { ProjectService } from '@/services/project.service.ee'; import { UserManagementMailer } from '@/user-management/email'; @@ -1206,6 +1208,152 @@ describe('PUT /credentials/:id/share', () => { }); }); +describe('PUT /credentials/:id/share - split share/unshare scopes', () => { + test('should allow owner to add new shares (share operation)', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + + const response = await authMemberAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [anotherMemberPersonalProject.id] }); + + expect(response.statusCode).toBe(200); + + const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ + where: { credentialsId: savedCredential.id }, + }); + expect(sharedCredentials).toHaveLength(2); + }); + + test('should allow owner to remove existing shares (unshare operation)', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + await shareCredentialWithUsers(savedCredential, [anotherMember]); + + // Verify initial state: owner + 1 shared + const initialSharing = await Container.get(SharedCredentialsRepository).find({ + where: { credentialsId: savedCredential.id }, + }); + expect(initialSharing).toHaveLength(2); + + // Send empty shareWithIds to remove all shares + const response = await authMemberAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [] }); + + expect(response.statusCode).toBe(200); + + const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ + where: { credentialsId: savedCredential.id }, + }); + expect(sharedCredentials).toHaveLength(1); + }); + + test('should allow both share and unshare in a single request', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + await shareCredentialWithUsers(savedCredential, [member]); + + // Replace member with anotherMember + const response = await authOwnerAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [anotherMemberPersonalProject.id] }); + + expect(response.statusCode).toBe(200); + + const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ + where: { credentialsId: savedCredential.id }, + }); + expect(sharedCredentials).toHaveLength(2); + const projectIds = sharedCredentials.map((sc) => sc.projectId); + expect(projectIds).toContain(anotherMemberPersonalProject.id); + expect(projectIds).not.toContain(memberPersonalProject.id); + }); + + describe('personal space sharing disabled', () => { + let securitySettingsService: SecuritySettingsService; + + beforeEach(async () => { + securitySettingsService = Container.get(SecuritySettingsService); + }); + + test('should forbid adding new shares when personal space sharing is disabled', async () => { + await securitySettingsService.setPersonalSpaceSetting(PERSONAL_SPACE_SHARING_SETTING, false); + + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + + const response = await authMemberAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [anotherMemberPersonalProject.id] }); + + expect(response.statusCode).toBe(403); + + const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ + where: { credentialsId: savedCredential.id }, + }); + expect(sharedCredentials).toHaveLength(1); + + // Re-enable for cleanup + await securitySettingsService.setPersonalSpaceSetting(PERSONAL_SPACE_SHARING_SETTING, true); + }); + + test('should allow removing existing shares when personal space sharing is disabled', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + await shareCredentialWithUsers(savedCredential, [anotherMember]); + + // Verify initial state + const initialSharing = await Container.get(SharedCredentialsRepository).find({ + where: { credentialsId: savedCredential.id }, + }); + expect(initialSharing).toHaveLength(2); + + // Disable personal space sharing + await securitySettingsService.setPersonalSpaceSetting(PERSONAL_SPACE_SHARING_SETTING, false); + + // Unshare should still work + const response = await authMemberAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [] }); + + expect(response.statusCode).toBe(200); + + const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ + where: { credentialsId: savedCredential.id }, + }); + expect(sharedCredentials).toHaveLength(1); + + // Re-enable for cleanup + await securitySettingsService.setPersonalSpaceSetting(PERSONAL_SPACE_SHARING_SETTING, true); + }); + + test('should forbid mixed share+unshare when user lacks share scope', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + await shareCredentialWithUsers(savedCredential, [anotherMember]); + + // Disable sharing + await securitySettingsService.setPersonalSpaceSetting(PERSONAL_SPACE_SHARING_SETTING, false); + + const tempUser = await createUser({ role: { slug: 'global:member' } }); + const tempUserPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + tempUser.id, + ); + + // Try to unshare anotherMember AND share tempUser - should fail because of share + const response = await authMemberAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [tempUserPersonalProject.id] }); + + expect(response.statusCode).toBe(403); + + // State should be unchanged + const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ + where: { credentialsId: savedCredential.id }, + }); + expect(sharedCredentials).toHaveLength(2); + + // Re-enable for cleanup + await securitySettingsService.setPersonalSpaceSetting(PERSONAL_SPACE_SHARING_SETTING, true); + }); + }); +}); + describe('PUT /:credentialId/transfer', () => { test('cannot transfer into the same project', async () => { const destinationProject = await createTeamProject('Destination Project', member); diff --git a/packages/cli/test/integration/credentials/credentials.api.test.ts b/packages/cli/test/integration/credentials/credentials.api.test.ts index a2ae70246d1..877ac792292 100644 --- a/packages/cli/test/integration/credentials/credentials.api.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.test.ts @@ -195,6 +195,7 @@ describe('GET /credentials', () => { 'credential:read', 'credential:update', 'credential:share', + 'credential:unshare', 'credential:delete', ].sort(), ); @@ -228,6 +229,7 @@ describe('GET /credentials', () => { 'credential:move', 'credential:read', 'credential:share', + 'credential:unshare', 'credential:update', ].sort(), ); @@ -254,6 +256,7 @@ describe('GET /credentials', () => { 'credential:read', 'credential:share', 'credential:shareGlobally', + 'credential:unshare', 'credential:update', ].sort(), ); @@ -269,6 +272,7 @@ describe('GET /credentials', () => { 'credential:read', 'credential:share', 'credential:shareGlobally', + 'credential:unshare', 'credential:update', ].sort(), ); @@ -333,6 +337,7 @@ describe('GET /credentials', () => { 'credential:read', 'credential:update', 'credential:share', + 'credential:unshare', 'credential:delete', ].sort(), ); @@ -396,6 +401,7 @@ describe('GET /credentials', () => { 'credential:update', 'credential:share', 'credential:shareGlobally', + 'credential:unshare', 'credential:delete', 'credential:create', 'credential:list', @@ -411,6 +417,7 @@ describe('GET /credentials', () => { 'credential:update', 'credential:share', 'credential:shareGlobally', + 'credential:unshare', 'credential:delete', 'credential:create', 'credential:list', @@ -429,6 +436,7 @@ describe('GET /credentials', () => { 'credential:update', 'credential:share', 'credential:shareGlobally', + 'credential:unshare', 'credential:delete', 'credential:create', 'credential:list', @@ -843,6 +851,7 @@ describe('POST /credentials', () => { 'credential:move', 'credential:read', 'credential:share', + 'credential:unshare', 'credential:update', ].sort(), ); @@ -1232,6 +1241,7 @@ describe('PATCH /credentials/:id', () => { 'credential:read', 'credential:share', 'credential:shareGlobally', + 'credential:unshare', 'credential:update', ].sort(), ); diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index 6b9a1d85aba..0f9da5c3404 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -22,6 +22,7 @@ import { } from '@n8n/db'; import { Container } from '@n8n/di'; import type { ProjectRole } from '@n8n/permissions'; +import { PERSONAL_SPACE_SHARING_SETTING } from '@n8n/permissions'; import { ApplicationError, WorkflowActivationError, @@ -32,6 +33,7 @@ import { v4 as uuid } from 'uuid'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import config from '@/config'; +import { SecuritySettingsService } from '@/services/security-settings.service'; import { UserManagementMailer } from '@/user-management/email'; import { createFolder } from '@test-integration/db/folders'; @@ -363,6 +365,136 @@ describe('PUT /workflows/:workflowId/share', () => { }); }); +describe('PUT /workflows/:workflowId/share - split share/unshare scopes', () => { + test('should allow owner to add new shares (share operation)', async () => { + const workflow = await createWorkflow({}, member); + + const response = await authMemberAgent + .put(`/workflows/${workflow.id}/share`) + .send({ shareWithIds: [anotherMemberPersonalProject.id] }); + + expect(response.statusCode).toBe(200); + + const sharedWorkflows = await getWorkflowSharing(workflow); + expect(sharedWorkflows).toHaveLength(2); + }); + + test('should allow owner to remove existing shares (unshare operation)', async () => { + const workflow = await createWorkflow({}, member); + await shareWorkflowWithUsers(workflow, [anotherMember]); + + // Verify initial state: owner + 1 shared + const initialSharing = await getWorkflowSharing(workflow); + expect(initialSharing).toHaveLength(2); + + // Send empty shareWithIds to remove all shares + const response = await authMemberAgent + .put(`/workflows/${workflow.id}/share`) + .send({ shareWithIds: [] }); + + expect(response.statusCode).toBe(200); + + const sharedWorkflows = await getWorkflowSharing(workflow); + expect(sharedWorkflows).toHaveLength(1); + }); + + test('should allow both share and unshare in a single request', async () => { + const workflow = await createWorkflow({}, owner); + await shareWorkflowWithUsers(workflow, [member]); + + // Replace member with anotherMember (unshare member, share anotherMember) + const response = await authOwnerAgent + .put(`/workflows/${workflow.id}/share`) + .send({ shareWithIds: [anotherMemberPersonalProject.id] }); + + expect(response.statusCode).toBe(200); + + const sharedWorkflows = await getWorkflowSharing(workflow); + expect(sharedWorkflows).toHaveLength(2); + const projectIds = sharedWorkflows.map((sw) => sw.projectId); + expect(projectIds).toContain(anotherMemberPersonalProject.id); + expect(projectIds).not.toContain(memberPersonalProject.id); + }); + + describe('personal space sharing disabled', () => { + let securitySettingsService: SecuritySettingsService; + + beforeEach(async () => { + securitySettingsService = Container.get(SecuritySettingsService); + }); + + test('should forbid adding new shares when personal space sharing is disabled', async () => { + await securitySettingsService.setPersonalSpaceSetting(PERSONAL_SPACE_SHARING_SETTING, false); + + const workflow = await createWorkflow({}, member); + + const response = await authMemberAgent + .put(`/workflows/${workflow.id}/share`) + .send({ shareWithIds: [anotherMemberPersonalProject.id] }); + + expect(response.statusCode).toBe(403); + + const sharedWorkflows = await getWorkflowSharing(workflow); + expect(sharedWorkflows).toHaveLength(1); + + // Re-enable for cleanup + await securitySettingsService.setPersonalSpaceSetting(PERSONAL_SPACE_SHARING_SETTING, true); + }); + + test('should allow removing existing shares when personal space sharing is disabled', async () => { + const workflow = await createWorkflow({}, member); + await shareWorkflowWithUsers(workflow, [anotherMember]); + + // Verify initial state + const initialSharing = await getWorkflowSharing(workflow); + expect(initialSharing).toHaveLength(2); + + // Disable personal space sharing + await securitySettingsService.setPersonalSpaceSetting(PERSONAL_SPACE_SHARING_SETTING, false); + + // Unshare should still work + const response = await authMemberAgent + .put(`/workflows/${workflow.id}/share`) + .send({ shareWithIds: [] }); + + expect(response.statusCode).toBe(200); + + const sharedWorkflows = await getWorkflowSharing(workflow); + expect(sharedWorkflows).toHaveLength(1); + + // Re-enable for cleanup + await securitySettingsService.setPersonalSpaceSetting(PERSONAL_SPACE_SHARING_SETTING, true); + }); + + test('should forbid mixed share+unshare when user lacks share scope', async () => { + const workflow = await createWorkflow({}, member); + await shareWorkflowWithUsers(workflow, [anotherMember]); + + // Disable sharing + await securitySettingsService.setPersonalSpaceSetting(PERSONAL_SPACE_SHARING_SETTING, false); + + const tempUser = await createUser({ role: { slug: 'global:member' } }); + const tempUserPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + tempUser.id, + ); + + // Try to unshare anotherMember AND share tempUser - should fail because of share + const response = await authMemberAgent + .put(`/workflows/${workflow.id}/share`) + .send({ shareWithIds: [tempUserPersonalProject.id] }); + + expect(response.statusCode).toBe(403); + + // State should be unchanged + const sharedWorkflows = await getWorkflowSharing(workflow); + expect(sharedWorkflows).toHaveLength(2); + + // Re-enable for cleanup + await securitySettingsService.setPersonalSpaceSetting(PERSONAL_SPACE_SHARING_SETTING, true); + }); + }); +}); + describe('GET /workflows/new', () => { [true, false].forEach((sharingEnabled) => { test(`should return an auto-incremented name, even when sharing is ${ diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 083c0c8234e..38a65e2784c 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -225,6 +225,7 @@ describe('POST /workflows', () => { 'workflow:read', 'workflow:share', 'workflow:unpublish', + 'workflow:unshare', 'workflow:update', ].sort(), ); @@ -1163,6 +1164,7 @@ describe('GET /workflows', () => { 'workflow:read', 'workflow:share', 'workflow:unpublish', + 'workflow:unshare', 'workflow:update', ].sort(), ); @@ -1191,6 +1193,7 @@ describe('GET /workflows', () => { 'workflow:publish', 'workflow:read', 'workflow:share', + 'workflow:unshare', 'workflow:update', ].sort(), ); @@ -1208,6 +1211,7 @@ describe('GET /workflows', () => { 'workflow:publish', 'workflow:read', 'workflow:share', + 'workflow:unshare', 'workflow:update', ].sort(), ); @@ -2331,6 +2335,7 @@ describe('GET /workflows?includeFolders=true', () => { 'workflow:read', 'workflow:share', 'workflow:unpublish', + 'workflow:unshare', 'workflow:update', ].sort(), ); @@ -2364,6 +2369,7 @@ describe('GET /workflows?includeFolders=true', () => { 'workflow:publish', 'workflow:read', 'workflow:share', + 'workflow:unshare', 'workflow:update', ].sort(), ); @@ -2381,6 +2387,7 @@ describe('GET /workflows?includeFolders=true', () => { 'workflow:publish', 'workflow:read', 'workflow:share', + 'workflow:unshare', 'workflow:update', ].sort(), ); diff --git a/packages/cli/test/migration/1771500000001-add-unshare-scope-to-custom-roles.test.ts b/packages/cli/test/migration/1771500000001-add-unshare-scope-to-custom-roles.test.ts new file mode 100644 index 00000000000..653827c6401 --- /dev/null +++ b/packages/cli/test/migration/1771500000001-add-unshare-scope-to-custom-roles.test.ts @@ -0,0 +1,412 @@ +import { + createTestMigrationContext, + initDbUpToMigration, + runSingleMigration, + undoLastSingleMigration, + type TestMigrationContext, +} from '@n8n/backend-test-utils'; +import { DbConnection } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; + +const MIGRATION_NAME = 'AddUnshareScopeToCustomRoles1771500000001'; + +interface ScopeData { + slug: string; + displayName: string; + description: string; +} + +interface RoleData { + slug: string; + displayName: string; + roleType: string; + systemRole?: boolean; +} + +interface RoleScopeData { + roleSlug: string; + scopeSlug: string; +} + +interface RoleScopeRow { + roleSlug: string; + scopeSlug: string; +} + +describe('AddUnshareScopeToCustomRoles Migration', () => { + let dataSource: DataSource; + + beforeEach(async () => { + const dbConnection = Container.get(DbConnection); + await dbConnection.init(); + + dataSource = Container.get(DataSource); + const context = createTestMigrationContext(dataSource); + await context.queryRunner.clearDatabase(); + await initDbUpToMigration(MIGRATION_NAME); + }); + + afterEach(async () => { + const dbConnection = Container.get(DbConnection); + await dbConnection.close(); + }); + + async function insertTestScope( + context: TestMigrationContext, + scopeData: ScopeData, + ): Promise { + const tableName = context.escape.tableName('scope'); + const slugColumn = context.escape.columnName('slug'); + const displayNameColumn = context.escape.columnName('displayName'); + const descriptionColumn = context.escape.columnName('description'); + + const existingScope = await context.runQuery( + `SELECT ${slugColumn} FROM ${tableName} WHERE ${slugColumn} = :slug`, + { slug: scopeData.slug }, + ); + + if (existingScope.length === 0) { + await context.runQuery( + `INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${descriptionColumn}) VALUES (:slug, :displayName, :description)`, + { + slug: scopeData.slug, + displayName: scopeData.displayName, + description: scopeData.description, + }, + ); + } + } + + async function insertTestRole(context: TestMigrationContext, roleData: RoleData): Promise { + const tableName = context.escape.tableName('role'); + const slugColumn = context.escape.columnName('slug'); + const displayNameColumn = context.escape.columnName('displayName'); + const roleTypeColumn = context.escape.columnName('roleType'); + const systemRoleColumn = context.escape.columnName('systemRole'); + const createdAtColumn = context.escape.columnName('createdAt'); + const updatedAtColumn = context.escape.columnName('updatedAt'); + + const systemRole = roleData.systemRole ?? false; + + const insertSql = context.isPostgres + ? `INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${roleTypeColumn}, ${systemRoleColumn}, ${createdAtColumn}, ${updatedAtColumn}) VALUES (:slug, :displayName, :roleType, :systemRole, :createdAt, :updatedAt) ON CONFLICT (${slugColumn}) DO NOTHING` + : `INSERT OR IGNORE INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${roleTypeColumn}, ${systemRoleColumn}, ${createdAtColumn}, ${updatedAtColumn}) VALUES (:slug, :displayName, :roleType, :systemRole, :createdAt, :updatedAt)`; + + await context.runQuery(insertSql, { + slug: roleData.slug, + displayName: roleData.displayName, + roleType: roleData.roleType, + systemRole, + createdAt: new Date(), + updatedAt: new Date(), + }); + } + + async function insertTestRoleScope( + context: TestMigrationContext, + roleScopeData: RoleScopeData, + ): Promise { + const tableName = context.escape.tableName('role_scope'); + const roleSlugColumn = context.escape.columnName('roleSlug'); + const scopeSlugColumn = context.escape.columnName('scopeSlug'); + + await context.runQuery( + `INSERT INTO ${tableName} (${roleSlugColumn}, ${scopeSlugColumn}) VALUES (:roleSlug, :scopeSlug)`, + { roleSlug: roleScopeData.roleSlug, scopeSlug: roleScopeData.scopeSlug }, + ); + } + + async function getRoleScopesByRole( + context: TestMigrationContext, + roleSlug: string, + ): Promise { + const tableName = context.escape.tableName('role_scope'); + const roleSlugColumn = context.escape.columnName('roleSlug'); + const scopeSlugColumn = context.escape.columnName('scopeSlug'); + + return await context.runQuery( + `SELECT ${roleSlugColumn} AS "roleSlug", ${scopeSlugColumn} AS "scopeSlug" FROM ${tableName} WHERE ${roleSlugColumn} = :roleSlug`, + { roleSlug }, + ); + } + + async function getRoleScopesByScope( + context: TestMigrationContext, + scopeSlug: string, + ): Promise { + const tableName = context.escape.tableName('role_scope'); + const roleSlugColumn = context.escape.columnName('roleSlug'); + const scopeSlugColumn = context.escape.columnName('scopeSlug'); + + return await context.runQuery( + `SELECT ${roleSlugColumn} AS "roleSlug", ${scopeSlugColumn} AS "scopeSlug" FROM ${tableName} WHERE ${scopeSlugColumn} = :scopeSlug`, + { scopeSlug }, + ); + } + + async function getScopeBySlug( + context: TestMigrationContext, + slug: string, + ): Promise<{ slug: string } | null> { + const tableName = context.escape.tableName('scope'); + const slugColumn = context.escape.columnName('slug'); + + const rows = await context.runQuery>>( + `SELECT ${slugColumn} AS "slug" FROM ${tableName} WHERE ${slugColumn} = :slug`, + { slug }, + ); + + return rows[0] ? (rows[0] as { slug: string }) : null; + } + + describe('up migration', () => { + it('should create workflow:unshare and credential:unshare scopes', async () => { + const context = createTestMigrationContext(dataSource); + + expect(await getScopeBySlug(context, 'workflow:unshare')).toBeNull(); + expect(await getScopeBySlug(context, 'credential:unshare')).toBeNull(); + + await context.queryRunner.release(); + + await runSingleMigration(MIGRATION_NAME); + dataSource = Container.get(DataSource); + + const postContext = createTestMigrationContext(dataSource); + const workflowScope = await getScopeBySlug(postContext, 'workflow:unshare'); + expect(workflowScope).not.toBeNull(); + expect(workflowScope?.slug).toBe('workflow:unshare'); + + const credentialScope = await getScopeBySlug(postContext, 'credential:unshare'); + expect(credentialScope).not.toBeNull(); + expect(credentialScope?.slug).toBe('credential:unshare'); + + await postContext.queryRunner.release(); + }); + + it('should add unshare scopes to roles that have share scopes, excluding project:personalOwner', async () => { + const context = createTestMigrationContext(dataSource); + + // Set up scopes + await insertTestScope(context, { + slug: 'workflow:share', + displayName: 'Share Workflow', + description: 'Allows sharing workflows.', + }); + await insertTestScope(context, { + slug: 'credential:share', + displayName: 'Share Credential', + description: 'Allows sharing credentials.', + }); + + // Set up roles + await insertTestRole(context, { + slug: 'custom-editor', + displayName: 'Custom Editor', + roleType: 'project', + }); + await insertTestRole(context, { + slug: 'project:personalOwner', + displayName: 'Personal Owner', + roleType: 'project', + }); + + // Assign share scopes to both roles + await insertTestRoleScope(context, { + roleSlug: 'custom-editor', + scopeSlug: 'workflow:share', + }); + await insertTestRoleScope(context, { + roleSlug: 'custom-editor', + scopeSlug: 'credential:share', + }); + await insertTestRoleScope(context, { + roleSlug: 'project:personalOwner', + scopeSlug: 'workflow:share', + }); + await insertTestRoleScope(context, { + roleSlug: 'project:personalOwner', + scopeSlug: 'credential:share', + }); + + await context.queryRunner.release(); + + await runSingleMigration(MIGRATION_NAME); + dataSource = Container.get(DataSource); + + const postContext = createTestMigrationContext(dataSource); + + // custom-editor should get both unshare scopes + const customEditorScopes = await getRoleScopesByRole(postContext, 'custom-editor'); + const customEditorScopeSlugs = customEditorScopes.map((s) => s.scopeSlug).sort(); + expect(customEditorScopeSlugs).toEqual([ + 'credential:share', + 'credential:unshare', + 'workflow:share', + 'workflow:unshare', + ]); + + // project:personalOwner should NOT get unshare scopes from migration + const personalOwnerScopes = await getRoleScopesByRole(postContext, 'project:personalOwner'); + const personalOwnerScopeSlugs = personalOwnerScopes.map((s) => s.scopeSlug).sort(); + expect(personalOwnerScopeSlugs).toEqual(['credential:share', 'workflow:share']); + expect(personalOwnerScopeSlugs).not.toContain('workflow:unshare'); + expect(personalOwnerScopeSlugs).not.toContain('credential:unshare'); + + await postContext.queryRunner.release(); + }); + + it('should not add workflow:unshare to roles without workflow:share', async () => { + const context = createTestMigrationContext(dataSource); + + await insertTestScope(context, { + slug: 'credential:share', + displayName: 'Share Credential', + description: 'Allows sharing credentials.', + }); + await insertTestScope(context, { + slug: 'workflow:read', + displayName: 'Read Workflow', + description: 'Allows reading workflows.', + }); + + await insertTestRole(context, { + slug: 'cred-only-sharer', + displayName: 'Credential Only Sharer', + roleType: 'project', + }); + + // Only has credential:share, not workflow:share + await insertTestRoleScope(context, { + roleSlug: 'cred-only-sharer', + scopeSlug: 'credential:share', + }); + await insertTestRoleScope(context, { + roleSlug: 'cred-only-sharer', + scopeSlug: 'workflow:read', + }); + + await context.queryRunner.release(); + + await runSingleMigration(MIGRATION_NAME); + dataSource = Container.get(DataSource); + + const postContext = createTestMigrationContext(dataSource); + const scopes = await getRoleScopesByRole(postContext, 'cred-only-sharer'); + const scopeSlugs = scopes.map((s) => s.scopeSlug).sort(); + + // Should have credential:unshare but NOT workflow:unshare + expect(scopeSlugs).toContain('credential:unshare'); + expect(scopeSlugs).not.toContain('workflow:unshare'); + + await postContext.queryRunner.release(); + }); + + it('should not duplicate unshare scopes for roles that already have them', async () => { + const context = createTestMigrationContext(dataSource); + + await insertTestScope(context, { + slug: 'workflow:share', + displayName: 'Share Workflow', + description: 'Allows sharing workflows.', + }); + await insertTestScope(context, { + slug: 'workflow:unshare', + displayName: 'Unshare Workflow', + description: 'Allows removing workflow shares.', + }); + + await insertTestRole(context, { + slug: 'already-has-unshare', + displayName: 'Already Has Unshare', + roleType: 'project', + }); + + await insertTestRoleScope(context, { + roleSlug: 'already-has-unshare', + scopeSlug: 'workflow:share', + }); + await insertTestRoleScope(context, { + roleSlug: 'already-has-unshare', + scopeSlug: 'workflow:unshare', + }); + + await context.queryRunner.release(); + + await runSingleMigration(MIGRATION_NAME); + dataSource = Container.get(DataSource); + + const postContext = createTestMigrationContext(dataSource); + const scopes = await getRoleScopesByRole(postContext, 'already-has-unshare'); + const unshareCount = scopes.filter((s) => s.scopeSlug === 'workflow:unshare').length; + expect(unshareCount).toBe(1); + + await postContext.queryRunner.release(); + }); + }); + + describe('down migration', () => { + it('should remove all unshare role_scope entries', async () => { + const context = createTestMigrationContext(dataSource); + + await insertTestScope(context, { + slug: 'workflow:share', + displayName: 'Share Workflow', + description: 'Allows sharing workflows.', + }); + await insertTestScope(context, { + slug: 'credential:share', + displayName: 'Share Credential', + description: 'Allows sharing credentials.', + }); + await insertTestRole(context, { + slug: 'role-with-share', + displayName: 'Role With Share', + roleType: 'project', + }); + await insertTestRoleScope(context, { + roleSlug: 'role-with-share', + scopeSlug: 'workflow:share', + }); + await insertTestRoleScope(context, { + roleSlug: 'role-with-share', + scopeSlug: 'credential:share', + }); + + await context.queryRunner.release(); + + await runSingleMigration(MIGRATION_NAME); + dataSource = Container.get(DataSource); + + // Verify up migration added the scopes + const afterUp = createTestMigrationContext(dataSource); + const workflowUnshareAfterUp = await getRoleScopesByScope(afterUp, 'workflow:unshare'); + expect(workflowUnshareAfterUp.length).toBeGreaterThan(0); + const credentialUnshareAfterUp = await getRoleScopesByScope(afterUp, 'credential:unshare'); + expect(credentialUnshareAfterUp.length).toBeGreaterThan(0); + await afterUp.queryRunner.release(); + + // Run down migration + await undoLastSingleMigration(); + dataSource = Container.get(DataSource); + + const postContext = createTestMigrationContext(dataSource); + + const workflowUnshareAfterDown = await getRoleScopesByScope(postContext, 'workflow:unshare'); + expect(workflowUnshareAfterDown).toHaveLength(0); + + const credentialUnshareAfterDown = await getRoleScopesByScope( + postContext, + 'credential:unshare', + ); + expect(credentialUnshareAfterDown).toHaveLength(0); + + // Original share scopes should still be there + const roleScopesAfterDown = await getRoleScopesByRole(postContext, 'role-with-share'); + const slugs = roleScopesAfterDown.map((s) => s.scopeSlug).sort(); + expect(slugs).toEqual(['credential:share', 'workflow:share']); + + await postContext.queryRunner.release(); + }); + }); +});