diff --git a/packages/@n8n/permissions/src/types.ts b/packages/@n8n/permissions/src/types.ts index 0a14440922d80..7d14f0ad94e74 100644 --- a/packages/@n8n/permissions/src/types.ts +++ b/packages/@n8n/permissions/src/types.ts @@ -6,7 +6,14 @@ export type Resource = | 'credential' | 'variable' | 'sourceControl' - | 'externalSecretsStore'; + | 'externalSecretsProvider' + | 'externalSecret' + | 'eventBusEvent' + | 'eventBusDestination' + | 'orchestration' + | 'communityPackage' + | 'ldap' + | 'saml'; export type ResourceScope< R extends Resource, @@ -17,14 +24,27 @@ export type WildcardScope = `${Resource}:*` | '*'; export type WorkflowScope = ResourceScope<'workflow', DefaultOperations | 'share'>; export type TagScope = ResourceScope<'tag'>; -export type UserScope = ResourceScope<'user'>; +export type UserScope = ResourceScope<'user', DefaultOperations | 'resetPassword'>; export type CredentialScope = ResourceScope<'credential', DefaultOperations | 'share'>; export type VariableScope = ResourceScope<'variable'>; export type SourceControlScope = ResourceScope<'sourceControl', 'pull' | 'push' | 'manage'>; -export type ExternalSecretStoreScope = ResourceScope< - 'externalSecretsStore', - DefaultOperations | 'refresh' +export type ExternalSecretProviderScope = ResourceScope< + 'externalSecretsProvider', + DefaultOperations | 'sync' >; +export type ExternalSecretScope = ResourceScope<'externalSecret', 'list'>; +export type EventBusEventScope = ResourceScope<'eventBusEvent', DefaultOperations | 'query'>; +export type EventBusDestinationScope = ResourceScope< + 'eventBusDestination', + DefaultOperations | 'test' +>; +export type OrchestrationScope = ResourceScope<'orchestration', 'read' | 'list'>; +export type CommunityPackageScope = ResourceScope< + 'communityPackage', + 'install' | 'uninstall' | 'update' | 'list' +>; +export type LdapScope = ResourceScope<'ldap', 'manage' | 'sync'>; +export type SamlScope = ResourceScope<'saml', 'manage'>; export type Scope = | WorkflowScope @@ -33,7 +53,14 @@ export type Scope = | CredentialScope | VariableScope | SourceControlScope - | ExternalSecretStoreScope; + | ExternalSecretProviderScope + | ExternalSecretScope + | EventBusEventScope + | EventBusDestinationScope + | OrchestrationScope + | CommunityPackageScope + | LdapScope + | SamlScope; export type ScopeLevel = 'global' | 'project' | 'resource'; export type GetScopeLevel = Record; diff --git a/packages/cli/src/ExternalSecrets/ExternalSecrets.controller.ee.ts b/packages/cli/src/ExternalSecrets/ExternalSecrets.controller.ee.ts index d46bcc6113091..d6487f9964442 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 } from '@/decorators'; +import { Authorized, Get, Post, RestController, RequireGlobalScope } from '@/decorators'; import { ExternalSecretsRequest } from '@/requests'; import { Response } from 'express'; import { Service } from 'typedi'; @@ -7,17 +7,19 @@ import { ExternalSecretsProviderNotFoundError } from '@/errors/external-secrets- import { NotFoundError } from '@/errors/response-errors/not-found.error'; @Service() -@Authorized(['global', 'owner']) +@Authorized() @RestController('/external-secrets') export class ExternalSecretsController { constructor(private readonly secretsService: ExternalSecretsService) {} @Get('/providers') + @RequireGlobalScope('externalSecretsProvider:list') async getProviders() { return this.secretsService.getProviders(); } @Get('/providers/:provider') + @RequireGlobalScope('externalSecretsProvider:read') async getProvider(req: ExternalSecretsRequest.GetProvider) { const providerName = req.params.provider; try { @@ -31,6 +33,7 @@ export class ExternalSecretsController { } @Post('/providers/:provider/test') + @RequireGlobalScope('externalSecretsProvider:read') async testProviderSettings(req: ExternalSecretsRequest.TestProviderSettings, res: Response) { const providerName = req.params.provider; try { @@ -50,6 +53,7 @@ export class ExternalSecretsController { } @Post('/providers/:provider') + @RequireGlobalScope('externalSecretsProvider:create') async setProviderSettings(req: ExternalSecretsRequest.SetProviderSettings) { const providerName = req.params.provider; try { @@ -64,6 +68,7 @@ export class ExternalSecretsController { } @Post('/providers/:provider/connect') + @RequireGlobalScope('externalSecretsProvider:update') async setProviderConnected(req: ExternalSecretsRequest.SetProviderConnected) { const providerName = req.params.provider; try { @@ -78,6 +83,7 @@ export class ExternalSecretsController { } @Post('/providers/:provider/update') + @RequireGlobalScope('externalSecretsProvider:sync') async updateProvider(req: ExternalSecretsRequest.UpdateProvider, res: Response) { const providerName = req.params.provider; try { @@ -97,6 +103,7 @@ export class ExternalSecretsController { } @Get('/secrets') + @RequireGlobalScope('externalSecret:list') getSecretNames() { return this.secretsService.getAllSecrets(); } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 4d6e0c05d69e0..99611163ce886 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -288,6 +288,7 @@ export class Server extends AbstractServer { Container.get(OrchestrationController), Container.get(WorkflowHistoryController), Container.get(BinaryDataController), + Container.get(VariablesController), new InvitationController( config, logger, diff --git a/packages/cli/src/controllers/communityPackages.controller.ts b/packages/cli/src/controllers/communityPackages.controller.ts index 689ae3bbdbe51..1e7d8310cb86e 100644 --- a/packages/cli/src/controllers/communityPackages.controller.ts +++ b/packages/cli/src/controllers/communityPackages.controller.ts @@ -6,7 +6,16 @@ import { STARTER_TEMPLATE_NAME, UNKNOWN_FAILURE_REASON, } from '@/constants'; -import { Authorized, Delete, Get, Middleware, Patch, Post, RestController } from '@/decorators'; +import { + Authorized, + Delete, + Get, + Middleware, + Patch, + Post, + RestController, + RequireGlobalScope, +} from '@/decorators'; import { NodeRequest } from '@/requests'; import type { InstalledPackages } from '@db/entities/InstalledPackages'; import type { CommunityPackages } from '@/Interfaces'; @@ -34,7 +43,7 @@ export function isNpmError(error: unknown): error is { code: number; stdout: str } @Service() -@Authorized(['global', 'owner']) +@Authorized() @RestController('/community-packages') export class CommunityPackagesController { constructor( @@ -55,6 +64,7 @@ export class CommunityPackagesController { } @Post('/') + @RequireGlobalScope('communityPackage:install') async installPackage(req: NodeRequest.Post) { const { name } = req.body; @@ -151,6 +161,7 @@ export class CommunityPackagesController { } @Get('/') + @RequireGlobalScope('communityPackage:list') async getInstalledPackages() { const installedPackages = await this.communityPackagesService.getAllInstalledPackages(); @@ -185,6 +196,7 @@ export class CommunityPackagesController { } @Delete('/') + @RequireGlobalScope('communityPackage:uninstall') async uninstallPackage(req: NodeRequest.Delete) { const { name } = req.query; @@ -236,6 +248,7 @@ export class CommunityPackagesController { } @Patch('/') + @RequireGlobalScope('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 5dc0c23829dfa..c68d3a544e78a 100644 --- a/packages/cli/src/controllers/invitation.controller.ts +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -1,6 +1,6 @@ import { In } from 'typeorm'; import Container, { Service } from 'typedi'; -import { Authorized, NoAuthRequired, Post, RestController } from '@/decorators'; +import { Authorized, NoAuthRequired, Post, RequireGlobalScope, RestController } from '@/decorators'; import { issueCookie } from '@/auth/jwt'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import { Response } from 'express'; @@ -19,6 +19,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; @Service() +@Authorized() @RestController('/invitations') export class InvitationController { constructor( @@ -34,8 +35,8 @@ export class InvitationController { * Send email invite(s) to one or multiple users and create user shell(s). */ - @Authorized(['global', 'owner']) @Post('/') + @RequireGlobalScope('user:create') async inviteUser(req: UserRequest.Invite) { const isWithinUsersLimit = Container.get(License).isWithinUsersLimit(); diff --git a/packages/cli/src/controllers/ldap.controller.ts b/packages/cli/src/controllers/ldap.controller.ts index 827d84648708c..6de886e1e976e 100644 --- a/packages/cli/src/controllers/ldap.controller.ts +++ b/packages/cli/src/controllers/ldap.controller.ts @@ -1,5 +1,5 @@ import pick from 'lodash/pick'; -import { Authorized, Get, Post, Put, RestController } from '@/decorators'; +import { Authorized, Get, Post, Put, RestController, RequireGlobalScope } from '@/decorators'; import { getLdapConfig, getLdapSynchronizations, updateLdapConfig } from '@/Ldap/helpers'; import { LdapService } from '@/Ldap/LdapService.ee'; import { LdapSync } from '@/Ldap/LdapSync.ee'; @@ -8,7 +8,7 @@ import { NON_SENSIBLE_LDAP_CONFIG_PROPERTIES } from '@/Ldap/constants'; import { InternalHooks } from '@/InternalHooks'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -@Authorized(['global', 'owner']) +@Authorized() @RestController('/ldap') export class LdapController { constructor( @@ -18,11 +18,13 @@ export class LdapController { ) {} @Get('/config') + @RequireGlobalScope('ldap:manage') async getConfig() { return getLdapConfig(); } @Post('/test-connection') + @RequireGlobalScope('ldap:manage') async testConnection() { try { await this.ldapService.testConnection(); @@ -32,6 +34,7 @@ export class LdapController { } @Put('/config') + @RequireGlobalScope('ldap:manage') async updateConfig(req: LdapConfiguration.Update) { try { await updateLdapConfig(req.body); @@ -50,12 +53,14 @@ export class LdapController { } @Get('/sync') + @RequireGlobalScope('ldap:sync') async getLdapSync(req: LdapConfiguration.GetSync) { const { page = '0', perPage = '20' } = req.query; return getLdapSynchronizations(parseInt(page, 10), parseInt(perPage, 10)); } @Post('/sync') + @RequireGlobalScope('ldap:sync') async syncLdap(req: LdapConfiguration.Sync) { try { await this.ldapSync.run(req.body.type); diff --git a/packages/cli/src/controllers/orchestration.controller.ts b/packages/cli/src/controllers/orchestration.controller.ts index 962db68208af2..fc73db8ddcecd 100644 --- a/packages/cli/src/controllers/orchestration.controller.ts +++ b/packages/cli/src/controllers/orchestration.controller.ts @@ -1,10 +1,10 @@ -import { Authorized, Post, RestController } from '@/decorators'; +import { Authorized, Post, RestController, RequireGlobalScope } from '@/decorators'; import { OrchestrationRequest } from '@/requests'; import { Service } from 'typedi'; import { SingleMainSetup } from '@/services/orchestration/main/SingleMainSetup'; import { License } from '../License'; -@Authorized('any') +@Authorized() @RestController('/orchestration') @Service() export class OrchestrationController { @@ -17,6 +17,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') @Post('/worker/status/:id') async getWorkersStatus(req: OrchestrationRequest.Get) { if (!this.licenseService.isWorkerViewLicensed()) return; @@ -24,12 +25,14 @@ export class OrchestrationController { return this.singleMainSetup.getWorkerStatus(id); } + @RequireGlobalScope('orchestration:read') @Post('/worker/status') async getWorkersStatusAll() { if (!this.licenseService.isWorkerViewLicensed()) return; return this.singleMainSetup.getWorkerStatus(); } + @RequireGlobalScope('orchestration:list') @Post('/worker/ids') async getWorkerIdsAll() { if (!this.licenseService.isWorkerViewLicensed()) return; diff --git a/packages/cli/src/controllers/tags.controller.ts b/packages/cli/src/controllers/tags.controller.ts index 01e40c842ca9a..2e8abc0dfb2f7 100644 --- a/packages/cli/src/controllers/tags.controller.ts +++ b/packages/cli/src/controllers/tags.controller.ts @@ -1,6 +1,15 @@ import { Request, Response, NextFunction } from 'express'; import config from '@/config'; -import { Authorized, Delete, Get, Middleware, Patch, Post, RestController } from '@/decorators'; +import { + Authorized, + Delete, + Get, + Middleware, + Patch, + Post, + RestController, + RequireGlobalScope, +} from '@/decorators'; import { TagService } from '@/services/tag.service'; import { TagsRequest } from '@/requests'; import { Service } from 'typedi'; @@ -23,11 +32,13 @@ export class TagsController { } @Get('/') + @RequireGlobalScope('tag:list') async getAll(req: TagsRequest.GetAll) { return this.tagService.getAll({ withUsageCount: req.query.withUsageCount === 'true' }); } @Post('/') + @RequireGlobalScope('tag:create') async createTag(req: TagsRequest.Create) { const tag = this.tagService.toEntity({ name: req.body.name }); @@ -35,14 +46,15 @@ export class TagsController { } @Patch('/:id(\\w+)') + @RequireGlobalScope('tag:update') async updateTag(req: TagsRequest.Update) { const newTag = this.tagService.toEntity({ id: req.params.id, name: req.body.name.trim() }); return this.tagService.save(newTag, 'update'); } - @Authorized(['global', 'owner']) @Delete('/:id(\\w+)') + @RequireGlobalScope('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 3aab71ce75aa7..8aee90a46f23e 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -3,7 +3,7 @@ import { In, Not } from 'typeorm'; import { User } from '@db/entities/User'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import { SharedWorkflow } from '@db/entities/SharedWorkflow'; -import { Authorized, Delete, Get, RestController, Patch } from '@/decorators'; +import { RequireGlobalScope, Authorized, Delete, Get, RestController, Patch } from '@/decorators'; import { ListQuery, UserRequest, UserSettingsUpdatePayload } from '@/requests'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces'; @@ -114,8 +114,8 @@ export class UsersController { return publicUsers; } - @Authorized('any') @Get('/', { middlewares: listQueryMiddleware }) + @RequireGlobalScope('user:list') async listUsers(req: ListQuery.Request) { const { listQueryOptions } = req; @@ -132,8 +132,8 @@ export class UsersController { : publicUsers; } - @Authorized(['global', 'owner']) @Get('/:id/password-reset-link') + @RequireGlobalScope('user:resetPassword') async getUserPasswordResetLink(req: UserRequest.PasswordResetLink) { const user = await this.userService.findOneOrFail({ where: { id: req.params.id }, @@ -146,8 +146,8 @@ export class UsersController { return { link }; } - @Authorized(['global', 'owner']) @Patch('/:id/settings') + @RequireGlobalScope('user:update') async updateUserSettings(req: UserRequest.UserSettingsUpdate) { const payload = plainToInstance(UserSettingsUpdatePayload, req.body); @@ -168,6 +168,7 @@ export class UsersController { */ @Authorized(['global', 'owner']) @Delete('/:id') + @RequireGlobalScope('user:delete') async deleteUser(req: UserRequest.Delete) { const { id: idToDelete } = req.params; diff --git a/packages/cli/src/decorators/Licensed.ts b/packages/cli/src/decorators/Licensed.ts index e19a24f0f0f62..b411e8fa1601d 100644 --- a/packages/cli/src/decorators/Licensed.ts +++ b/packages/cli/src/decorators/Licensed.ts @@ -2,7 +2,6 @@ import type { BooleanLicenseFeature } from '@/Interfaces'; import type { LicenseMetadata } from './types'; import { CONTROLLER_LICENSE_FEATURES } from './constants'; -// eslint-disable-next-line @typescript-eslint/naming-convention export const Licensed = (features: BooleanLicenseFeature | BooleanLicenseFeature[]) => { // eslint-disable-next-line @typescript-eslint/ban-types return (target: Function | object, handlerName?: string) => { diff --git a/packages/cli/src/decorators/Scopes.ts b/packages/cli/src/decorators/Scopes.ts new file mode 100644 index 0000000000000..9e4bdca22a99b --- /dev/null +++ b/packages/cli/src/decorators/Scopes.ts @@ -0,0 +1,14 @@ +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 44f158ca5a987..ba3d8a314712f 100644 --- a/packages/cli/src/decorators/constants.ts +++ b/packages/cli/src/decorators/constants.ts @@ -3,3 +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'; diff --git a/packages/cli/src/decorators/index.ts b/packages/cli/src/decorators/index.ts index 5ce2038f4d337..5ec9e3d0105fe 100644 --- a/packages/cli/src/decorators/index.ts +++ b/packages/cli/src/decorators/index.ts @@ -4,3 +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'; diff --git a/packages/cli/src/decorators/registerController.ts b/packages/cli/src/decorators/registerController.ts index 0f814f214d39f..f4a8bc0e794b7 100644 --- a/packages/cli/src/decorators/registerController.ts +++ b/packages/cli/src/decorators/registerController.ts @@ -8,6 +8,7 @@ import { CONTROLLER_BASE_PATH, CONTROLLER_LICENSE_FEATURES, CONTROLLER_MIDDLEWARES, + CONTROLLER_REQUIRED_SCOPES, CONTROLLER_ROUTES, } from './constants'; import type { @@ -17,10 +18,12 @@ import type { LicenseMetadata, MiddlewareMetadata, RouteMetadata, + ScopeMetadata, } from './types'; import type { BooleanLicenseFeature } from '@/Interfaces'; import Container from 'typedi'; import { License } from '@/License'; +import type { Scope } from '@n8n/permissions'; export const createAuthMiddleware = (authRole: AuthRole): RequestHandler => @@ -55,6 +58,23 @@ export const createLicenseMiddleware = return next(); }; +export const createGlobalScopeMiddleware = + (scopes: Scope[]): RequestHandler => + async ({ user }: AuthenticatedRequest, res, next) => { + if (scopes.length === 0) { + return next(); + } + + if (!user) return res.status(401).json({ status: 'error', message: 'Unauthorized' }); + + const hasScopes = await user.hasGlobalScope(scopes); + if (!hasScopes) { + return res.status(403).json({ status: 'error', message: 'Unauthorized' }); + } + + return next(); + }; + const authFreeRoutes: string[] = []; export const canSkipAuth = (method: string, path: string): boolean => @@ -76,6 +96,10 @@ export const registerController = (app: Application, config: Config, cObj: objec const licenseFeatures = Reflect.getMetadata(CONTROLLER_LICENSE_FEATURES, controllerClass) as | LicenseMetadata | undefined; + const requiredScopes = Reflect.getMetadata(CONTROLLER_REQUIRED_SCOPES, controllerClass) as + | ScopeMetadata + | undefined; + if (routes.length > 0) { const router = Router({ mergeParams: true }); const restBasePath = config.getEnv('endpoints.rest'); @@ -89,13 +113,15 @@ export const registerController = (app: Application, config: Config, cObj: objec routes.forEach( ({ method, path, middlewares: routeMiddlewares, handlerName, usesTemplates }) => { - const authRole = authRoles && (authRoles[handlerName] ?? authRoles['*']); - const features = licenseFeatures && (licenseFeatures[handlerName] ?? licenseFeatures['*']); + const authRole = authRoles?.[handlerName] ?? authRoles?.['*']; + const features = licenseFeatures?.[handlerName] ?? licenseFeatures?.['*']; + const scopes = requiredScopes?.[handlerName] ?? requiredScopes?.['*']; const handler = async (req: Request, res: Response) => controller[handlerName](req, res); router[method]( path, ...(authRole ? [createAuthMiddleware(authRole)] : []), ...(features ? [createLicenseMiddleware(features)] : []), + ...(scopes ? [createGlobalScopeMiddleware(scopes)] : []), ...controllerMiddlewares, ...routeMiddlewares, usesTemplates ? handler : send(handler), diff --git a/packages/cli/src/decorators/types.ts b/packages/cli/src/decorators/types.ts index 2d57cc1fd66aa..919e748686986 100644 --- a/packages/cli/src/decorators/types.ts +++ b/packages/cli/src/decorators/types.ts @@ -1,6 +1,7 @@ import type { Request, Response, RequestHandler } from 'express'; import type { RoleNames, RoleScopes } from '@db/entities/Role'; import type { BooleanLicenseFeature } from '@/Interfaces'; +import type { Scope } from '@n8n/permissions'; export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete'; @@ -9,6 +10,8 @@ export type AuthRoleMetadata = Record; export type LicenseMetadata = Record; +export type ScopeMetadata = Record; + 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 1a808c3b30fdc..e5291c905922e 100644 --- a/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts @@ -1,7 +1,7 @@ import { Container, Service } from 'typedi'; import type { PullResult } from 'simple-git'; import express from 'express'; -import { Authorized, Get, Post, Patch, RestController } from '@/decorators'; +import { Authorized, Get, Post, Patch, RestController, RequireGlobalScope } from '@/decorators'; import { sourceControlLicensedMiddleware, sourceControlLicensedAndEnabledMiddleware, @@ -19,6 +19,7 @@ import { SourceControlGetStatus } from './types/sourceControlGetStatus'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @Service() +@Authorized() @RestController(`/${SOURCE_CONTROL_API_ROOT}`) export class SourceControlController { constructor( @@ -33,8 +34,8 @@ export class SourceControlController { return this.sourceControlPreferencesService.getPreferences(); } - @Authorized(['global', 'owner']) @Post('/preferences', { middlewares: [sourceControlLicensedMiddleware] }) + @RequireGlobalScope('sourceControl:manage') async setPreferences(req: SourceControlRequest.UpdatePreferences) { if ( req.body.branchReadOnly === undefined && @@ -97,8 +98,8 @@ export class SourceControlController { } } - @Authorized(['global', 'owner']) @Patch('/preferences', { middlewares: [sourceControlLicensedMiddleware] }) + @RequireGlobalScope('sourceControl:manage') async updatePreferences(req: SourceControlRequest.UpdatePreferences) { try { const sanitizedPreferences: Partial = { @@ -141,8 +142,8 @@ export class SourceControlController { } } - @Authorized(['global', 'owner']) @Post('/disconnect', { middlewares: [sourceControlLicensedMiddleware] }) + @RequireGlobalScope('sourceControl:manage') async disconnect(req: SourceControlRequest.Disconnect) { try { return await this.sourceControlService.disconnect(req.body); @@ -161,8 +162,8 @@ export class SourceControlController { } } - @Authorized(['global', 'owner']) @Post('/push-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] }) + @RequireGlobalScope('sourceControl:push') async pushWorkfolder( req: SourceControlRequest.PushWorkFolder, res: express.Response, @@ -183,8 +184,8 @@ export class SourceControlController { } } - @Authorized(['global', 'owner']) @Post('/pull-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] }) + @RequireGlobalScope('sourceControl:pull') async pullWorkfolder( req: SourceControlRequest.PullWorkFolder, res: express.Response, @@ -202,8 +203,8 @@ export class SourceControlController { } } - @Authorized(['global', 'owner']) @Get('/reset-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] }) + @RequireGlobalScope('sourceControl:manage') async resetWorkfolder(): Promise { try { return await this.sourceControlService.resetWorkfolder(); @@ -235,8 +236,8 @@ export class SourceControlController { } } - @Authorized(['global', 'owner']) @Post('/generate-key-pair', { middlewares: [sourceControlLicensedMiddleware] }) + @RequireGlobalScope('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 42e1b8153c3f2..125c56bf892f5 100644 --- a/packages/cli/src/environments/variables/variables.controller.ee.ts +++ b/packages/cli/src/environments/variables/variables.controller.ee.ts @@ -1,10 +1,17 @@ -import { Container, Service } from 'typedi'; +import { Service } from 'typedi'; import { VariablesRequest } from '@/requests'; -import { Authorized, Delete, Get, Licensed, Patch, Post, RestController } from '@/decorators'; +import { + Authorized, + Delete, + Get, + Licensed, + Patch, + Post, + RequireGlobalScope, + RestController, +} from '@/decorators'; import { VariablesService } from './variables.service.ee'; -import { Logger } from '@/Logger'; -import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { VariableValidationError } from '@/errors/variable-validation.error'; @@ -14,29 +21,22 @@ import { VariableCountLimitReachedError } from '@/errors/variable-count-limit-re @Authorized() @RestController('/variables') export class VariablesController { - constructor( - private variablesService: VariablesService, - private logger: Logger, - ) {} + constructor(private variablesService: VariablesService) {} @Get('/') + @RequireGlobalScope('variable:list') async getVariables() { - return Container.get(VariablesService).getAllCached(); + return this.variablesService.getAllCached(); } @Post('/') @Licensed('feat:variables') + @RequireGlobalScope('variable:create') async createVariable(req: VariablesRequest.Create) { - if (req.user.globalRole.name !== 'owner') { - this.logger.info('Attempt to update a variable blocked due to lack of permissions', { - userId: req.user.id, - }); - throw new UnauthorizedError('Unauthorized'); - } const variable = req.body; delete variable.id; try { - return await Container.get(VariablesService).create(variable); + return await this.variablesService.create(variable); } catch (error) { if (error instanceof VariableCountLimitReachedError) { throw new BadRequestError(error.message); @@ -48,9 +48,10 @@ export class VariablesController { } @Get('/:id') + @RequireGlobalScope('variable:read') async getVariable(req: VariablesRequest.Get) { const id = req.params.id; - const variable = await Container.get(VariablesService).getCached(id); + const variable = await this.variablesService.getCached(id); if (variable === null) { throw new NotFoundError(`Variable with id ${req.params.id} not found`); } @@ -59,19 +60,13 @@ export class VariablesController { @Patch('/:id') @Licensed('feat:variables') + @RequireGlobalScope('variable:update') async updateVariable(req: VariablesRequest.Update) { const id = req.params.id; - if (req.user.globalRole.name !== 'owner') { - this.logger.info('Attempt to update a variable blocked due to lack of permissions', { - id, - userId: req.user.id, - }); - throw new UnauthorizedError('Unauthorized'); - } const variable = req.body; delete variable.id; try { - return await Container.get(VariablesService).update(id, variable); + return await this.variablesService.update(id, variable); } catch (error) { if (error instanceof VariableCountLimitReachedError) { throw new BadRequestError(error.message); @@ -82,16 +77,10 @@ export class VariablesController { } } - @Delete('/:id') + @Delete('/:id(\\w+)') + @RequireGlobalScope('variable:delete') async deleteVariable(req: VariablesRequest.Delete) { const id = req.params.id; - if (req.user.globalRole.name !== 'owner') { - this.logger.info('Attempt to delete a variable blocked due to lack of permissions', { - id, - userId: req.user.id, - }); - throw new UnauthorizedError('Unauthorized'); - } await this.variablesService.delete(id); return true; diff --git a/packages/cli/src/eventbus/eventBus.controller.ee.ts b/packages/cli/src/eventbus/eventBus.controller.ee.ts index a7d16e1b6519e..f60a70d534251 100644 --- a/packages/cli/src/eventbus/eventBus.controller.ee.ts +++ b/packages/cli/src/eventbus/eventBus.controller.ee.ts @@ -14,7 +14,7 @@ import type { MessageEventBusDestinationOptions, } from 'n8n-workflow'; import { MessageEventBusDestinationTypeNames } from 'n8n-workflow'; -import { RestController, Get, Post, Delete, Authorized } from '@/decorators'; +import { RestController, Get, Post, Delete, Authorized, RequireGlobalScope } from '@/decorators'; import type { MessageEventBusDestination } from './MessageEventBusDestination/MessageEventBusDestination.ee'; import type { DeleteResult } from 'typeorm'; import { AuthenticatedRequest } from '@/requests'; @@ -59,6 +59,7 @@ export class EventBusControllerEE { // ---------------------------------------- @Get('/destination', { middlewares: [logStreamingLicensedMiddleware] }) + @RequireGlobalScope('eventBusDestination:list') async getDestination(req: express.Request): Promise { if (isWithIdString(req.query)) { return eventBus.findDestination(req.query.id); @@ -67,8 +68,8 @@ export class EventBusControllerEE { } } - @Authorized(['global', 'owner']) @Post('/destination', { middlewares: [logStreamingLicensedMiddleware] }) + @RequireGlobalScope('eventBusDestination:create') async postDestination(req: AuthenticatedRequest): Promise { let result: MessageEventBusDestination | undefined; if (isMessageEventBusDestinationOptions(req.body)) { @@ -112,6 +113,7 @@ export class EventBusControllerEE { } @Get('/testmessage', { middlewares: [logStreamingLicensedMiddleware] }) + @RequireGlobalScope('eventBusDestination:test') async sendTestMessage(req: express.Request): Promise { if (isWithIdString(req.query)) { return eventBus.testDestination(req.query.id); @@ -119,8 +121,8 @@ export class EventBusControllerEE { return false; } - @Authorized(['global', 'owner']) @Delete('/destination', { middlewares: [logStreamingLicensedMiddleware] }) + @RequireGlobalScope('eventBusDestination:delete') async deleteDestination(req: AuthenticatedRequest): Promise { if (isWithIdString(req.query)) { return eventBus.removeDestination(req.query.id); diff --git a/packages/cli/src/eventbus/eventBus.controller.ts b/packages/cli/src/eventbus/eventBus.controller.ts index 6fecc8bf69535..6e46c75d551ab 100644 --- a/packages/cli/src/eventbus/eventBus.controller.ts +++ b/packages/cli/src/eventbus/eventBus.controller.ts @@ -14,7 +14,7 @@ import { EventMessageTypeNames } from 'n8n-workflow'; import type { EventMessageNodeOptions } from './EventMessageClasses/EventMessageNode'; import { EventMessageNode } from './EventMessageClasses/EventMessageNode'; import { recoverExecutionDataFromEventLogMessages } from './MessageEventBus/recoverEvents'; -import { RestController, Get, Post, Authorized } from '@/decorators'; +import { RestController, Get, Post, Authorized, RequireGlobalScope } from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; // ---------------------------------------- @@ -37,8 +37,8 @@ export class EventBusController { // ---------------------------------------- // Events // ---------------------------------------- - @Authorized(['global', 'owner']) @Get('/event') + @RequireGlobalScope('eventBusEvent:query') async getEvents( req: express.Request, ): Promise> { @@ -60,12 +60,14 @@ export class EventBusController { } @Get('/failed') + @RequireGlobalScope('eventBusEvent:list') async getFailedEvents(req: express.Request): Promise { const amount = parseInt(req.query?.amount as string) ?? 5; return eventBus.getEventsFailed(amount); } @Get('/execution/:id') + @RequireGlobalScope('eventBusEvent:read') async getEventForExecutionId(req: express.Request): Promise { if (req.params?.id) { let logHistory; @@ -78,6 +80,7 @@ export class EventBusController { } @Get('/execution-recover/:id') + @RequireGlobalScope('eventBusEvent:read') async getRecoveryForExecutionId(req: express.Request): Promise { const { id } = req.params; if (req.params?.id) { @@ -91,8 +94,8 @@ export class EventBusController { return; } - @Authorized(['global', 'owner']) @Post('/event') + @RequireGlobalScope('eventBusEvent:create') async postEvent(req: express.Request): Promise { let msg: EventMessageTypes | undefined; if (isEventMessageOptions(req.body)) { diff --git a/packages/cli/src/permissions/roles.ts b/packages/cli/src/permissions/roles.ts index 131406aee854d..4d80481848d62 100644 --- a/packages/cli/src/permissions/roles.ts +++ b/packages/cli/src/permissions/roles.ts @@ -7,11 +7,17 @@ export const ownerPermissions: Scope[] = [ 'workflow:delete', 'workflow:list', 'workflow:share', + 'tag:create', + 'tag:read', + 'tag:update', + 'tag:delete', + 'tag:list', 'user:create', 'user:read', 'user:update', 'user:delete', 'user:list', + 'user:resetPassword', 'credential:create', 'credential:read', 'credential:update', @@ -26,17 +32,35 @@ export const ownerPermissions: Scope[] = [ 'sourceControl:pull', 'sourceControl:push', 'sourceControl:manage', - 'externalSecretsStore:create', - 'externalSecretsStore:read', - 'externalSecretsStore:update', - 'externalSecretsStore:delete', - 'externalSecretsStore:list', - 'externalSecretsStore:refresh', - 'tag:create', - 'tag:read', - 'tag:update', - 'tag:delete', - 'tag:list', + 'externalSecretsProvider:create', + 'externalSecretsProvider:read', + 'externalSecretsProvider:update', + 'externalSecretsProvider:delete', + 'externalSecretsProvider:list', + 'externalSecretsProvider:sync', + 'externalSecret:list', + 'orchestration:read', + 'orchestration:list', + 'communityPackage:install', + 'communityPackage:uninstall', + 'communityPackage:update', + 'communityPackage:list', + 'ldap:manage', + 'ldap:sync', + 'saml:manage', + 'eventBusEvent:create', + 'eventBusEvent:read', + 'eventBusEvent:update', + 'eventBusEvent:delete', + 'eventBusEvent:list', + 'eventBusEvent:query', + 'eventBusEvent:create', + 'eventBusDestination:create', + 'eventBusDestination:read', + 'eventBusDestination:update', + 'eventBusDestination:delete', + 'eventBusDestination:list', + 'eventBusDestination:test', ]; export const adminPermissions: Scope[] = ownerPermissions.concat(); export const memberPermissions: Scope[] = [ @@ -47,4 +71,8 @@ export const memberPermissions: Scope[] = [ 'tag:read', 'tag:update', 'tag:list', + 'eventBusEvent:list', + 'eventBusEvent:read', + 'eventBusDestination:list', + 'eventBusDestination:test', ]; 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 aa5f98dd67af2..85958e83a6da8 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts @@ -1,7 +1,14 @@ import express from 'express'; import { Container, Service } from 'typedi'; import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; -import { Authorized, Get, NoAuthRequired, Post, RestController } from '@/decorators'; +import { + Authorized, + Get, + NoAuthRequired, + Post, + RestController, + RequireGlobalScope, +} from '@/decorators'; import { SamlUrls } from '../constants'; import { samlLicensedAndEnabledMiddleware, @@ -30,6 +37,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { AuthError } from '@/errors/response-errors/auth.error'; @Service() +@Authorized() @RestController('/sso/saml') export class SamlController { constructor(private samlService: SamlService) {} @@ -61,8 +69,8 @@ export class SamlController { * POST /sso/saml/config * Set SAML config */ - @Authorized(['global', 'owner']) @Post(SamlUrls.config, { middlewares: [samlLicensedMiddleware] }) + @RequireGlobalScope('saml:manage') async configPost(req: SamlConfiguration.Update) { const validationResult = await validate(req.body); if (validationResult.length === 0) { @@ -80,8 +88,8 @@ export class SamlController { * POST /sso/saml/config/toggle * Set SAML config */ - @Authorized(['global', 'owner']) @Post(SamlUrls.configToggleEnabled, { middlewares: [samlLicensedMiddleware] }) + @RequireGlobalScope('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'); @@ -196,8 +204,8 @@ export class SamlController { * Test SAML config * This endpoint is available if SAML is licensed and the requestor is an instance owner */ - @Authorized(['global', 'owner']) @Get(SamlUrls.configTest, { middlewares: [samlLicensedMiddleware] }) + @RequireGlobalScope('saml:manage') async configTestGet(req: AuthenticatedRequest, res: express.Response) { return this.handleInitSSO(res, getServiceProviderConfigTestReturnUrl()); } diff --git a/packages/cli/src/workflows/workflows.controller.ee.ts b/packages/cli/src/workflows/workflows.controller.ee.ts index 6fd345060fda6..ffec9c12df2cf 100644 --- a/packages/cli/src/workflows/workflows.controller.ee.ts +++ b/packages/cli/src/workflows/workflows.controller.ee.ts @@ -109,8 +109,7 @@ EEWorkflowController.get( } const userSharing = workflow.shared?.find((shared) => shared.user.id === req.user.id); - - if (!userSharing && req.user.globalRole.name !== 'owner') { + if (!userSharing && !(await req.user.hasGlobalScope('workflow:read'))) { throw new UnauthorizedError( 'You do not have permission to access this workflow. Ask the owner to share it with you', );