diff --git a/packages/cli/.eslintrc.js b/packages/cli/.eslintrc.js index 1840061479716..9ccd6b341655a 100644 --- a/packages/cli/.eslintrc.js +++ b/packages/cli/.eslintrc.js @@ -34,4 +34,20 @@ module.exports = { '@typescript-eslint/no-unsafe-enum-comparison': 'warn', '@typescript-eslint/no-unsafe-declaration-merging': 'warn', }, + + overrides: [ + { + files: ['./src/decorators/**/*.ts'], + rules: { + '@typescript-eslint/ban-types': [ + 'warn', + { + types: { + Function: false, + }, + }, + ], + }, + }, + ], }; diff --git a/packages/cli/src/ExternalSecrets/ExternalSecrets.controller.ee.ts b/packages/cli/src/ExternalSecrets/ExternalSecrets.controller.ee.ts index 03967cf3ee26d..1be5bc7a42bc9 100644 --- a/packages/cli/src/ExternalSecrets/ExternalSecrets.controller.ee.ts +++ b/packages/cli/src/ExternalSecrets/ExternalSecrets.controller.ee.ts @@ -1,4 +1,4 @@ -import { Authorized, Get, Post, RestController, RequireGlobalScope } from '@/decorators'; +import { Authorized, Get, Post, RestController, GlobalScope } from '@/decorators'; import { ExternalSecretsRequest } from '@/requests'; import { Response } from 'express'; import { ExternalSecretsService } from './ExternalSecrets.service.ee'; @@ -11,13 +11,13 @@ export class ExternalSecretsController { constructor(private readonly secretsService: ExternalSecretsService) {} @Get('/providers') - @RequireGlobalScope('externalSecretsProvider:list') + @GlobalScope('externalSecretsProvider:list') async getProviders() { return await this.secretsService.getProviders(); } @Get('/providers/:provider') - @RequireGlobalScope('externalSecretsProvider:read') + @GlobalScope('externalSecretsProvider:read') async getProvider(req: ExternalSecretsRequest.GetProvider) { const providerName = req.params.provider; try { @@ -31,7 +31,7 @@ export class ExternalSecretsController { } @Post('/providers/:provider/test') - @RequireGlobalScope('externalSecretsProvider:read') + @GlobalScope('externalSecretsProvider:read') async testProviderSettings(req: ExternalSecretsRequest.TestProviderSettings, res: Response) { const providerName = req.params.provider; try { @@ -51,7 +51,7 @@ export class ExternalSecretsController { } @Post('/providers/:provider') - @RequireGlobalScope('externalSecretsProvider:create') + @GlobalScope('externalSecretsProvider:create') async setProviderSettings(req: ExternalSecretsRequest.SetProviderSettings) { const providerName = req.params.provider; try { @@ -66,7 +66,7 @@ export class ExternalSecretsController { } @Post('/providers/:provider/connect') - @RequireGlobalScope('externalSecretsProvider:update') + @GlobalScope('externalSecretsProvider:update') async setProviderConnected(req: ExternalSecretsRequest.SetProviderConnected) { const providerName = req.params.provider; try { @@ -81,7 +81,7 @@ export class ExternalSecretsController { } @Post('/providers/:provider/update') - @RequireGlobalScope('externalSecretsProvider:sync') + @GlobalScope('externalSecretsProvider:sync') async updateProvider(req: ExternalSecretsRequest.UpdateProvider, res: Response) { const providerName = req.params.provider; try { @@ -101,7 +101,7 @@ export class ExternalSecretsController { } @Get('/secrets') - @RequireGlobalScope('externalSecret:list') + @GlobalScope('externalSecret:list') getSecretNames() { return this.secretsService.getAllSecrets(); } diff --git a/packages/cli/src/Ldap/ldap.controller.ts b/packages/cli/src/Ldap/ldap.controller.ts index a158d81d49c66..cb408cf1c9a2a 100644 --- a/packages/cli/src/Ldap/ldap.controller.ts +++ b/packages/cli/src/Ldap/ldap.controller.ts @@ -1,5 +1,5 @@ import pick from 'lodash/pick'; -import { Authorized, Get, Post, Put, RestController, RequireGlobalScope } from '@/decorators'; +import { Authorized, Get, Post, Put, RestController, GlobalScope } from '@/decorators'; import { InternalHooks } from '@/InternalHooks'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @@ -17,13 +17,13 @@ export class LdapController { ) {} @Get('/config') - @RequireGlobalScope('ldap:manage') + @GlobalScope('ldap:manage') async getConfig() { return await this.ldapService.loadConfig(); } @Post('/test-connection') - @RequireGlobalScope('ldap:manage') + @GlobalScope('ldap:manage') async testConnection() { try { await this.ldapService.testConnection(); @@ -33,7 +33,7 @@ export class LdapController { } @Put('/config') - @RequireGlobalScope('ldap:manage') + @GlobalScope('ldap:manage') async updateConfig(req: LdapConfiguration.Update) { try { await this.ldapService.updateConfig(req.body); @@ -52,14 +52,14 @@ export class LdapController { } @Get('/sync') - @RequireGlobalScope('ldap:sync') + @GlobalScope('ldap:sync') async getLdapSync(req: LdapConfiguration.GetSync) { const { page = '0', perPage = '20' } = req.query; return await getLdapSynchronizations(parseInt(page, 10), parseInt(perPage, 10)); } @Post('/sync') - @RequireGlobalScope('ldap:sync') + @GlobalScope('ldap:sync') async syncLdap(req: LdapConfiguration.Sync) { try { await this.ldapService.runSync(req.body.type); diff --git a/packages/cli/src/PublicApi/types.ts b/packages/cli/src/PublicApi/types.ts index b57080974bdad..3ef7042dcac51 100644 --- a/packages/cli/src/PublicApi/types.ts +++ b/packages/cli/src/PublicApi/types.ts @@ -1,5 +1,5 @@ import type express from 'express'; -import type { IDataObject, ExecutionStatus } from 'n8n-workflow'; +import type { ExecutionStatus, ICredentialDataDecryptedObject } from 'n8n-workflow'; import type { User } from '@db/entities/User'; @@ -151,7 +151,14 @@ export declare namespace UserRequest { } export declare namespace CredentialRequest { - type Create = AuthenticatedRequest<{}, {}, { type: string; name: string; data: IDataObject }, {}>; + type Create = AuthenticatedRequest< + {}, + {}, + { type: string; name: string; data: ICredentialDataDecryptedObject }, + {} + >; + + type Delete = AuthenticatedRequest<{ id: string }, {}, {}, Record>; } export type OperationID = 'getUsers' | 'getUser'; diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts index 1a4275f949dc2..a55a7b6bbe3e7 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts @@ -4,8 +4,7 @@ import type express from 'express'; import { CredentialsHelper } from '@/CredentialsHelper'; import { CredentialTypes } from '@/CredentialTypes'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; -import type { CredentialRequest } from '@/requests'; -import type { CredentialTypeRequest } from '../../../types'; +import type { CredentialTypeRequest, CredentialRequest } from '../../../types'; import { authorize } from '../../shared/middlewares/global.middleware'; import { validCredentialsProperties, validCredentialType } from './credentials.middleware'; diff --git a/packages/cli/src/auth/jwt.ts b/packages/cli/src/auth/jwt.ts index affc9ea75f8b8..f9bcb78b4c0c2 100644 --- a/packages/cli/src/auth/jwt.ts +++ b/packages/cli/src/auth/jwt.ts @@ -8,7 +8,7 @@ import { License } from '@/License'; import { Container } from 'typedi'; import { UserRepository } from '@db/repositories/user.repository'; import { JwtService } from '@/services/jwt.service'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { AuthError } from '@/errors/response-errors/auth.error'; import { ApplicationError } from 'n8n-workflow'; @@ -30,7 +30,7 @@ export function issueJWT(user: User): JwtToken { !user.isOwner && !isWithinUsersLimit ) { - throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } if (password) { payload.password = createHash('sha256') diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 9e65e4c342bfa..8909f5533fd9b 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -48,7 +48,8 @@ export const RESPONSE_ERROR_MESSAGES = { USERS_QUOTA_REACHED: 'Maximum number of users reached', OAUTH2_CREDENTIAL_TEST_SUCCEEDED: 'Connection Successful!', OAUTH2_CREDENTIAL_TEST_FAILED: 'This OAuth2 credential was not connected to an account.', -}; + MISSING_SCOPE: 'User is missing a scope required to perform this action', +} as const; export const AUTH_COOKIE_NAME = 'n8n-auth'; diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 7ba13a2677dc7..0b41b3bd0ec7b 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -22,7 +22,7 @@ import { Logger } from '@/Logger'; import { AuthError } from '@/errors/response-errors/auth.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { ApplicationError } from 'n8n-workflow'; import { UserRepository } from '@/databases/repositories/user.repository'; @@ -166,7 +166,7 @@ export class AuthController { inviterId, inviteeId, }); - throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } if (!inviterId || !inviteeId) { diff --git a/packages/cli/src/controllers/communityPackages.controller.ts b/packages/cli/src/controllers/communityPackages.controller.ts index 0e260c863b52f..918a3e3dcae1a 100644 --- a/packages/cli/src/controllers/communityPackages.controller.ts +++ b/packages/cli/src/controllers/communityPackages.controller.ts @@ -13,7 +13,7 @@ import { Patch, Post, RestController, - RequireGlobalScope, + GlobalScope, } from '@/decorators'; import { NodeRequest } from '@/requests'; import type { InstalledPackages } from '@db/entities/InstalledPackages'; @@ -62,7 +62,7 @@ export class CommunityPackagesController { } @Post('/') - @RequireGlobalScope('communityPackage:install') + @GlobalScope('communityPackage:install') async installPackage(req: NodeRequest.Post) { const { name } = req.body; @@ -159,7 +159,7 @@ export class CommunityPackagesController { } @Get('/') - @RequireGlobalScope('communityPackage:list') + @GlobalScope('communityPackage:list') async getInstalledPackages() { const installedPackages = await this.communityPackagesService.getAllInstalledPackages(); @@ -194,7 +194,7 @@ export class CommunityPackagesController { } @Delete('/') - @RequireGlobalScope('communityPackage:uninstall') + @GlobalScope('communityPackage:uninstall') async uninstallPackage(req: NodeRequest.Delete) { const { name } = req.query; @@ -246,7 +246,7 @@ export class CommunityPackagesController { } @Patch('/') - @RequireGlobalScope('communityPackage:update') + @GlobalScope('communityPackage:update') async updatePackage(req: NodeRequest.Update) { const { name } = req.body; diff --git a/packages/cli/src/controllers/invitation.controller.ts b/packages/cli/src/controllers/invitation.controller.ts index cba846965dca5..e6cf5dcb14882 100644 --- a/packages/cli/src/controllers/invitation.controller.ts +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -2,7 +2,7 @@ import { Response } from 'express'; import validator from 'validator'; import config from '@/config'; -import { Authorized, NoAuthRequired, Post, RequireGlobalScope, RestController } from '@/decorators'; +import { Authorized, NoAuthRequired, Post, RestController, GlobalScope } from '@/decorators'; import { issueCookie } from '@/auth/jwt'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import { UserRequest } from '@/requests'; @@ -15,7 +15,7 @@ import { PostHogClient } from '@/posthog'; import type { User } from '@/databases/entities/User'; import { UserRepository } from '@db/repositories/user.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { InternalHooks } from '@/InternalHooks'; import { ExternalHooks } from '@/ExternalHooks'; @@ -38,7 +38,7 @@ export class InvitationController { */ @Post('/') - @RequireGlobalScope('user:create') + @GlobalScope('user:create') async inviteUser(req: UserRequest.Invite) { const isWithinUsersLimit = this.license.isWithinUsersLimit(); @@ -55,7 +55,7 @@ export class InvitationController { this.logger.debug( 'Request to send email invite(s) to user(s) failed because the user limit quota has been reached', ); - throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } if (!config.getEnv('userManagement.isInstanceOwnerSetUp')) { @@ -98,7 +98,7 @@ export class InvitationController { } if (invite.role === 'global:admin' && !this.license.isAdvancedPermissionsLicensed()) { - throw new UnauthorizedError( + throw new ForbiddenError( 'Cannot invite admin user without advanced permissions. Please upgrade to a license that includes this feature.', ); } diff --git a/packages/cli/src/controllers/orchestration.controller.ts b/packages/cli/src/controllers/orchestration.controller.ts index fb044014bebe1..6ef467effd9e9 100644 --- a/packages/cli/src/controllers/orchestration.controller.ts +++ b/packages/cli/src/controllers/orchestration.controller.ts @@ -1,4 +1,4 @@ -import { Authorized, Post, RestController, RequireGlobalScope } from '@/decorators'; +import { Authorized, Post, RestController, GlobalScope } from '@/decorators'; import { OrchestrationRequest } from '@/requests'; import { OrchestrationService } from '@/services/orchestration.service'; import { License } from '@/License'; @@ -15,7 +15,7 @@ export class OrchestrationController { * These endpoints do not return anything, they just trigger the messsage to * the workers to respond on Redis with their status. */ - @RequireGlobalScope('orchestration:read') + @GlobalScope('orchestration:read') @Post('/worker/status/:id') async getWorkersStatus(req: OrchestrationRequest.Get) { if (!this.licenseService.isWorkerViewLicensed()) return; @@ -23,14 +23,14 @@ export class OrchestrationController { return await this.orchestrationService.getWorkerStatus(id); } - @RequireGlobalScope('orchestration:read') + @GlobalScope('orchestration:read') @Post('/worker/status') async getWorkersStatusAll() { if (!this.licenseService.isWorkerViewLicensed()) return; return await this.orchestrationService.getWorkerStatus(); } - @RequireGlobalScope('orchestration:list') + @GlobalScope('orchestration:list') @Post('/worker/ids') async getWorkerIdsAll() { if (!this.licenseService.isWorkerViewLicensed()) return; diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index 806d892d26375..713124fb5e561 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -18,7 +18,7 @@ import { InternalHooks } from '@/InternalHooks'; import { UrlService } from '@/services/url.service'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; import { UserRepository } from '@/databases/repositories/user.repository'; @@ -84,7 +84,7 @@ export class PasswordResetController { this.logger.debug( 'Request to send password reset email failed because the user limit was reached', ); - throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } if ( isSamlCurrentAuthenticationMethod() && @@ -96,7 +96,7 @@ export class PasswordResetController { this.logger.debug( 'Request to send password reset email failed because login is handled by SAML', ); - throw new UnauthorizedError( + throw new ForbiddenError( 'Login is handled by SAML. Please contact your Identity Provider to reset your password.', ); } @@ -171,7 +171,7 @@ export class PasswordResetController { 'Request to resolve password token failed because the user limit was reached', { userId: user.id }, ); - throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); + throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } this.logger.info('Reset-password token resolved successfully', { userId: user.id }); diff --git a/packages/cli/src/controllers/tags.controller.ts b/packages/cli/src/controllers/tags.controller.ts index a50cb25dcd094..d7c7c6fced948 100644 --- a/packages/cli/src/controllers/tags.controller.ts +++ b/packages/cli/src/controllers/tags.controller.ts @@ -8,7 +8,7 @@ import { Patch, Post, RestController, - RequireGlobalScope, + GlobalScope, } from '@/decorators'; import { TagService } from '@/services/tag.service'; import { TagsRequest } from '@/requests'; @@ -30,13 +30,13 @@ export class TagsController { } @Get('/') - @RequireGlobalScope('tag:list') + @GlobalScope('tag:list') async getAll(req: TagsRequest.GetAll) { return await this.tagService.getAll({ withUsageCount: req.query.withUsageCount === 'true' }); } @Post('/') - @RequireGlobalScope('tag:create') + @GlobalScope('tag:create') async createTag(req: TagsRequest.Create) { const tag = this.tagService.toEntity({ name: req.body.name }); @@ -44,7 +44,7 @@ export class TagsController { } @Patch('/:id(\\w+)') - @RequireGlobalScope('tag:update') + @GlobalScope('tag:update') async updateTag(req: TagsRequest.Update) { const newTag = this.tagService.toEntity({ id: req.params.id, name: req.body.name.trim() }); @@ -52,7 +52,7 @@ export class TagsController { } @Delete('/:id(\\w+)') - @RequireGlobalScope('tag:delete') + @GlobalScope('tag:delete') async deleteTag(req: TagsRequest.Delete) { const { id } = req.params; diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 2ed94e1345fea..8a3955267c61b 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -2,13 +2,13 @@ import { User } from '@db/entities/User'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { - RequireGlobalScope, Authorized, Delete, Get, RestController, Patch, Licensed, + GlobalScope, } from '@/decorators'; import { ListQuery, @@ -26,7 +26,7 @@ import { plainToInstance } from 'class-transformer'; import { UserService } from '@/services/user.service'; import { listQueryMiddleware } from '@/middlewares'; import { Logger } from '@/Logger'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ExternalHooks } from '@/ExternalHooks'; @@ -86,7 +86,7 @@ export class UsersController { } @Get('/', { middlewares: listQueryMiddleware }) - @RequireGlobalScope('user:list') + @GlobalScope('user:list') async listUsers(req: ListQuery.Request) { const { listQueryOptions } = req; @@ -107,7 +107,7 @@ export class UsersController { } @Get('/:id/password-reset-link') - @RequireGlobalScope('user:resetPassword') + @GlobalScope('user:resetPassword') async getUserPasswordResetLink(req: UserRequest.PasswordResetLink) { const user = await this.userRepository.findOneOrFail({ where: { id: req.params.id }, @@ -121,7 +121,7 @@ export class UsersController { } @Patch('/:id/settings') - @RequireGlobalScope('user:update') + @GlobalScope('user:update') async updateUserSettings(req: UserRequest.UserSettingsUpdate) { const payload = plainToInstance(UserSettingsUpdatePayload, req.body); @@ -141,7 +141,7 @@ export class UsersController { * Delete a user. Optionally, designate a transferee for their workflows and credentials. */ @Delete('/:id') - @RequireGlobalScope('user:delete') + @GlobalScope('user:delete') async deleteUser(req: UserRequest.Delete) { const { id: idToDelete } = req.params; @@ -293,7 +293,7 @@ export class UsersController { } @Patch('/:id/role') - @RequireGlobalScope('user:changeRole') + @GlobalScope('user:changeRole') @Licensed('feat:advancedPermissions') async changeGlobalRole(req: UserRequest.ChangeRole) { const { NO_ADMIN_ON_OWNER, NO_USER, NO_OWNER_ON_OWNER } = @@ -310,11 +310,11 @@ export class UsersController { } if (req.user.role === 'global:admin' && targetUser.role === 'global:owner') { - throw new UnauthorizedError(NO_ADMIN_ON_OWNER); + throw new ForbiddenError(NO_ADMIN_ON_OWNER); } if (req.user.role === 'global:owner' && targetUser.role === 'global:owner') { - throw new UnauthorizedError(NO_OWNER_ON_OWNER); + throw new ForbiddenError(NO_OWNER_ON_OWNER); } await this.userService.update(targetUser.id, { role: payload.newRoleName }); diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 9d26c37f08d05..0660cb758b754 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -5,13 +5,23 @@ import { CredentialRequest, ListQuery } from '@/requests'; import { InternalHooks } from '@/InternalHooks'; import { Logger } from '@/Logger'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NamingService } from '@/services/naming.service'; import { License } from '@/License'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import { OwnershipService } from '@/services/ownership.service'; import { EnterpriseCredentialsService } from './credentials.service.ee'; -import { Authorized, Delete, Get, Licensed, Patch, Post, Put, RestController } from '@/decorators'; +import { + Authorized, + Delete, + Get, + Licensed, + Patch, + Post, + Put, + RestController, + ProjectScope, +} from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { UserManagementMailer } from '@/UserManagement/email'; import * as Db from '@/Db'; @@ -49,10 +59,11 @@ export class CredentialsController { }; } - @Get('/:id') + @Get('/:credentialId') + @ProjectScope('credential:read') async getOne(req: CredentialRequest.Get) { if (this.license.isSharingEnabled()) { - const { id: credentialId } = req.params; + const { credentialId } = req.params; const includeDecryptedData = req.query.includeData === 'true'; let credential = await this.credentialsRepository.findOne({ @@ -69,7 +80,7 @@ export class CredentialsController { const userSharing = credential.shared?.find((shared) => shared.user.id === req.user.id); if (!userSharing && !req.user.hasGlobalScope('credential:read')) { - throw new UnauthorizedError('Forbidden.'); + throw new ForbiddenError(); } credential = this.ownershipService.addOwnedByAndSharedWith(credential); @@ -91,7 +102,7 @@ export class CredentialsController { // non-enterprise - const { id: credentialId } = req.params; + const { credentialId } = req.params; const includeDecryptedData = req.query.includeData === 'true'; const sharing = await this.credentialsService.getSharing( @@ -138,7 +149,7 @@ export class CredentialsController { }); if (!ownsCredential) { if (!sharing) { - throw new UnauthorizedError('Forbidden'); + throw new ForbiddenError(); } const decryptedData = this.credentialsService.decrypt(sharing.credentials); @@ -196,9 +207,10 @@ export class CredentialsController { return credential; } - @Patch('/:id') + @Patch('/:credentialId') + @ProjectScope('credential:update') async updateCredentials(req: CredentialRequest.Update) { - const { id: credentialId } = req.params; + const { credentialId } = req.params; const sharing = await this.credentialsService.getSharing( req.user, @@ -225,7 +237,7 @@ export class CredentialsController { credentialId, userId: req.user.id, }); - throw new UnauthorizedError('You can only update credentials owned by you'); + throw new ForbiddenError('You can only update credentials owned by you'); } const { credentials: credential } = sharing; @@ -254,9 +266,10 @@ export class CredentialsController { return { ...rest }; } - @Delete('/:id') + @Delete('/:credentialId') + @ProjectScope('credential:delete') async deleteCredentials(req: CredentialRequest.Delete) { - const { id: credentialId } = req.params; + const { credentialId } = req.params; const sharing = await this.credentialsService.getSharing( req.user, @@ -283,7 +296,7 @@ export class CredentialsController { credentialId, userId: req.user.id, }); - throw new UnauthorizedError('You can only remove credentials owned by you'); + throw new ForbiddenError('You can only remove credentials owned by you'); } const { credentials: credential } = sharing; @@ -294,9 +307,10 @@ export class CredentialsController { } @Licensed('feat:sharing') - @Put('/:id/share') + @Put('/:credentialId/share') + @ProjectScope('credential:share') async shareCredentials(req: CredentialRequest.Share) { - const { id: credentialId } = req.params; + const { credentialId } = req.params; const { shareWithIds } = req.body; if ( @@ -324,7 +338,7 @@ export class CredentialsController { credential = sharedRes?.credentials; } if (!credential) { - throw new UnauthorizedError('Forbidden'); + throw new ForbiddenError(); } } diff --git a/packages/cli/src/databases/entities/User.ts b/packages/cli/src/databases/entities/User.ts index a9604ef36b7df..905a99efd84b8 100644 --- a/packages/cli/src/databases/entities/User.ts +++ b/packages/cli/src/databases/entities/User.ts @@ -18,17 +18,21 @@ import { objectRetriever, lowerCaser } from '../utils/transformers'; import { WithTimestamps, jsonColumnType } from './AbstractEntity'; import type { IPersonalizationSurveyAnswers } from '@/Interfaces'; import type { AuthIdentity } from './AuthIdentity'; -import { ownerPermissions, memberPermissions, adminPermissions } from '@/permissions/roles'; +import { + GLOBAL_OWNER_SCOPES, + GLOBAL_MEMBER_SCOPES, + GLOBAL_ADMIN_SCOPES, +} from '@/permissions/global-roles'; import { hasScope, type ScopeOptions, type Scope } from '@n8n/permissions'; -import type { ProjectRelation } from './ProjectRelation'; +import type { ProjectRelation, ProjectRole } from './ProjectRelation'; export type GlobalRole = 'global:owner' | 'global:admin' | 'global:member'; export type AssignableRole = Exclude; const STATIC_SCOPE_MAP: Record = { - 'global:owner': ownerPermissions, - 'global:member': memberPermissions, - 'global:admin': adminPermissions, + 'global:owner': GLOBAL_OWNER_SCOPES, + 'global:member': GLOBAL_MEMBER_SCOPES, + 'global:admin': GLOBAL_ADMIN_SCOPES, }; @Entity() @@ -146,4 +150,17 @@ export class User extends WithTimestamps implements IUser { scopeOptions, ); } + + hasScope(scope: Scope | Scope[], projectRole: ProjectRole) { + scope = Array.isArray(scope) ? scope : [scope]; + + return hasScope( + scope, + { + global: this.globalScopes, + project: ['credential:read'], // @TODO: Gather scopes for all project roles + }, + { sharing: scope }, + ); + } } diff --git a/packages/cli/src/databases/repositories/projectRelation.repository.ts b/packages/cli/src/databases/repositories/projectRelation.repository.ts index 5567926c1c2db..74f2c4a032823 100644 --- a/packages/cli/src/databases/repositories/projectRelation.repository.ts +++ b/packages/cli/src/databases/repositories/projectRelation.repository.ts @@ -7,4 +7,13 @@ export class ProjectRelationRepository extends Repository { constructor(dataSource: DataSource) { super(ProjectRelation, dataSource.manager); } + + /** + * Find the role of a user in a project. + */ + async findProjectRole({ userId, projectId }: { userId: string; projectId: string }) { + const relation = await this.findOneBy({ projectId, userId }); + + return relation?.role ?? null; + } } diff --git a/packages/cli/src/decorators/Scoped.ts b/packages/cli/src/decorators/Scoped.ts new file mode 100644 index 0000000000000..0d4644ae10f8b --- /dev/null +++ b/packages/cli/src/decorators/Scoped.ts @@ -0,0 +1,60 @@ +import type { Scope } from '@n8n/permissions'; +import type { RouteScopeMetadata } from './types'; +import { CONTROLLER_ROUTE_SCOPES } from './constants'; + +const Scoped = (scope: Scope | Scope[], { globalOnly } = { globalOnly: false }) => { + return (target: Function | object, handlerName?: string) => { + const controllerClass = handlerName ? target.constructor : target; + const scopes = (Reflect.getMetadata(CONTROLLER_ROUTE_SCOPES, controllerClass) ?? + {}) as RouteScopeMetadata; + + const metadata = { + scopes: Array.isArray(scope) ? scope : [scope], + globalOnly, + }; + + scopes[handlerName ?? '*'] = metadata; + Reflect.defineMetadata(CONTROLLER_ROUTE_SCOPES, scopes, controllerClass); + }; +}; + +/** + * Decorator for a controller method to ensure the user has a scope, + * checking only at the global level. + * + * To check only at project level as well, use the `@ProjectScope` decorator. + * + * @example + * ```ts + * @RestController() + * export class UsersController { + * @Delete('/:id') + * @GlobalScope('user:delete') + * async deleteUser(req, res) { ... } + * } + * ``` + */ +export const GlobalScope = (scope: Scope | Scope[]) => { + return Scoped(scope, { globalOnly: true }); +}; + +/** + * Decorator for a controller method to ensure the user has a scope, + * checking first at project level and then at global level. + * + * To check only at global level, use the `@GlobalScope` decorator. + * + * @example + * ```ts + * @RestController() + * export class WorkflowController { + * @Get('/:workflowId') + * @GlobalScope('workflow:read') + * async getWorkflow(req, res) { ... } + * } + * ``` + */ + +export const ProjectScope = (scope: Scope | Scope[]) => { + return Scoped(scope); +}; diff --git a/packages/cli/src/decorators/Scopes.ts b/packages/cli/src/decorators/Scopes.ts deleted file mode 100644 index 9e4bdca22a99b..0000000000000 --- a/packages/cli/src/decorators/Scopes.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Scope } from '@n8n/permissions'; -import type { ScopeMetadata } from './types'; -import { CONTROLLER_REQUIRED_SCOPES } from './constants'; - -export const RequireGlobalScope = (scope: Scope | Scope[]) => { - // eslint-disable-next-line @typescript-eslint/ban-types - return (target: Function | object, handlerName?: string) => { - const controllerClass = handlerName ? target.constructor : target; - const scopes = (Reflect.getMetadata(CONTROLLER_REQUIRED_SCOPES, controllerClass) ?? - []) as ScopeMetadata; - scopes[handlerName ?? '*'] = Array.isArray(scope) ? scope : [scope]; - Reflect.defineMetadata(CONTROLLER_REQUIRED_SCOPES, scopes, controllerClass); - }; -}; diff --git a/packages/cli/src/decorators/constants.ts b/packages/cli/src/decorators/constants.ts index ba3d8a314712f..bb7b95638bb05 100644 --- a/packages/cli/src/decorators/constants.ts +++ b/packages/cli/src/decorators/constants.ts @@ -3,4 +3,4 @@ export const CONTROLLER_BASE_PATH = 'CONTROLLER_BASE_PATH'; export const CONTROLLER_MIDDLEWARES = 'CONTROLLER_MIDDLEWARES'; export const CONTROLLER_AUTH_ROLES = 'CONTROLLER_AUTH_ROLES'; export const CONTROLLER_LICENSE_FEATURES = 'CONTROLLER_LICENSE_FEATURES'; -export const CONTROLLER_REQUIRED_SCOPES = 'CONTROLLER_REQUIRED_SCOPES'; +export const CONTROLLER_ROUTE_SCOPES = 'CONTROLLER_ROUTE_SCOPES'; diff --git a/packages/cli/src/decorators/index.ts b/packages/cli/src/decorators/index.ts index 5ec9e3d0105fe..a2563fa7bafd5 100644 --- a/packages/cli/src/decorators/index.ts +++ b/packages/cli/src/decorators/index.ts @@ -4,4 +4,4 @@ export { Get, Post, Put, Patch, Delete } from './Route'; export { Middleware } from './Middleware'; export { registerController } from './registerController'; export { Licensed } from './Licensed'; -export { RequireGlobalScope } from './Scopes'; +export { GlobalScope, ProjectScope } from './Scoped'; diff --git a/packages/cli/src/decorators/registerController.ts b/packages/cli/src/decorators/registerController.ts index f6abd260fa423..dc7d4e94a7e4d 100644 --- a/packages/cli/src/decorators/registerController.ts +++ b/packages/cli/src/decorators/registerController.ts @@ -11,7 +11,7 @@ import { CONTROLLER_BASE_PATH, CONTROLLER_LICENSE_FEATURES, CONTROLLER_MIDDLEWARES, - CONTROLLER_REQUIRED_SCOPES, + CONTROLLER_ROUTE_SCOPES, CONTROLLER_ROUTES, } from './constants'; import type { @@ -21,13 +21,19 @@ import type { LicenseMetadata, MiddlewareMetadata, RouteMetadata, - ScopeMetadata, + RouteScopeMetadata, } from './types'; import type { BooleanLicenseFeature } from '@/Interfaces'; import { License } from '@/License'; -import type { Scope } from '@n8n/permissions'; import { ApplicationError } from 'n8n-workflow'; +import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; +import { RESPONSE_ERROR_MESSAGES } from '@/constants'; +import { RoleService } from '@/services/role.service'; +import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import { In } from '@n8n/typeorm'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; export const createAuthMiddleware = (authRole: AuthRole): RequestHandler => @@ -60,21 +66,85 @@ export const createLicenseMiddleware = return next(); }; -export const createGlobalScopeMiddleware = - (scopes: Scope[]): RequestHandler => - async ({ user }: AuthenticatedRequest, res, next) => { - if (scopes.length === 0) { +export const createScopedMiddleware = + (routeScopeMetadata: RouteScopeMetadata[string]): RequestHandler => + async (req: AuthenticatedRequest<{ credentialId?: string; workflowId?: string }>, res, next) => { + if (!req.user) throw new UnauthenticatedError(); + + const { scopes, globalOnly } = routeScopeMetadata; + + if (scopes.length === 0) return next(); + + // Short circuit here since a global role will always + if (req.user.hasGlobalScope(scopes)) { return next(); } - if (!user) return res.status(401).json({ status: 'error', message: 'Unauthorized' }); + if (globalOnly) { + // The above check already failed so return an auth error + return res.status(403).json({ + status: 'error', + message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE, + }); + } + + const { credentialId, workflowId } = req.params; + + const roleService = Container.get(RoleService); + const projectRoles = roleService.rolesWithScope('project', scopes); + const userProjectIds = ( + await Container.get(ProjectRepository).find({ + where: { + projectRelations: { + userId: req.user.id, + role: In(projectRoles), + }, + }, + select: ['id'], + }) + ).map((p) => p.id); + + if (credentialId) { + const exists = await Container.get(SharedCredentialsRepository).find({ + where: { + projectId: In(userProjectIds), + credentialsId: credentialId, + role: In(roleService.rolesWithScope('credential', scopes)), + }, + }); + + if (!exists.length) { + return res.status(403).json({ + status: 'error', + message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE, + }); + } - const hasScopes = user.hasGlobalScope(scopes); - if (!hasScopes) { - return res.status(403).json({ status: 'error', message: 'Unauthorized' }); + return next(); } - return next(); + if (workflowId) { + const exists = await Container.get(SharedWorkflowRepository).find({ + where: { + projectId: In(userProjectIds), + workflowId, + role: In(roleService.rolesWithScope('workflow', scopes)), + }, + }); + + if (!exists.length) { + return res.status(403).json({ + status: 'error', + message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE, + }); + } + + return next(); + } + + throw new ApplicationError( + "@ProjectScope decorator was used but does not have a credentialId or workflowId in it's URL parameters. This is likely an implementation error. If you're a developer, please check you're URL is correct or that this should be using @GlobalScope.", + ); }; const authFreeRoutes: string[] = []; @@ -99,8 +169,8 @@ export const registerController = (app: Application, controllerClass: Class 0) { @@ -118,14 +188,14 @@ export const registerController = (app: Application, controllerClass: Class { const authRole = authRoles?.[handlerName] ?? authRoles?.['*']; const features = licenseFeatures?.[handlerName] ?? licenseFeatures?.['*']; - const scopes = requiredScopes?.[handlerName] ?? requiredScopes?.['*']; + const scopes = routeScopes?.[handlerName] ?? routeScopes?.['*']; const handler = async (req: Request, res: Response) => await controller[handlerName](req, res); router[method]( path, ...(authRole ? [createAuthMiddleware(authRole)] : []), ...(features ? [createLicenseMiddleware(features)] : []), - ...(scopes ? [createGlobalScopeMiddleware(scopes)] : []), + ...(scopes ? [createScopedMiddleware(scopes)] : []), ...controllerMiddlewares, ...routeMiddlewares, usesTemplates ? handler : send(handler), diff --git a/packages/cli/src/decorators/types.ts b/packages/cli/src/decorators/types.ts index bbaccf39ab6a5..116f4b03d064f 100644 --- a/packages/cli/src/decorators/types.ts +++ b/packages/cli/src/decorators/types.ts @@ -10,7 +10,12 @@ export type AuthRoleMetadata = Record; export type LicenseMetadata = Record; -export type ScopeMetadata = Record; +export type RouteScopeMetadata = { + [handlerName: string]: { + scopes: Scope[]; + globalOnly: boolean; + }; +}; export interface MiddlewareMetadata { handlerName: string; diff --git a/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts b/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts index d589af30d8acc..5fdf0e73c3dae 100644 --- a/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts @@ -1,6 +1,6 @@ import type { PullResult } from 'simple-git'; import express from 'express'; -import { Authorized, Get, Post, Patch, RestController, RequireGlobalScope } from '@/decorators'; +import { Authorized, Get, Post, Patch, RestController, GlobalScope } from '@/decorators'; import { sourceControlLicensedMiddleware, sourceControlLicensedAndEnabledMiddleware, @@ -34,7 +34,7 @@ export class SourceControlController { } @Post('/preferences', { middlewares: [sourceControlLicensedMiddleware] }) - @RequireGlobalScope('sourceControl:manage') + @GlobalScope('sourceControl:manage') async setPreferences(req: SourceControlRequest.UpdatePreferences) { if ( req.body.branchReadOnly === undefined && @@ -98,7 +98,7 @@ export class SourceControlController { } @Patch('/preferences', { middlewares: [sourceControlLicensedMiddleware] }) - @RequireGlobalScope('sourceControl:manage') + @GlobalScope('sourceControl:manage') async updatePreferences(req: SourceControlRequest.UpdatePreferences) { try { const sanitizedPreferences: Partial = { @@ -142,7 +142,7 @@ export class SourceControlController { } @Post('/disconnect', { middlewares: [sourceControlLicensedMiddleware] }) - @RequireGlobalScope('sourceControl:manage') + @GlobalScope('sourceControl:manage') async disconnect(req: SourceControlRequest.Disconnect) { try { return await this.sourceControlService.disconnect(req.body); @@ -162,7 +162,7 @@ export class SourceControlController { } @Post('/push-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] }) - @RequireGlobalScope('sourceControl:push') + @GlobalScope('sourceControl:push') async pushWorkfolder( req: SourceControlRequest.PushWorkFolder, res: express.Response, @@ -184,7 +184,7 @@ export class SourceControlController { } @Post('/pull-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] }) - @RequireGlobalScope('sourceControl:pull') + @GlobalScope('sourceControl:pull') async pullWorkfolder( req: SourceControlRequest.PullWorkFolder, res: express.Response, @@ -203,7 +203,7 @@ export class SourceControlController { } @Get('/reset-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] }) - @RequireGlobalScope('sourceControl:manage') + @GlobalScope('sourceControl:manage') async resetWorkfolder(): Promise { try { return await this.sourceControlService.resetWorkfolder(); @@ -236,7 +236,7 @@ export class SourceControlController { } @Post('/generate-key-pair', { middlewares: [sourceControlLicensedMiddleware] }) - @RequireGlobalScope('sourceControl:manage') + @GlobalScope('sourceControl:manage') async generateKeyPair( req: SourceControlRequest.GenerateKeyPair, ): Promise { diff --git a/packages/cli/src/environments/variables/variables.controller.ee.ts b/packages/cli/src/environments/variables/variables.controller.ee.ts index 5e3eb6452f9be..bde1c214103c6 100644 --- a/packages/cli/src/environments/variables/variables.controller.ee.ts +++ b/packages/cli/src/environments/variables/variables.controller.ee.ts @@ -3,10 +3,10 @@ import { Authorized, Delete, Get, + GlobalScope, Licensed, Patch, Post, - RequireGlobalScope, RestController, } from '@/decorators'; import { VariablesService } from './variables.service.ee'; @@ -21,14 +21,14 @@ export class VariablesController { constructor(private readonly variablesService: VariablesService) {} @Get('/') - @RequireGlobalScope('variable:list') + @GlobalScope('variable:list') async getVariables() { return await this.variablesService.getAllCached(); } @Post('/') @Licensed('feat:variables') - @RequireGlobalScope('variable:create') + @GlobalScope('variable:create') async createVariable(req: VariablesRequest.Create) { const variable = req.body; delete variable.id; @@ -45,7 +45,7 @@ export class VariablesController { } @Get('/:id') - @RequireGlobalScope('variable:read') + @GlobalScope('variable:read') async getVariable(req: VariablesRequest.Get) { const id = req.params.id; const variable = await this.variablesService.getCached(id); @@ -57,7 +57,7 @@ export class VariablesController { @Patch('/:id') @Licensed('feat:variables') - @RequireGlobalScope('variable:update') + @GlobalScope('variable:update') async updateVariable(req: VariablesRequest.Update) { const id = req.params.id; const variable = req.body; @@ -75,7 +75,7 @@ export class VariablesController { } @Delete('/:id(\\w+)') - @RequireGlobalScope('variable:delete') + @GlobalScope('variable:delete') async deleteVariable(req: VariablesRequest.Delete) { const id = req.params.id; await this.variablesService.delete(id); diff --git a/packages/cli/src/errors/response-errors/forbidden.error.ts b/packages/cli/src/errors/response-errors/forbidden.error.ts new file mode 100644 index 0000000000000..4856f7cd4727f --- /dev/null +++ b/packages/cli/src/errors/response-errors/forbidden.error.ts @@ -0,0 +1,7 @@ +import { ResponseError } from './abstract/response.error'; + +export class ForbiddenError extends ResponseError { + constructor(message = 'Forbidden', hint?: string) { + super(message, 403, 403, hint); + } +} diff --git a/packages/cli/src/errors/response-errors/unauthenticated.error.ts b/packages/cli/src/errors/response-errors/unauthenticated.error.ts new file mode 100644 index 0000000000000..7f1409da7facc --- /dev/null +++ b/packages/cli/src/errors/response-errors/unauthenticated.error.ts @@ -0,0 +1,7 @@ +import { ResponseError } from './abstract/response.error'; + +export class UnauthenticatedError extends ResponseError { + constructor(message = 'Unauthenticated', hint?: string) { + super(message, 401, 401, hint); + } +} diff --git a/packages/cli/src/errors/response-errors/unauthorized.error.ts b/packages/cli/src/errors/response-errors/unauthorized.error.ts deleted file mode 100644 index bc8993c014874..0000000000000 --- a/packages/cli/src/errors/response-errors/unauthorized.error.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ResponseError } from './abstract/response.error'; - -export class UnauthorizedError extends ResponseError { - constructor(message: string, hint: string | undefined = undefined) { - super(message, 403, 403, hint); - } -} diff --git a/packages/cli/src/eventbus/eventBus.controller.ee.ts b/packages/cli/src/eventbus/eventBus.controller.ee.ts index 216496092e369..c8b649cbc46e7 100644 --- a/packages/cli/src/eventbus/eventBus.controller.ee.ts +++ b/packages/cli/src/eventbus/eventBus.controller.ee.ts @@ -5,7 +5,7 @@ import type { } from 'n8n-workflow'; import { MessageEventBusDestinationTypeNames } from 'n8n-workflow'; -import { RestController, Get, Post, Delete, Authorized, RequireGlobalScope } from '@/decorators'; +import { RestController, Get, Post, Delete, Authorized, GlobalScope } from '@/decorators'; import { AuthenticatedRequest } from '@/requests'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @@ -62,7 +62,7 @@ export class EventBusControllerEE { // ---------------------------------------- @Get('/destination', { middlewares: [logStreamingLicensedMiddleware] }) - @RequireGlobalScope('eventBusDestination:list') + @GlobalScope('eventBusDestination:list') async getDestination(req: express.Request): Promise { if (isWithIdString(req.query)) { return await this.eventBus.findDestination(req.query.id); @@ -72,7 +72,7 @@ export class EventBusControllerEE { } @Post('/destination', { middlewares: [logStreamingLicensedMiddleware] }) - @RequireGlobalScope('eventBusDestination:create') + @GlobalScope('eventBusDestination:create') async postDestination(req: AuthenticatedRequest): Promise { let result: MessageEventBusDestination | undefined; if (isMessageEventBusDestinationOptions(req.body)) { @@ -116,7 +116,7 @@ export class EventBusControllerEE { } @Get('/testmessage', { middlewares: [logStreamingLicensedMiddleware] }) - @RequireGlobalScope('eventBusDestination:test') + @GlobalScope('eventBusDestination:test') async sendTestMessage(req: express.Request): Promise { if (isWithIdString(req.query)) { return await this.eventBus.testDestination(req.query.id); @@ -125,7 +125,7 @@ export class EventBusControllerEE { } @Delete('/destination', { middlewares: [logStreamingLicensedMiddleware] }) - @RequireGlobalScope('eventBusDestination:delete') + @GlobalScope('eventBusDestination:delete') async deleteDestination(req: AuthenticatedRequest) { if (isWithIdString(req.query)) { return await this.eventBus.removeDestination(req.query.id); diff --git a/packages/cli/src/eventbus/eventBus.controller.ts b/packages/cli/src/eventbus/eventBus.controller.ts index a76c90ddc8628..84d2feb1313ec 100644 --- a/packages/cli/src/eventbus/eventBus.controller.ts +++ b/packages/cli/src/eventbus/eventBus.controller.ts @@ -2,7 +2,7 @@ import express from 'express'; import type { IRunExecutionData } from 'n8n-workflow'; import { EventMessageTypeNames } from 'n8n-workflow'; -import { RestController, Get, Post, Authorized, RequireGlobalScope } from '@/decorators'; +import { RestController, Get, Post, Authorized, GlobalScope } from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { isEventMessageOptions } from './EventMessageClasses/AbstractEventMessage'; @@ -45,7 +45,7 @@ export class EventBusController { // Events // ---------------------------------------- @Get('/event') - @RequireGlobalScope('eventBusEvent:query') + @GlobalScope('eventBusEvent:query') async getEvents( req: express.Request, ): Promise> { @@ -67,14 +67,14 @@ export class EventBusController { } @Get('/failed') - @RequireGlobalScope('eventBusEvent:list') + @GlobalScope('eventBusEvent:list') async getFailedEvents(req: express.Request): Promise { const amount = parseInt(req.query?.amount as string) ?? 5; return await this.eventBus.getEventsFailed(amount); } @Get('/execution/:id') - @RequireGlobalScope('eventBusEvent:read') + @GlobalScope('eventBusEvent:read') async getEventForExecutionId(req: express.Request): Promise { if (req.params?.id) { let logHistory; @@ -87,7 +87,7 @@ export class EventBusController { } @Get('/execution-recover/:id') - @RequireGlobalScope('eventBusEvent:read') + @GlobalScope('eventBusEvent:read') async getRecoveryForExecutionId(req: express.Request): Promise { const { id } = req.params; if (req.params?.id) { @@ -102,7 +102,7 @@ export class EventBusController { } @Post('/event') - @RequireGlobalScope('eventBusEvent:create') + @GlobalScope('eventBusEvent:create') async postEvent(req: express.Request): Promise { let msg: EventMessageTypes | undefined; if (isEventMessageOptions(req.body)) { diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index 96900366289fc..010ce83f48bc1 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -1,4 +1,4 @@ -import { Authorized, Get, Post, RequireGlobalScope, RestController } from '@/decorators'; +import { Authorized, Get, Post, RestController, GlobalScope } from '@/decorators'; import { LicenseRequest } from '@/requests'; import { LicenseService } from './license.service'; @@ -13,7 +13,7 @@ export class LicenseController { } @Post('/activate') - @RequireGlobalScope('license:manage') + @GlobalScope('license:manage') async activateLicense(req: LicenseRequest.Activate) { const { activationKey } = req.body; await this.licenseService.activateLicense(activationKey); @@ -21,7 +21,7 @@ export class LicenseController { } @Post('/renew') - @RequireGlobalScope('license:manage') + @GlobalScope('license:manage') async renewLicense() { await this.licenseService.renewLicense(); return await this.getTokenAndData(); diff --git a/packages/cli/src/permissions/roles.ts b/packages/cli/src/permissions/global-roles.ts similarity index 91% rename from packages/cli/src/permissions/roles.ts rename to packages/cli/src/permissions/global-roles.ts index f8619cb882520..3044086060845 100644 --- a/packages/cli/src/permissions/roles.ts +++ b/packages/cli/src/permissions/global-roles.ts @@ -1,6 +1,6 @@ import type { Scope } from '@n8n/permissions'; -export const ownerPermissions: Scope[] = [ +export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'auditLogs:manage', 'credential:create', 'credential:read', @@ -68,8 +68,10 @@ export const ownerPermissions: Scope[] = [ 'workflow:execute', 'workersView:manage', ]; -export const adminPermissions: Scope[] = ownerPermissions.concat(); -export const memberPermissions: Scope[] = [ + +export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat(); + +export const GLOBAL_MEMBER_SCOPES: Scope[] = [ 'eventBusEvent:list', 'eventBusEvent:read', 'eventBusDestination:list', diff --git a/packages/cli/src/permissions/project-roles.ts b/packages/cli/src/permissions/project-roles.ts new file mode 100644 index 0000000000000..c6ea1de65063c --- /dev/null +++ b/packages/cli/src/permissions/project-roles.ts @@ -0,0 +1,58 @@ +import type { Scope } from '@n8n/permissions'; + +/** + * Diff between admin in personal project and admin in other projects: + * - You cannot rename your personal project. + * - You cannot invite people to your personal project. + */ + +export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [ + 'workflow:create', + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:list', + 'workflow:execute', + 'credential:create', + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:list', +]; + +export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ + 'workflow:create', + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:list', + 'workflow:execute', + 'workflow:share', + 'credential:create', + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:list', + 'credential:share', +]; + +export const PROJECT_EDITOR_SCOPES: Scope[] = [ + 'workflow:create', + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:list', + 'workflow:execute', + 'credential:create', + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:list', +]; + +export const PROJECT_VIEWER_SCOPES: Scope[] = [ + 'workflow:read', + 'workflow:list', + 'credential:read', + 'credential:list', +]; diff --git a/packages/cli/src/permissions/resource-roles.ts b/packages/cli/src/permissions/resource-roles.ts new file mode 100644 index 0000000000000..429242a0c75d5 --- /dev/null +++ b/packages/cli/src/permissions/resource-roles.ts @@ -0,0 +1,24 @@ +import type { Scope } from '@n8n/permissions'; + +export const CREDENTIALS_SHARING_OWNER_SCOPES: Scope[] = [ + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:share', +]; + +export const CREDENTIALS_SHARING_USER_SCOPES: Scope[] = ['credential:read']; + +export const WORKFLOW_SHARING_OWNER_SCOPES: Scope[] = [ + 'workflow:read', + 'workflow:update', + 'workflow:delete', + 'workflow:execute', + 'workflow:share', +]; + +export const WORKFLOW_SHARING_EDITOR_SCOPES: Scope[] = [ + 'workflow:read', + 'workflow:update', + 'workflow:execute', +]; diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index cdcd1bffe1c59..1a282d96bcd56 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -149,19 +149,19 @@ export declare namespace CredentialRequest { type Create = AuthenticatedRequest<{}, {}, CredentialProperties>; - type Get = AuthenticatedRequest<{ id: string }, {}, {}, Record>; + type Get = AuthenticatedRequest<{ credentialId: string }, {}, {}, Record>; type Delete = Get; type GetAll = AuthenticatedRequest<{}, {}, {}, { filter: string }>; - type Update = AuthenticatedRequest<{ id: string }, {}, CredentialProperties>; + type Update = AuthenticatedRequest<{ credentialId: string }, {}, CredentialProperties>; type NewName = AuthenticatedRequest<{}, {}, {}, { name?: string }>; type Test = AuthenticatedRequest<{}, {}, INodeCredentialTestRequest>; - type Share = AuthenticatedRequest<{ id: string }, {}, { shareWithIds: string[] }>; + type Share = AuthenticatedRequest<{ credentialId: string }, {}, { shareWithIds: string[] }>; } // ---------------------------------- diff --git a/packages/cli/src/services/role.service.ts b/packages/cli/src/services/role.service.ts new file mode 100644 index 0000000000000..f7ada7f6bca1b --- /dev/null +++ b/packages/cli/src/services/role.service.ts @@ -0,0 +1,78 @@ +import type { ProjectRole } from '@/databases/entities/ProjectRelation'; +import type { CredentialSharingRole } from '@/databases/entities/SharedCredentials'; +import type { WorkflowSharingRole } from '@/databases/entities/SharedWorkflow'; +import type { GlobalRole } from '@/databases/entities/User'; +import { + GLOBAL_ADMIN_SCOPES, + GLOBAL_MEMBER_SCOPES, + GLOBAL_OWNER_SCOPES, +} from '@/permissions/global-roles'; +import { + PERSONAL_PROJECT_OWNER_SCOPES, + PROJECT_EDITOR_SCOPES, + PROJECT_VIEWER_SCOPES, + REGULAR_PROJECT_ADMIN_SCOPES, +} from '@/permissions/project-roles'; +import { + CREDENTIALS_SHARING_OWNER_SCOPES, + CREDENTIALS_SHARING_USER_SCOPES, + WORKFLOW_SHARING_EDITOR_SCOPES, + WORKFLOW_SHARING_OWNER_SCOPES, +} from '@/permissions/resource-roles'; +import type { Scope } from '@n8n/permissions'; +import { Service } from 'typedi'; + +export type RoleNamespace = 'global' | 'project' | 'credential' | 'workflow'; + +const GLOBAL_SCOPE_MAP: Record = { + 'global:owner': GLOBAL_OWNER_SCOPES, + 'global:admin': GLOBAL_ADMIN_SCOPES, + 'global:member': GLOBAL_MEMBER_SCOPES, +}; + +const PROJECT_SCOPE_MAP: Record = { + 'project:admin': REGULAR_PROJECT_ADMIN_SCOPES, + 'project:personalOwner': PERSONAL_PROJECT_OWNER_SCOPES, + 'project:editor': PROJECT_EDITOR_SCOPES, + 'project:viewer': PROJECT_VIEWER_SCOPES, +}; + +const CREDENTIALS_SHARING_SCOPE_MAP: Record = { + 'credential:owner': CREDENTIALS_SHARING_OWNER_SCOPES, + 'credential:user': CREDENTIALS_SHARING_USER_SCOPES, +}; + +const WORKFLOW_SHARING_SCOPE_MAP: Record = { + 'workflow:owner': WORKFLOW_SHARING_OWNER_SCOPES, + 'workflow:editor': WORKFLOW_SHARING_EDITOR_SCOPES, + + // Not sure why this exists? + 'workflow:user': [], +}; + +const ALL_MAPS = { + global: GLOBAL_SCOPE_MAP, + project: PROJECT_SCOPE_MAP, + credential: CREDENTIALS_SHARING_SCOPE_MAP, + workflow: WORKFLOW_SHARING_SCOPE_MAP, +} as const; + +@Service() +export class RoleService { + rolesWithScope(namespace: 'global', scopes: Scope | Scope[]): GlobalRole[]; + rolesWithScope(namespace: 'project', scopes: Scope | Scope[]): ProjectRole[]; + rolesWithScope(namespace: 'credential', scopes: Scope | Scope[]): CredentialSharingRole[]; + rolesWithScope(namespace: 'workflow', scopes: Scope | Scope[]): WorkflowSharingRole[]; + rolesWithScope(namespace: RoleNamespace, scopes: Scope | Scope[]) { + if (!Array.isArray(scopes)) { + scopes = [scopes]; + } + + return Object.keys(ALL_MAPS[namespace]).filter((k) => { + return (scopes as Scope[]).every((s) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + ((ALL_MAPS[namespace] as any)[k] as Scope[]).includes(s), + ); + }); + } +} diff --git a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts index cfa68bfdd6fd3..15bcd78b76a74 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts @@ -1,12 +1,5 @@ import express from 'express'; -import { - Authorized, - Get, - NoAuthRequired, - Post, - RestController, - RequireGlobalScope, -} from '@/decorators'; +import { Authorized, Get, NoAuthRequired, Post, RestController, GlobalScope } from '@/decorators'; import { SamlUrls } from '../constants'; import { samlLicensedAndEnabledMiddleware, @@ -72,7 +65,7 @@ export class SamlController { * Set SAML config */ @Post(SamlUrls.config, { middlewares: [samlLicensedMiddleware] }) - @RequireGlobalScope('saml:manage') + @GlobalScope('saml:manage') async configPost(req: SamlConfiguration.Update) { const validationResult = await validate(req.body); if (validationResult.length === 0) { @@ -91,7 +84,7 @@ export class SamlController { * Set SAML config */ @Post(SamlUrls.configToggleEnabled, { middlewares: [samlLicensedMiddleware] }) - @RequireGlobalScope('saml:manage') + @GlobalScope('saml:manage') async toggleEnabledPost(req: SamlConfiguration.Toggle, res: express.Response) { if (req.body.loginEnabled === undefined) { throw new BadRequestError('Body should contain a boolean "loginEnabled" property'); @@ -207,7 +200,7 @@ export class SamlController { * This endpoint is available if SAML is licensed and the requestor is an instance owner */ @Get(SamlUrls.configTest, { middlewares: [samlLicensedMiddleware] }) - @RequireGlobalScope('saml:manage') + @GlobalScope('saml:manage') async configTestGet(req: AuthenticatedRequest, res: express.Response) { return await this.handleInitSSO(res, getServiceProviderConfigTestReturnUrl()); } diff --git a/packages/cli/src/workflows/workflow.request.ts b/packages/cli/src/workflows/workflow.request.ts index e576f50f3625c..773f12950260a 100644 --- a/packages/cli/src/workflows/workflow.request.ts +++ b/packages/cli/src/workflows/workflow.request.ts @@ -25,12 +25,12 @@ export declare namespace WorkflowRequest { type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload>; - type Get = AuthenticatedRequest<{ id: string }>; + type Get = AuthenticatedRequest<{ workflowId: string }>; type Delete = Get; type Update = AuthenticatedRequest< - { id: string }, + { workflowId: string }, {}, CreateUpdatePayload, { forceSave?: string } diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 751521cc1b6c9..a2df2fa30cd3f 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -8,7 +8,16 @@ import * as ResponseHelper from '@/ResponseHelper'; import * as WorkflowHelpers from '@/WorkflowHelpers'; import type { IWorkflowResponse } from '@/Interfaces'; import config from '@/config'; -import { Authorized, Delete, Get, Patch, Post, Put, RestController } from '@/decorators'; +import { + Authorized, + Delete, + Get, + Patch, + Post, + ProjectScope, + Put, + RestController, +} from '@/decorators'; import { SharedWorkflow, type WorkflowSharingRole } from '@db/entities/SharedWorkflow'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; @@ -29,7 +38,7 @@ import { Logger } from '@/Logger'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NamingService } from '@/services/naming.service'; import { UserOnboardingService } from '@/services/userOnboarding.service'; import { CredentialsService } from '../credentials/credentials.service'; @@ -217,9 +226,10 @@ export class WorkflowsController { return workflowData; } - @Get('/:id') + @Get('/:workflowId') + @ProjectScope('workflow:read') async getWorkflow(req: WorkflowRequest.Get) { - const { id: workflowId } = req.params; + const { workflowId } = req.params; if (this.license.isSharingEnabled()) { const relations = ['shared', 'shared.user']; @@ -235,7 +245,7 @@ export class WorkflowsController { const userSharing = workflow.shared?.find((shared) => shared.user.id === req.user.id); if (!userSharing && !req.user.hasGlobalScope('workflow:read')) { - throw new UnauthorizedError( + throw new ForbiddenError( 'You do not have permission to access this workflow. Ask the owner to share it with you', ); } @@ -271,9 +281,10 @@ export class WorkflowsController { return shared.workflow; } - @Patch('/:id') + @Patch('/:workflowId') + @ProjectScope('workflow:update') async update(req: WorkflowRequest.Update) { - const { id: workflowId } = req.params; + const { workflowId } = req.params; const forceSave = req.query.forceSave === 'true'; let updateData = new WorkflowEntity(); @@ -301,9 +312,10 @@ export class WorkflowsController { return updatedWorkflow; } - @Delete('/:id') + @Delete('/:workflowId') + @ProjectScope('workflow:delete') async delete(req: WorkflowRequest.Delete) { - const { id: workflowId } = req.params; + const { workflowId } = req.params; const workflow = await this.workflowService.delete(req.user, workflowId); if (!workflow) { @@ -342,6 +354,7 @@ export class WorkflowsController { } @Put('/:workflowId/share') + @ProjectScope('workflow:share') async share(req: WorkflowRequest.Share) { if (!this.license.isSharingEnabled()) throw new NotFoundError('Route not found'); @@ -370,7 +383,7 @@ export class WorkflowsController { workflow = sharedRes?.workflow; } if (!workflow) { - throw new UnauthorizedError('Forbidden'); + throw new ForbiddenError(); } } diff --git a/packages/cli/test/integration/credentials.ee.test.ts b/packages/cli/test/integration/credentials.ee.test.ts index ab09f4c14349e..ffb14164e794b 100644 --- a/packages/cli/test/integration/credentials.ee.test.ts +++ b/packages/cli/test/integration/credentials.ee.test.ts @@ -255,7 +255,7 @@ describe('GET /credentials/:id', () => { lastName: member1.lastName, }); expect(firstCredential.sharedWith).toHaveLength(2); - firstCredential.sharedWith.forEach((sharee: IUser, idx: number) => { + firstCredential.sharedWith.forEach((sharee: IUser) => { expect([member2.id, member3.id]).toContain(sharee.id); }); diff --git a/packages/cli/test/integration/credentials.test.ts b/packages/cli/test/integration/credentials.test.ts index f665d3f7b5fc6..c917008f19ea3 100644 --- a/packages/cli/test/integration/credentials.test.ts +++ b/packages/cli/test/integration/credentials.test.ts @@ -206,7 +206,7 @@ describe('DELETE /credentials/:id', () => { const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); const shellCredential = await Container.get(CredentialsRepository).findOneBy({ id: savedCredential.id, @@ -226,7 +226,7 @@ describe('DELETE /credentials/:id', () => { const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); const shellCredential = await Container.get(CredentialsRepository).findOneBy({ id: savedCredential.id, @@ -291,7 +291,7 @@ describe('PATCH /credentials/:id', () => { .patch(`/credentials/${savedCredential.id}`) .send(patchPayload); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); const credential = await Container.get(CredentialsRepository).findOneByOrFail({ id: savedCredential.id, @@ -354,7 +354,7 @@ describe('PATCH /credentials/:id', () => { .patch(`/credentials/${savedCredential.id}`) .send(patchPayload); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); const shellCredential = await Container.get(CredentialsRepository).findOneByOrFail({ id: savedCredential.id, @@ -372,7 +372,7 @@ describe('PATCH /credentials/:id', () => { .patch(`/credentials/${savedCredential.id}`) .send(patchPayload); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); const shellCredential = await Container.get(CredentialsRepository).findOneByOrFail({ id: savedCredential.id, @@ -390,7 +390,7 @@ describe('PATCH /credentials/:id', () => { .patch(`/credentials/${savedCredential.id}`) .send(patchPayload); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); const shellCredential = await Container.get(CredentialsRepository).findOneByOrFail({ id: savedCredential.id, @@ -417,7 +417,7 @@ describe('PATCH /credentials/:id', () => { test('should fail if cred not found', async () => { const response = await authOwnerAgent.patch('/credentials/123').send(randomCredentialPayload()); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); }); }); @@ -523,7 +523,7 @@ describe('GET /credentials/:id', () => { const response = await authMemberAgent.get(`/credentials/${savedCredential.id}`); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); expect(response.body.data).toBeUndefined(); // owner's cred not returned }); diff --git a/packages/cli/test/integration/license.api.test.ts b/packages/cli/test/integration/license.api.test.ts index 3d1fc4cda84a7..370d36f436b64 100644 --- a/packages/cli/test/integration/license.api.test.ts +++ b/packages/cli/test/integration/license.api.test.ts @@ -6,6 +6,7 @@ import { License } from '@/License'; import * as testDb from './shared/testDb'; import * as utils from './shared/utils/'; import { createUserShell } from './shared/db/users'; +import { RESPONSE_ERROR_MESSAGES } from '@/constants'; const MOCK_SERVER_URL = 'https://server.com/v1'; const MOCK_RENEW_OFFSET = 259200; @@ -57,7 +58,7 @@ describe('POST /license/activate', () => { await authMemberAgent .post('/license/activate') .send({ activationKey: 'abcde' }) - .expect(403, UNAUTHORIZED_RESPONSE); + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('errors out properly', async () => { @@ -79,7 +80,9 @@ describe('POST /license/renew', () => { }); test('does not work for regular users', async () => { - await authMemberAgent.post('/license/renew').expect(403, UNAUTHORIZED_RESPONSE); + await authMemberAgent + .post('/license/renew') + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('errors out properly', async () => { diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index fefb0161b12f7..35f9425d4b084 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -17,6 +17,7 @@ import { randomName } from './shared/random'; import * as utils from './shared/utils/'; import * as testDb from './shared/testDb'; import { mockInstance } from '../shared/mocking'; +import { RESPONSE_ERROR_MESSAGES } from '@/constants'; mockInstance(ExecutionService); @@ -400,66 +401,66 @@ describe('PATCH /users/:id/role', () => { describe('member', () => { test('should fail to demote owner to member', async () => { - const response = await memberAgent.patch(`/users/${owner.id}/role`).send({ - newRoleName: 'global:member', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${owner.id}/role`) + .send({ + newRoleName: 'global:member', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to demote owner to admin', async () => { - const response = await memberAgent.patch(`/users/${owner.id}/role`).send({ - newRoleName: 'global:admin', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${owner.id}/role`) + .send({ + newRoleName: 'global:admin', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to demote admin to member', async () => { - const response = await memberAgent.patch(`/users/${admin.id}/role`).send({ - newRoleName: 'global:member', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${admin.id}/role`) + .send({ + newRoleName: 'global:member', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to promote other member to owner', async () => { - const response = await memberAgent.patch(`/users/${otherMember.id}/role`).send({ - newRoleName: 'global:owner', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${otherMember.id}/role`) + .send({ + newRoleName: 'global:owner', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to promote other member to admin', async () => { - const response = await memberAgent.patch(`/users/${otherMember.id}/role`).send({ - newRoleName: 'global:admin', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${otherMember.id}/role`) + .send({ + newRoleName: 'global:admin', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to promote self to admin', async () => { - const response = await memberAgent.patch(`/users/${member.id}/role`).send({ - newRoleName: 'global:admin', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${member.id}/role`) + .send({ + newRoleName: 'global:admin', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); test('should fail to promote self to owner', async () => { - const response = await memberAgent.patch(`/users/${member.id}/role`).send({ - newRoleName: 'global:owner', - }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(UNAUTHORIZED); + await memberAgent + .patch(`/users/${member.id}/role`) + .send({ + newRoleName: 'global:owner', + }) + .expect(403, { status: 'error', message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE }); }); }); 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 ed3e13d9f063d..ea1910794aa64 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -739,7 +739,7 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () => }, ], }); - expect(response.statusCode).toBe(400); + expect(response.statusCode).toBe(403); }); it('Should succeed but prevent modifying node attributes other than position, name and disabled', async () => { diff --git a/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts b/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts index 4a808bfb35da6..af4f510bc3210 100644 --- a/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts +++ b/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts @@ -6,7 +6,7 @@ import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { mockInstance } from '../../shared/mocking'; -import { memberPermissions, ownerPermissions } from '@/permissions/roles'; +import { GLOBAL_MEMBER_SCOPES, GLOBAL_OWNER_SCOPES } from '@/permissions/global-roles'; import { hasScope } from '@n8n/permissions'; describe('SharedCredentialsRepository', () => { @@ -26,7 +26,7 @@ describe('SharedCredentialsRepository', () => { isOwner: true, hasGlobalScope: (scope) => hasScope(scope, { - global: ownerPermissions, + global: GLOBAL_OWNER_SCOPES, }), }); const member = mock({ @@ -34,7 +34,7 @@ describe('SharedCredentialsRepository', () => { id: 'test', hasGlobalScope: (scope) => hasScope(scope, { - global: memberPermissions, + global: GLOBAL_MEMBER_SCOPES, }), });