diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 93ab6141b95f7..e99b7be07255c 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -1,8 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ -/* eslint-disable @typescript-eslint/no-shadow */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { Container, Service } from 'typedi'; import { exec as callbackExec } from 'child_process'; import { access as fsAccess } from 'fs/promises'; @@ -10,68 +5,67 @@ import { promisify } from 'util'; import cookieParser from 'cookie-parser'; import express from 'express'; import helmet from 'helmet'; -import { type Class, InstanceSettings } from 'n8n-core'; +import { InstanceSettings } from 'n8n-core'; import type { IN8nUISettings } from 'n8n-workflow'; -// @ts-ignore +// @ts-expect-error missing types import timezones from 'google-timezones-json'; import config from '@/config'; -import { Queue } from '@/Queue'; -import { WorkflowsController } from '@/workflows/workflows.controller'; -import { EDITOR_UI_DIST_DIR, inDevelopment, inE2ETests, N8N_VERSION, Time } from '@/constants'; -import { CredentialsController } from '@/credentials/credentials.controller'; +import { + EDITOR_UI_DIST_DIR, + inDevelopment, + inE2ETests, + inProduction, + N8N_VERSION, + Time, +} from '@/constants'; import type { APIRequest } from '@/requests'; -import { registerController } from '@/decorators'; -import { AuthController } from '@/controllers/auth.controller'; -import { BinaryDataController } from '@/controllers/binaryData.controller'; -import { CurlController } from '@/controllers/curl.controller'; -import { DynamicNodeParametersController } from '@/controllers/dynamicNodeParameters.controller'; -import { MeController } from '@/controllers/me.controller'; -import { MFAController } from '@/controllers/mfa.controller'; -import { NodeTypesController } from '@/controllers/nodeTypes.controller'; -import { OAuth1CredentialController } from '@/controllers/oauth/oAuth1Credential.controller'; -import { OAuth2CredentialController } from '@/controllers/oauth/oAuth2Credential.controller'; -import { OwnerController } from '@/controllers/owner.controller'; -import { PasswordResetController } from '@/controllers/passwordReset.controller'; -import { TagsController } from '@/controllers/tags.controller'; -import { TranslationController } from '@/controllers/translation.controller'; -import { UsersController } from '@/controllers/users.controller'; -import { WorkflowStatisticsController } from '@/controllers/workflowStatistics.controller'; -import { ExternalSecretsController } from '@/ExternalSecrets/ExternalSecrets.controller.ee'; -import { ExecutionsController } from '@/executions/executions.controller'; +import { ControllerRegistry } from '@/decorators'; import { isApiEnabled, loadPublicApiVersions } from '@/PublicApi'; import type { ICredentialsOverwrite } from '@/Interfaces'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import * as ResponseHelper from '@/ResponseHelper'; -import { EventBusController } from '@/eventbus/eventBus.controller'; -import { LicenseController } from '@/license/license.controller'; import { setupPushServer, setupPushHandler } from '@/push'; -import { isLdapEnabled } from './Ldap/helpers'; -import { AbstractServer } from './AbstractServer'; -import { PostHogClient } from './posthog'; +import { isLdapEnabled } from '@/Ldap/helpers'; +import { AbstractServer } from '@/AbstractServer'; +import { PostHogClient } from '@/posthog'; import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; -import { InternalHooks } from './InternalHooks'; -import { SamlController } from './sso/saml/routes/saml.controller.ee'; -import { SamlService } from './sso/saml/saml.service.ee'; -import { VariablesController } from './environments/variables/variables.controller.ee'; -import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee'; -import { SourceControlController } from '@/environments/sourceControl/sourceControl.controller.ee'; -import { AIController } from '@/controllers/ai.controller'; - -import { handleMfaDisable, isMfaFeatureEnabled } from './Mfa/helpers'; -import type { FrontendService } from './services/frontend.service'; -import { ActiveWorkflowsController } from './controllers/activeWorkflows.controller'; -import { OrchestrationController } from './controllers/orchestration.controller'; -import { WorkflowHistoryController } from './workflows/workflowHistory/workflowHistory.controller.ee'; -import { InvitationController } from './controllers/invitation.controller'; -// import { CollaborationService } from './collaboration/collaboration.service'; +import { InternalHooks } from '@/InternalHooks'; +import { handleMfaDisable, isMfaFeatureEnabled } from '@/Mfa/helpers'; +import type { FrontendService } from '@/services/frontend.service'; import { OrchestrationService } from '@/services/orchestration.service'; -import { ProjectController } from './controllers/project.controller'; -import { RoleController } from './controllers/role.controller'; -import { UserSettingsController } from './controllers/userSettings.controller'; + +import '@/controllers/activeWorkflows.controller'; +import '@/controllers/ai.controller'; +import '@/controllers/auth.controller'; +import '@/controllers/binaryData.controller'; +import '@/controllers/curl.controller'; +import '@/controllers/dynamicNodeParameters.controller'; +import '@/controllers/invitation.controller'; +import '@/controllers/me.controller'; +import '@/controllers/nodeTypes.controller'; +import '@/controllers/oauth/oAuth1Credential.controller'; +import '@/controllers/oauth/oAuth2Credential.controller'; +import '@/controllers/orchestration.controller'; +import '@/controllers/owner.controller'; +import '@/controllers/passwordReset.controller'; +import '@/controllers/project.controller'; +import '@/controllers/role.controller'; +import '@/controllers/tags.controller'; +import '@/controllers/translation.controller'; +import '@/controllers/users.controller'; +import '@/controllers/userSettings.controller'; +import '@/controllers/workflowStatistics.controller'; +import '@/credentials/credentials.controller'; +import '@/eventbus/eventBus.controller'; +import '@/executions/executions.controller'; +import '@/ExternalSecrets/ExternalSecrets.controller.ee'; +import '@/license/license.controller'; +import '@/workflows/workflowHistory/workflowHistory.controller.ee'; +import '@/workflows/workflows.controller'; const exec = promisify(callbackExec); @@ -81,11 +75,13 @@ export class Server extends AbstractServer { private presetCredentialsLoaded: boolean; - private loadNodesAndCredentials: LoadNodesAndCredentials; - private frontendService?: FrontendService; - constructor() { + constructor( + private readonly loadNodesAndCredentials: LoadNodesAndCredentials, + private readonly orchestrationService: OrchestrationService, + private readonly postHogClient: PostHogClient, + ) { super('main'); this.testWebhooksEnabled = true; @@ -93,11 +89,9 @@ export class Server extends AbstractServer { } async start() { - this.loadNodesAndCredentials = Container.get(LoadNodesAndCredentials); - if (!config.getEnv('endpoints.disableUi')) { - // eslint-disable-next-line @typescript-eslint/no-var-requires - this.frontendService = Container.get(require('@/services/frontend.service').FrontendService); + const { FrontendService } = await import('@/services/frontend.service'); + this.frontendService = Container.get(FrontendService); } this.presetCredentialsLoaded = false; @@ -111,84 +105,62 @@ export class Server extends AbstractServer { } void Container.get(InternalHooks).onServerStarted(); - // Container.get(CollaborationService); } - private async registerControllers() { - const { app } = this; - - const controllers: Array> = [ - EventBusController, - AuthController, - LicenseController, - OAuth1CredentialController, - OAuth2CredentialController, - OwnerController, - MeController, - DynamicNodeParametersController, - NodeTypesController, - PasswordResetController, - TagsController, - TranslationController, - UsersController, - SamlController, - SourceControlController, - WorkflowStatisticsController, - ExternalSecretsController, - OrchestrationController, - WorkflowHistoryController, - BinaryDataController, - VariablesController, - InvitationController, - VariablesController, - ActiveWorkflowsController, - WorkflowsController, - ExecutionsController, - CredentialsController, - AIController, - ProjectController, - RoleController, - CurlController, - UserSettingsController, - ]; - - if ( - process.env.NODE_ENV !== 'production' && - Container.get(OrchestrationService).isMultiMainSetupEnabled - ) { - const { DebugController } = await import('@/controllers/debug.controller'); - controllers.push(DebugController); + private async registerAdditionalControllers() { + if (!inProduction && this.orchestrationService.isMultiMainSetupEnabled) { + await import('@/controllers/debug.controller'); } if (isLdapEnabled()) { const { LdapService } = await import('@/Ldap/ldap.service'); - const { LdapController } = await require('@/Ldap/ldap.controller'); + await import('@/Ldap/ldap.controller'); await Container.get(LdapService).init(); - controllers.push(LdapController); } if (config.getEnv('nodes.communityPackages.enabled')) { - const { CommunityPackagesController } = await import( - '@/controllers/communityPackages.controller' - ); - controllers.push(CommunityPackagesController); + await import('@/controllers/communityPackages.controller'); } if (inE2ETests) { - const { E2EController } = await import('./controllers/e2e.controller'); - controllers.push(E2EController); + await import('@/controllers/e2e.controller'); } if (isMfaFeatureEnabled()) { - controllers.push(MFAController); + await import('@/controllers/mfa.controller'); } if (!config.getEnv('endpoints.disableUi')) { - const { CtaController } = await import('@/controllers/cta.controller'); - controllers.push(CtaController); + await import('@/controllers/cta.controller'); } - controllers.forEach((controller) => registerController(app, controller)); + // ---------------------------------------- + // SAML + // ---------------------------------------- + + // initialize SamlService if it is licensed, even if not enabled, to + // set up the initial environment + try { + const { SamlService } = await import('@/sso/saml/saml.service.ee'); + await Container.get(SamlService).init(); + await import('@/sso/saml/routes/saml.controller.ee'); + } catch (error) { + this.logger.warn(`SAML initialization failed: ${(error as Error).message}`); + } + + // ---------------------------------------- + // Source Control + // ---------------------------------------- + try { + const { SourceControlService } = await import( + '@/environments/sourceControl/sourceControl.service.ee' + ); + await Container.get(SourceControlService).init(); + await import('@/environments/sourceControl/sourceControl.controller.ee'); + await import('@/environments/variables/variables.controller.ee'); + } catch (error) { + this.logger.warn(`Source Control initialization failed: ${(error as Error).message}`); + } } async configure(): Promise { @@ -209,7 +181,7 @@ export class Server extends AbstractServer { await this.externalHooks.run('frontend.settings', [frontendService.getSettings()]); } - await Container.get(PostHogClient).init(); + await this.postHogClient.init(); const publicApiEndpoint = config.getEnv('publicApi.path'); @@ -238,33 +210,16 @@ export class Server extends AbstractServer { setupPushHandler(restEndpoint, app); if (config.getEnv('executions.mode') === 'queue') { + const { Queue } = await import('@/Queue'); await Container.get(Queue).init(); } await handleMfaDisable(); - await this.registerControllers(); - - // ---------------------------------------- - // SAML - // ---------------------------------------- - - // initialize SamlService if it is licensed, even if not enabled, to - // set up the initial environment - try { - await Container.get(SamlService).init(); - } catch (error) { - this.logger.warn(`SAML initialization failed: ${error.message}`); - } + await this.registerAdditionalControllers(); - // ---------------------------------------- - // Source Control - // ---------------------------------------- - try { - await Container.get(SourceControlService).init(); - } catch (error) { - this.logger.warn(`Source Control initialization failed: ${error.message}`); - } + // register all known controllers + Container.get(ControllerRegistry).activate(app); // ---------------------------------------- // Options @@ -273,6 +228,7 @@ export class Server extends AbstractServer { // Returns all the available timezones this.app.get( `/${this.restEndpoint}/options/timezones`, + // eslint-disable-next-line @typescript-eslint/no-unsafe-return ResponseHelper.send(async () => timezones), ); diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 6ba144903b9fa..39285b797e746 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -39,7 +39,7 @@ export class AuthController { ) {} /** Log in a user */ - @Post('/login', { skipAuth: true, rateLimit: {} }) + @Post('/login', { skipAuth: true, rateLimit: true }) async login(req: LoginRequest, res: Response): Promise { const { email, password, mfaToken, mfaRecoveryCode } = req.body; if (!email) throw new ApplicationError('Email is required to log in'); diff --git a/packages/cli/src/decorators/Licensed.ts b/packages/cli/src/decorators/Licensed.ts index b411e8fa1601d..36d3e5a46bd76 100644 --- a/packages/cli/src/decorators/Licensed.ts +++ b/packages/cli/src/decorators/Licensed.ts @@ -1,14 +1,10 @@ import type { BooleanLicenseFeature } from '@/Interfaces'; -import type { LicenseMetadata } from './types'; -import { CONTROLLER_LICENSE_FEATURES } from './constants'; +import { getRouteMetadata } from './controller.registry'; +import type { Controller } from './types'; -export const Licensed = (features: BooleanLicenseFeature | BooleanLicenseFeature[]) => { - // eslint-disable-next-line @typescript-eslint/ban-types - return (target: Function | object, handlerName?: string) => { - const controllerClass = handlerName ? target.constructor : target; - const license = (Reflect.getMetadata(CONTROLLER_LICENSE_FEATURES, controllerClass) ?? - {}) as LicenseMetadata; - license[handlerName ?? '*'] = Array.isArray(features) ? features : [features]; - Reflect.defineMetadata(CONTROLLER_LICENSE_FEATURES, license, controllerClass); +export const Licensed = + (licenseFeature: BooleanLicenseFeature): MethodDecorator => + (target, handlerName) => { + const routeMetadata = getRouteMetadata(target.constructor as Controller, String(handlerName)); + routeMetadata.licenseFeature = licenseFeature; }; -}; diff --git a/packages/cli/src/decorators/Middleware.ts b/packages/cli/src/decorators/Middleware.ts index ed26fe5bf4d9f..57e9b6782ed1e 100644 --- a/packages/cli/src/decorators/Middleware.ts +++ b/packages/cli/src/decorators/Middleware.ts @@ -1,10 +1,7 @@ -import { CONTROLLER_MIDDLEWARES } from './constants'; -import type { MiddlewareMetadata } from './types'; +import { getControllerMetadata } from './controller.registry'; +import type { Controller } from './types'; export const Middleware = (): MethodDecorator => (target, handlerName) => { - const controllerClass = target.constructor; - const middlewares = (Reflect.getMetadata(CONTROLLER_MIDDLEWARES, controllerClass) ?? - []) as MiddlewareMetadata[]; - middlewares.push({ handlerName: String(handlerName) }); - Reflect.defineMetadata(CONTROLLER_MIDDLEWARES, middlewares, controllerClass); + const metadata = getControllerMetadata(target.constructor as Controller); + metadata.middlewares.push(String(handlerName)); }; diff --git a/packages/cli/src/decorators/RestController.ts b/packages/cli/src/decorators/RestController.ts index ca235cba59d66..278504ad60756 100644 --- a/packages/cli/src/decorators/RestController.ts +++ b/packages/cli/src/decorators/RestController.ts @@ -1,10 +1,12 @@ import { Service } from 'typedi'; -import { CONTROLLER_BASE_PATH } from './constants'; +import { getControllerMetadata } from './controller.registry'; +import type { Controller } from './types'; export const RestController = (basePath: `/${string}` = '/'): ClassDecorator => - (target: object) => { - Reflect.defineMetadata(CONTROLLER_BASE_PATH, basePath, target); + (target) => { + const metadata = getControllerMetadata(target as unknown as Controller); + metadata.basePath = basePath; // eslint-disable-next-line @typescript-eslint/no-unsafe-return return Service()(target); }; diff --git a/packages/cli/src/decorators/Route.ts b/packages/cli/src/decorators/Route.ts index bdaab78c7095b..42ce97bc6e184 100644 --- a/packages/cli/src/decorators/Route.ts +++ b/packages/cli/src/decorators/Route.ts @@ -1,6 +1,6 @@ import type { RequestHandler } from 'express'; -import { CONTROLLER_ROUTES } from './constants'; -import type { Method, RateLimit, RouteMetadata } from './types'; +import type { Controller, Method, RateLimit } from './types'; +import { getRouteMetadata } from './controller.registry'; interface RouteOptions { middlewares?: RequestHandler[]; @@ -8,26 +8,20 @@ interface RouteOptions { /** When this flag is set to true, auth cookie isn't validated, and req.user will not be set */ skipAuth?: boolean; /** When these options are set, calls to this endpoint are rate limited using the options */ - rateLimit?: RateLimit; + rateLimit?: boolean | RateLimit; } const RouteFactory = (method: Method) => (path: `/${string}`, options: RouteOptions = {}): MethodDecorator => (target, handlerName) => { - const controllerClass = target.constructor; - const routes = (Reflect.getMetadata(CONTROLLER_ROUTES, controllerClass) ?? - []) as RouteMetadata[]; - routes.push({ - method, - path, - middlewares: options.middlewares ?? [], - handlerName: String(handlerName), - usesTemplates: options.usesTemplates ?? false, - skipAuth: options.skipAuth ?? false, - rateLimit: options.rateLimit, - }); - Reflect.defineMetadata(CONTROLLER_ROUTES, routes, controllerClass); + const routeMetadata = getRouteMetadata(target.constructor as Controller, String(handlerName)); + routeMetadata.method = method; + routeMetadata.path = path; + routeMetadata.middlewares = options.middlewares ?? []; + routeMetadata.usesTemplates = options.usesTemplates ?? false; + routeMetadata.skipAuth = options.skipAuth ?? false; + routeMetadata.rateLimit = options.rateLimit; }; export const Get = RouteFactory('get'); diff --git a/packages/cli/src/decorators/Scoped.ts b/packages/cli/src/decorators/Scoped.ts index 0d4644ae10f8b..8a3f0b02a57e5 100644 --- a/packages/cli/src/decorators/Scoped.ts +++ b/packages/cli/src/decorators/Scoped.ts @@ -1,22 +1,13 @@ import type { Scope } from '@n8n/permissions'; -import type { RouteScopeMetadata } from './types'; -import { CONTROLLER_ROUTE_SCOPES } from './constants'; +import { getRouteMetadata } from './controller.registry'; +import type { Controller } from './types'; -const Scoped = (scope: Scope | Scope[], { globalOnly } = { globalOnly: false }) => { - return (target: Function | object, handlerName?: string) => { - const controllerClass = handlerName ? target.constructor : target; - const scopes = (Reflect.getMetadata(CONTROLLER_ROUTE_SCOPES, controllerClass) ?? - {}) as RouteScopeMetadata; - - const metadata = { - scopes: Array.isArray(scope) ? scope : [scope], - globalOnly, - }; - - scopes[handlerName ?? '*'] = metadata; - Reflect.defineMetadata(CONTROLLER_ROUTE_SCOPES, scopes, controllerClass); +const Scoped = + (scope: Scope, { globalOnly } = { globalOnly: false }): MethodDecorator => + (target, handlerName) => { + const routeMetadata = getRouteMetadata(target.constructor as Controller, String(handlerName)); + routeMetadata.accessScope = { scope, globalOnly }; }; -}; /** * Decorator for a controller method to ensure the user has a scope, @@ -34,9 +25,7 @@ const Scoped = (scope: Scope | Scope[], { globalOnly } = { globalOnly: false }) * } * ``` */ -export const GlobalScope = (scope: Scope | Scope[]) => { - return Scoped(scope, { globalOnly: true }); -}; +export const GlobalScope = (scope: Scope) => Scoped(scope, { globalOnly: true }); /** * Decorator for a controller method to ensure the user has a scope, @@ -55,6 +44,4 @@ export const GlobalScope = (scope: Scope | Scope[]) => { * ``` */ -export const ProjectScope = (scope: Scope | Scope[]) => { - return Scoped(scope); -}; +export const ProjectScope = (scope: Scope) => Scoped(scope); diff --git a/packages/cli/src/decorators/constants.ts b/packages/cli/src/decorators/constants.ts deleted file mode 100644 index 8f3aac403d592..0000000000000 --- a/packages/cli/src/decorators/constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const CONTROLLER_ROUTES = 'CONTROLLER_ROUTES'; -export const CONTROLLER_BASE_PATH = 'CONTROLLER_BASE_PATH'; -export const CONTROLLER_MIDDLEWARES = 'CONTROLLER_MIDDLEWARES'; -export const CONTROLLER_LICENSE_FEATURES = 'CONTROLLER_LICENSE_FEATURES'; -export const CONTROLLER_ROUTE_SCOPES = 'CONTROLLER_ROUTE_SCOPES'; diff --git a/packages/cli/src/decorators/controller.registry.ts b/packages/cli/src/decorators/controller.registry.ts new file mode 100644 index 0000000000000..c012922c16728 --- /dev/null +++ b/packages/cli/src/decorators/controller.registry.ts @@ -0,0 +1,137 @@ +import { Container, Service } from 'typedi'; +import { Router } from 'express'; +import type { Application, Request, Response, RequestHandler } from 'express'; +import { rateLimit as expressRateLimit } from 'express-rate-limit'; + +import { AuthService } from '@/auth/auth.service'; +import config from '@/config'; +import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; +import { inProduction, RESPONSE_ERROR_MESSAGES } from '@/constants'; +import type { BooleanLicenseFeature } from '@/Interfaces'; +import { License } from '@/License'; +import type { AuthenticatedRequest } from '@/requests'; +import { send } from '@/ResponseHelper'; // TODO: move `ResponseHelper.send` to this file +import { userHasScope } from '@/permissions/checkAccess'; + +import type { + AccessScope, + Controller, + ControllerMetadata, + HandlerName, + RateLimit, + RouteMetadata, +} from './types'; + +const registry = new Map(); + +export const getControllerMetadata = (controllerClass: Controller) => { + let metadata = registry.get(controllerClass); + if (!metadata) { + metadata = { + basePath: '/', + middlewares: [], + routes: new Map(), + }; + registry.set(controllerClass, metadata); + } + return metadata; +}; + +export const getRouteMetadata = (controllerClass: Controller, handlerName: HandlerName) => { + const metadata = getControllerMetadata(controllerClass); + let route = metadata.routes.get(handlerName); + if (!route) { + route = {} as RouteMetadata; + metadata.routes.set(handlerName, route); + } + return route; +}; + +@Service() +export class ControllerRegistry { + constructor( + private readonly license: License, + private readonly authService: AuthService, + ) {} + + activate(app: Application) { + for (const controllerClass of registry.keys()) { + this.activateController(app, controllerClass); + } + } + + private activateController(app: Application, controllerClass: Controller) { + const metadata = registry.get(controllerClass)!; + + const router = Router({ mergeParams: true }); + const prefix = `/${config.getEnv('endpoints.rest')}/${metadata.basePath}` + .replace(/\/+/g, '/') + .replace(/\/$/, ''); + app.use(prefix, router); + + const controller = Container.get(controllerClass); + const controllerMiddlewares = metadata.middlewares.map( + (handlerName) => controller[handlerName].bind(controller) as RequestHandler, + ); + + for (const [handlerName, route] of metadata.routes) { + const handler = async (req: Request, res: Response) => + await controller[handlerName](req, res); + + router[route.method]( + route.path, + ...(inProduction && route.rateLimit + ? [this.createRateLimitMiddleware(route.rateLimit)] + : []), + // eslint-disable-next-line @typescript-eslint/unbound-method + ...(route.skipAuth ? [] : [this.authService.authMiddleware]), + ...(route.licenseFeature ? [this.createLicenseMiddleware(route.licenseFeature)] : []), + ...(route.accessScope ? [this.createScopedMiddleware(route.accessScope)] : []), + ...controllerMiddlewares, + ...route.middlewares, + route.usesTemplates ? handler : send(handler), + ); + } + } + + private createRateLimitMiddleware(rateLimit: true | RateLimit): RequestHandler { + if (typeof rateLimit === 'boolean') rateLimit = {}; + return expressRateLimit({ + windowMs: rateLimit.windowMs, + limit: rateLimit.limit, + message: { message: 'Too many requests' }, + }); + } + + private createLicenseMiddleware(feature: BooleanLicenseFeature): RequestHandler { + return (_req, res, next) => { + if (!this.license.isFeatureEnabled(feature)) { + return res + .status(403) + .json({ status: 'error', message: 'Plan lacks license for this feature' }); + } + return next(); + }; + } + + private createScopedMiddleware(accessScope: AccessScope): RequestHandler { + return async ( + req: AuthenticatedRequest<{ credentialId?: string; workflowId?: string; projectId?: string }>, + res, + next, + ) => { + if (!req.user) throw new UnauthenticatedError(); + + const { scope, globalOnly } = accessScope; + + if (!(await userHasScope(req.user, [scope], globalOnly, req.params))) { + return res.status(403).json({ + status: 'error', + message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE, + }); + } + + return next(); + }; + } +} diff --git a/packages/cli/src/decorators/index.ts b/packages/cli/src/decorators/index.ts index 576b55cdd77e4..61edd6d9d985d 100644 --- a/packages/cli/src/decorators/index.ts +++ b/packages/cli/src/decorators/index.ts @@ -1,6 +1,6 @@ export { RestController } from './RestController'; export { Get, Post, Put, Patch, Delete } from './Route'; export { Middleware } from './Middleware'; -export { registerController } from './registerController'; +export { ControllerRegistry } from './controller.registry'; export { Licensed } from './Licensed'; export { GlobalScope, ProjectScope } from './Scoped'; diff --git a/packages/cli/src/decorators/registerController.ts b/packages/cli/src/decorators/registerController.ts deleted file mode 100644 index f3ed79154e635..0000000000000 --- a/packages/cli/src/decorators/registerController.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { Container } from 'typedi'; -import { Router } from 'express'; -import type { Application, Request, Response, RequestHandler } from 'express'; -import { rateLimit as expressRateLimit } from 'express-rate-limit'; -import { ApplicationError } from 'n8n-workflow'; -import type { Class } from 'n8n-core'; - -import { AuthService } from '@/auth/auth.service'; -import config from '@/config'; -import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; -import { inProduction, RESPONSE_ERROR_MESSAGES } from '@/constants'; -import type { BooleanLicenseFeature } from '@/Interfaces'; -import { License } from '@/License'; -import type { AuthenticatedRequest } from '@/requests'; -import { send } from '@/ResponseHelper'; // TODO: move `ResponseHelper.send` to this file -import { - CONTROLLER_BASE_PATH, - CONTROLLER_LICENSE_FEATURES, - CONTROLLER_MIDDLEWARES, - CONTROLLER_ROUTE_SCOPES, - CONTROLLER_ROUTES, -} from './constants'; -import type { - Controller, - LicenseMetadata, - MiddlewareMetadata, - RateLimit, - RouteMetadata, - RouteScopeMetadata, -} from './types'; -import { userHasScope } from '@/permissions/checkAccess'; - -const createRateLimitMiddleware = (rateLimit: RateLimit): RequestHandler => - expressRateLimit({ - windowMs: rateLimit.windowMs, - limit: rateLimit.limit, - message: { message: 'Too many requests' }, - }); - -export const createLicenseMiddleware = - (features: BooleanLicenseFeature[]): RequestHandler => - (_req, res, next) => { - if (features.length === 0) { - return next(); - } - - const licenseService = Container.get(License); - - const hasAllFeatures = features.every((feature) => licenseService.isFeatureEnabled(feature)); - if (!hasAllFeatures) { - return res - .status(403) - .json({ status: 'error', message: 'Plan lacks license for this feature' }); - } - - return next(); - }; - -export const createScopedMiddleware = - (routeScopeMetadata: RouteScopeMetadata[string]): RequestHandler => - async ( - req: AuthenticatedRequest<{ credentialId?: string; workflowId?: string; projectId?: string }>, - res, - next, - ) => { - if (!req.user) throw new UnauthenticatedError(); - - const { scopes, globalOnly } = routeScopeMetadata; - - if (scopes.length === 0) return next(); - - if (!(await userHasScope(req.user, scopes, globalOnly, req.params))) { - return res.status(403).json({ - status: 'error', - message: RESPONSE_ERROR_MESSAGES.MISSING_SCOPE, - }); - } - - return next(); - }; - -export const registerController = (app: Application, controllerClass: Class) => { - const controller = Container.get(controllerClass as Class); - const controllerBasePath = Reflect.getMetadata(CONTROLLER_BASE_PATH, controllerClass) as - | string - | undefined; - if (!controllerBasePath) - throw new ApplicationError('Controller is missing the RestController decorator', { - extra: { controllerName: controllerClass.name }, - }); - - const routes = Reflect.getMetadata(CONTROLLER_ROUTES, controllerClass) as RouteMetadata[]; - const licenseFeatures = Reflect.getMetadata(CONTROLLER_LICENSE_FEATURES, controllerClass) as - | LicenseMetadata - | undefined; - const routeScopes = Reflect.getMetadata(CONTROLLER_ROUTE_SCOPES, controllerClass) as - | RouteScopeMetadata - | undefined; - - if (routes.length > 0) { - const router = Router({ mergeParams: true }); - const restBasePath = config.getEnv('endpoints.rest'); - const prefix = `/${[restBasePath, controllerBasePath].join('/')}` - .replace(/\/+/g, '/') - .replace(/\/$/, ''); - - const controllerMiddlewares = ( - (Reflect.getMetadata(CONTROLLER_MIDDLEWARES, controllerClass) ?? []) as MiddlewareMetadata[] - ).map(({ handlerName }) => controller[handlerName].bind(controller) as RequestHandler); - - const authService = Container.get(AuthService); - - routes.forEach( - ({ - method, - path, - middlewares: routeMiddlewares, - handlerName, - usesTemplates, - skipAuth, - rateLimit, - }) => { - const features = licenseFeatures?.[handlerName] ?? licenseFeatures?.['*']; - const scopes = routeScopes?.[handlerName] ?? routeScopes?.['*']; - const handler = async (req: Request, res: Response) => - await controller[handlerName](req, res); - router[method]( - path, - ...(inProduction && rateLimit ? [createRateLimitMiddleware(rateLimit)] : []), - // eslint-disable-next-line @typescript-eslint/unbound-method - ...(skipAuth ? [] : [authService.authMiddleware]), - ...(features ? [createLicenseMiddleware(features)] : []), - ...(scopes ? [createScopedMiddleware(scopes)] : []), - ...controllerMiddlewares, - ...routeMiddlewares, - usesTemplates ? handler : send(handler), - ); - }, - ); - - app.use(prefix, router); - } -}; diff --git a/packages/cli/src/decorators/types.ts b/packages/cli/src/decorators/types.ts index 3c5e3cf2c104c..2acc2ae01e8a7 100644 --- a/packages/cli/src/decorators/types.ts +++ b/packages/cli/src/decorators/types.ts @@ -1,22 +1,10 @@ -import type { Request, Response, RequestHandler } from 'express'; +import type { RequestHandler } from 'express'; +import type { Class } from 'n8n-core'; import type { BooleanLicenseFeature } from '@/Interfaces'; import type { Scope } from '@n8n/permissions'; export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete'; -export type LicenseMetadata = Record; - -export type RouteScopeMetadata = { - [handlerName: string]: { - scopes: Scope[]; - globalOnly: boolean; - }; -}; - -export interface MiddlewareMetadata { - handlerName: string; -} - export interface RateLimit { /** * The maximum number of requests to allow during the `window` before rate limiting the client. @@ -30,17 +18,29 @@ export interface RateLimit { windowMs?: number; } +export type HandlerName = string; + +export interface AccessScope { + scope: Scope; + globalOnly: boolean; +} + export interface RouteMetadata { method: Method; path: string; - handlerName: string; middlewares: RequestHandler[]; usesTemplates: boolean; skipAuth: boolean; - rateLimit?: RateLimit; + rateLimit?: boolean | RateLimit; + licenseFeature?: BooleanLicenseFeature; + accessScope?: AccessScope; +} + +export interface ControllerMetadata { + basePath: `/${string}`; + middlewares: HandlerName[]; + routes: Map; } -export type Controller = Record< - RouteMetadata['handlerName'], - (req?: Request, res?: Response) => Promise ->; +export type Controller = Class & + Record Promise>; diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index 4968ddb3d9dab..7f6a312b6be89 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -8,21 +8,21 @@ import { URL } from 'url'; import config from '@/config'; import { AUTH_COOKIE_NAME } from '@/constants'; import type { User } from '@db/entities/User'; -import { registerController } from '@/decorators'; +import { ControllerRegistry } from '@/decorators'; import { rawBodyReader, bodyParser } from '@/middlewares'; import { PostHogClient } from '@/posthog'; import { Push } from '@/push'; import { License } from '@/License'; import { Logger } from '@/Logger'; import { InternalHooks } from '@/InternalHooks'; +import { AuthService } from '@/auth/auth.service'; +import type { APIRequest } from '@/requests'; import { mockInstance } from '../../../shared/mocking'; import * as testDb from '../../shared/testDb'; import { PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from '../constants'; import type { SetupProps, TestServer } from '../types'; import { LicenseMocker } from '../license'; -import { AuthService } from '@/auth/auth.service'; -import type { APIRequest } from '@/requests'; /** * Plugin to prefix a path segment into a request URL pathname. @@ -125,30 +125,23 @@ export const setupTestServer = ({ for (const group of endpointGroups) { switch (group) { case 'credentials': - const { CredentialsController } = await import('@/credentials/credentials.controller'); - registerController(app, CredentialsController); + await import('@/credentials/credentials.controller'); break; case 'workflows': - const { WorkflowsController } = await import('@/workflows/workflows.controller'); - registerController(app, WorkflowsController); + await import('@/workflows/workflows.controller'); break; case 'executions': - const { ExecutionsController } = await import('@/executions/executions.controller'); - registerController(app, ExecutionsController); + await import('@/executions/executions.controller'); break; case 'variables': - const { VariablesController } = await import( - '@/environments/variables/variables.controller.ee' - ); - registerController(app, VariablesController); + await import('@/environments/variables/variables.controller.ee'); break; case 'license': - const { LicenseController } = await import('@/license/license.controller'); - registerController(app, LicenseController); + await import('@/license/license.controller'); break; case 'metrics': @@ -157,123 +150,93 @@ export const setupTestServer = ({ break; case 'eventBus': - const { EventBusController } = await import('@/eventbus/eventBus.controller'); - registerController(app, EventBusController); + await import('@/eventbus/eventBus.controller'); break; case 'auth': - const { AuthController } = await import('@/controllers/auth.controller'); - registerController(app, AuthController); + await import('@/controllers/auth.controller'); break; case 'mfa': - const { MFAController } = await import('@/controllers/mfa.controller'); - registerController(app, MFAController); + await import('@/controllers/mfa.controller'); break; case 'ldap': const { LdapService } = await import('@/Ldap/ldap.service'); - const { LdapController } = await import('@/Ldap/ldap.controller'); + await import('@/Ldap/ldap.controller'); testServer.license.enable('feat:ldap'); await Container.get(LdapService).init(); - registerController(app, LdapController); break; case 'saml': const { setSamlLoginEnabled } = await import('@/sso/saml/samlHelpers'); - const { SamlController } = await import('@/sso/saml/routes/saml.controller.ee'); + await import('@/sso/saml/routes/saml.controller.ee'); await setSamlLoginEnabled(true); - registerController(app, SamlController); break; case 'sourceControl': - const { SourceControlController } = await import( - '@/environments/sourceControl/sourceControl.controller.ee' - ); - registerController(app, SourceControlController); + await import('@/environments/sourceControl/sourceControl.controller.ee'); break; case 'community-packages': - const { CommunityPackagesController } = await import( - '@/controllers/communityPackages.controller' - ); - registerController(app, CommunityPackagesController); + await import('@/controllers/communityPackages.controller'); break; case 'me': - const { MeController } = await import('@/controllers/me.controller'); - registerController(app, MeController); + await import('@/controllers/me.controller'); break; case 'passwordReset': - const { PasswordResetController } = await import( - '@/controllers/passwordReset.controller' - ); - registerController(app, PasswordResetController); + await import('@/controllers/passwordReset.controller'); break; case 'owner': - const { OwnerController } = await import('@/controllers/owner.controller'); - registerController(app, OwnerController); + await import('@/controllers/owner.controller'); break; case 'users': - const { UsersController } = await import('@/controllers/users.controller'); - registerController(app, UsersController); + await import('@/controllers/users.controller'); break; case 'invitations': - const { InvitationController } = await import('@/controllers/invitation.controller'); - registerController(app, InvitationController); + await import('@/controllers/invitation.controller'); break; case 'tags': - const { TagsController } = await import('@/controllers/tags.controller'); - registerController(app, TagsController); + await import('@/controllers/tags.controller'); break; case 'externalSecrets': - const { ExternalSecretsController } = await import( - '@/ExternalSecrets/ExternalSecrets.controller.ee' - ); - registerController(app, ExternalSecretsController); + await import('@/ExternalSecrets/ExternalSecrets.controller.ee'); break; case 'workflowHistory': - const { WorkflowHistoryController } = await import( - '@/workflows/workflowHistory/workflowHistory.controller.ee' - ); - registerController(app, WorkflowHistoryController); + await import('@/workflows/workflowHistory/workflowHistory.controller.ee'); break; case 'binaryData': - const { BinaryDataController } = await import('@/controllers/binaryData.controller'); - registerController(app, BinaryDataController); + await import('@/controllers/binaryData.controller'); break; case 'debug': - const { DebugController } = await import('@/controllers/debug.controller'); - registerController(app, DebugController); + await import('@/controllers/debug.controller'); break; case 'project': - const { ProjectController } = await import('@/controllers/project.controller'); - registerController(app, ProjectController); + await import('@/controllers/project.controller'); break; case 'role': - const { RoleController } = await import('@/controllers/role.controller'); - registerController(app, RoleController); + await import('@/controllers/role.controller'); break; case 'dynamic-node-parameters': - const { DynamicNodeParametersController } = await import( - '@/controllers/dynamicNodeParameters.controller' - ); - registerController(app, DynamicNodeParametersController); + await import('@/controllers/dynamicNodeParameters.controller'); break; } } + + Container.get(ControllerRegistry).activate(app); } }); diff --git a/packages/cli/test/unit/decorators/controller.registry.test.ts b/packages/cli/test/unit/decorators/controller.registry.test.ts new file mode 100644 index 0000000000000..04b4884dcc331 --- /dev/null +++ b/packages/cli/test/unit/decorators/controller.registry.test.ts @@ -0,0 +1,115 @@ +jest.mock('@/constants', () => ({ + inProduction: true, +})); + +import express from 'express'; +import { agent as testAgent } from 'supertest'; +import { mock } from 'jest-mock-extended'; + +import { ControllerRegistry, Get, Licensed, RestController } from '@/decorators'; +import type { AuthService } from '@/auth/auth.service'; +import type { License } from '@/License'; +import type { SuperAgentTest } from '@test-integration/types'; + +describe('ControllerRegistry', () => { + const license = mock(); + const authService = mock(); + let agent: SuperAgentTest; + + beforeEach(() => { + jest.resetAllMocks(); + const app = express(); + new ControllerRegistry(license, authService).activate(app); + agent = testAgent(app); + }); + + describe('Rate limiting', () => { + @RestController('/test') + // @ts-expect-error tsc complains about unused class + class TestController { + @Get('/unlimited') + unlimited() { + return { ok: true }; + } + + @Get('/rate-limited', { rateLimit: true }) + rateLimited() { + return { ok: true }; + } + } + + beforeEach(() => { + authService.authMiddleware.mockImplementation(async (_req, _res, next) => next()); + }); + + it('should not rate-limit by default', async () => { + for (let i = 0; i < 6; i++) { + await agent.get('/rest/test/unlimited').expect(200); + } + }); + + it('should rate-limit when configured', async () => { + for (let i = 0; i < 5; i++) { + await agent.get('/rest/test/rate-limited').expect(200); + } + await agent.get('/rest/test/rate-limited').expect(429); + }); + }); + + describe('Authorization', () => { + @RestController('/test') + // @ts-expect-error tsc complains about unused class + class TestController { + @Get('/no-auth', { skipAuth: true }) + noAuth() { + return { ok: true }; + } + + @Get('/auth') + auth() { + return { ok: true }; + } + } + + it('should not require auth if configured to skip', async () => { + await agent.get('/rest/test/no-auth').expect(200); + expect(authService.authMiddleware).not.toHaveBeenCalled(); + }); + + it('should require auth by default', async () => { + authService.authMiddleware.mockImplementation(async (_req, res) => { + res.status(401).send(); + }); + await agent.get('/rest/test/auth').expect(401); + expect(authService.authMiddleware).toHaveBeenCalled(); + }); + }); + + describe('License checks', () => { + @RestController('/test') + // @ts-expect-error tsc complains about unused class + class TestController { + @Get('/with-sharing') + @Licensed('feat:sharing') + sharing() { + return { ok: true }; + } + } + + beforeEach(() => { + authService.authMiddleware.mockImplementation(async (_req, _res, next) => next()); + }); + + it('should disallow when feature is missing', async () => { + license.isFeatureEnabled.calledWith('feat:sharing').mockReturnValue(false); + await agent.get('/rest/test/with-sharing').expect(403); + expect(license.isFeatureEnabled).toHaveBeenCalled(); + }); + + it('should allow when feature is available', async () => { + license.isFeatureEnabled.calledWith('feat:sharing').mockReturnValue(true); + await agent.get('/rest/test/with-sharing').expect(200); + expect(license.isFeatureEnabled).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/test/unit/decorators/registerController.test.ts b/packages/cli/test/unit/decorators/registerController.test.ts deleted file mode 100644 index 56ba7838844c0..0000000000000 --- a/packages/cli/test/unit/decorators/registerController.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -jest.mock('@/constants', () => ({ - inProduction: true, -})); - -import express from 'express'; -import { agent as testAgent } from 'supertest'; - -import { Get, RestController, registerController } from '@/decorators'; -import { AuthService } from '@/auth/auth.service'; -import { mockInstance } from '../../shared/mocking'; - -describe('registerController', () => { - @RestController('/test') - class TestController { - @Get('/unlimited', { skipAuth: true }) - @Get('/rate-limited', { skipAuth: true, rateLimit: {} }) - endpoint() { - return { ok: true }; - } - } - - mockInstance(AuthService); - const app = express(); - registerController(app, TestController); - const agent = testAgent(app); - - it('should not rate-limit by default', async () => { - for (let i = 0; i < 6; i++) { - await agent.get('/rest/test/unlimited').expect(200); - } - }); - - it('should rate-limit when configured', async () => { - for (let i = 0; i < 5; i++) { - await agent.get('/rest/test/rate-limited').expect(200); - } - await agent.get('/rest/test/rate-limited').expect(429); - }); -});