feat: Add initial scope checks via decorators (#7737)

This commit is contained in:
Val 2023-11-28 11:41:34 +00:00 committed by GitHub
parent 753cbc1e96
commit a37f1cb0ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 233 additions and 89 deletions

View file

@ -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<T extends ScopeLevel> = Record<T, Scope[]>;

View file

@ -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();
}

View file

@ -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,

View file

@ -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;

View file

@ -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();

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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) => {

View file

@ -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);
};
};

View file

@ -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';

View file

@ -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';

View file

@ -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),

View file

@ -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<string, AuthRole>;
export type LicenseMetadata = Record<string, BooleanLicenseFeature[]>;
export type ScopeMetadata = Record<string, Scope[]>;
export interface MiddlewareMetadata {
handlerName: string;
}

View file

@ -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<SourceControlPreferences> = {
@ -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<ImportResult | undefined> {
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<SourceControlPreferences> {

View file

@ -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;

View file

@ -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<MessageEventBusDestinationOptions[]> {
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<any> {
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<boolean> {
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<DeleteResult | undefined> {
if (isWithIdString(req.query)) {
return eventBus.removeDestination(req.query.id);

View file

@ -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<EventMessageTypes[] | Record<string, EventMessageTypes[]>> {
@ -60,12 +60,14 @@ export class EventBusController {
}
@Get('/failed')
@RequireGlobalScope('eventBusEvent:list')
async getFailedEvents(req: express.Request): Promise<FailedEventSummary[]> {
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<EventMessageTypes[] | undefined> {
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<IRunExecutionData | undefined> {
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<EventMessageTypes | undefined> {
let msg: EventMessageTypes | undefined;
if (isEventMessageOptions(req.body)) {

View file

@ -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',
];

View file

@ -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());
}

View file

@ -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',
);