mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
feat: Add initial scope checks via decorators (#7737)
This commit is contained in:
parent
753cbc1e96
commit
a37f1cb0ba
|
@ -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[]>;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
|
|
14
packages/cli/src/decorators/Scopes.ts
Normal file
14
packages/cli/src/decorators/Scopes.ts
Normal 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);
|
||||
};
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue