All skills
n8n-io avatar

/protect-endpoints

@15cbd90 official
by n8n - Workflow Automationn8n-io/n8n206k stars
60,960

Applies n8n's RBAC scope decorators to REST endpoints. Use when creating a new @RestController, adding any @Get/@Post/@Put/@Patch/@Delete route to an existing controller, or reviewing endpoint authorization. Every authenticated endpoint must be gated by @ProjectScope or @GlobalScope.

  • 1 file
  • 7.3 KB
  • Updated 2 months ago
  • GitHub

Use this Skill: https://skilld.dev/gh/n8n-io/n8n/protect-endpoints

This session only. Nothing lands on disk.

SKILL.md

β‰ˆ76 tokens always: the name and description. β‰ˆ1.8k when used: this file.

Protect REST endpoints with RBAC

Rule: every authenticated route on a @RestController MUST carry an access-scope decorator. If you add a route without one, the IDOR/permission bypass is on you.

Decision

URL has :projectId  β†’ @ProjectScope('<resource>:<op>')
URL has no project  β†’ @GlobalScope('<resource>:<op>')
skipAuth: true      β†’ no decorator + comment explaining alternate auth

@ProjectScope succeeds if the user has the scope globally OR in the project named in the URL. @GlobalScope ignores project relations entirely.

Both decorators come from @n8n/decorators. The middleware lives in packages/cli/src/controller.registry.ts (createScopedMiddleware) and resolves access via userHasScopes in packages/cli/src/permissions.ee/check-access.ts.

Apply the decorator

import { Get, Post, ProjectScope, RestController } from '@n8n/decorators';

@RestController('/projects/:projectId/widgets')
export class WidgetsController {
  @Post('/')
  @ProjectScope('widget:create')          // create
  async create(...) { ... }

  @Get('/:widgetId')
  @ProjectScope('widget:read')            // read one
  async get(...) { ... }

  @Get('/')
  @ProjectScope('widget:list')            // list
  async list(...) { ... }

  @Patch('/:widgetId')
  @ProjectScope('widget:update')          // update
  async update(...) { ... }

  @Delete('/:widgetId')
  @ProjectScope('widget:delete')          // delete
  async delete(...) { ... }
}

Conventions:

  • One decorator per route, placed directly under the HTTP-method decorator.
  • Use the most specific scope that fits. Reuse *:update for state-changing actions like publish/unpublish/build unless the resource needs to gate them separately (see workflow:publish for the precedent).
  • Routes without :projectId and not global-only operations are usually a design smell β€” flag it.

When the scope doesn't exist yet

Add the resource and ops in packages/@n8n/permissions/:

  1. src/constants.ee.ts β€” add to RESOURCES (alphabetical):
    widget: [...DEFAULT_OPERATIONS, 'execute'] as const,
    The Scope union (<resource>:<op> template-literal type) auto-derives.
  2. src/scope-information.ts β€” add a display name + description per scope.
  3. src/roles/scopes/project-scopes.ee.ts β€” add to project roles. Match the workflow precedent unless product says otherwise:
    • REGULAR_PROJECT_ADMIN_SCOPES, PERSONAL_PROJECT_OWNER_SCOPES, PROJECT_EDITOR_SCOPES β†’ all CRUDL+execute scopes.
    • PROJECT_VIEWER_SCOPES β†’ read/list/execute only.
    • PROJECT_CHAT_USER_SCOPES β†’ execute only (if applicable).
  4. src/roles/scopes/global-scopes.ee.ts β€” add to GLOBAL_OWNER_SCOPES (admin inherits via concat()). Do not add to member/chat-user globals β€” they get scopes via project relations.
  5. Personal-space publishing: if you add a <resource>:publish scope, also append it to PERSONAL_SPACE_PUBLISHING_SETTING.scopes in constants.ee.ts so personal-owner gating matches workflow:publish.
  6. src/roles/custom-role-scopes.ee.ts β€” add the resource to PROJECT_CUSTOM_ROLE_OPERATIONS with the ops to render in the permissions matrix, in display order. The editor's SCOPES/SCOPE_TYPES and the save-time whitelist PROJECT_CUSTOM_ROLE_SCOPES both derive from it: a resource missing here cannot reach the UI, and a scope missing from it is rejected on save.
  7. Frontend wiring β€” three files; skipping any of them means the new scopes will not appear in the project-role configuration UI:
    • packages/frontend/@n8n/stores/src/rbac.store.ts β€” add <resource>: {} to scopesByResourceId (typecheck will fail otherwise).
    • packages/frontend/editor-ui/src/features/roles/project/projectRoleScopes.ts β€” add the resource to SCOPE_TYPES (the order the resource group appears on the page).
    • packages/frontend/@n8n/i18n/src/locales/en.json β€” add projectRoles.<resource>:<op> (column label) and projectRoles.<resource>:<op>.tooltip (hover description) for every op, plus projectRoles.type.<resource> (the group header).
  8. Snapshot β€” update packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap to include the new <resource>:* entries.

No DB migration needed β€” AuthRolesService.init() syncs scopes/roles on every startup. Custom team roles created in the UI are not auto-updated; mention this in the PR description.

Public / unauthenticated routes

{ skipAuth: true } skips the auth middleware β†’ req.user is undefined β†’ adding @ProjectScope would 401 every call. Public routes (third-party webhooks, signed callbacks) must:

  1. Omit the scope decorator.
  2. Authenticate via signature/HMAC verification inside the handler (or another route-specific mechanism).
  3. Carry a comment explaining why no scope is applied, so the next reviewer doesn't try to "fix" it.

Example:

// Third-party webhook callback: do not add @ProjectScope. Auth happens
// via per-platform signature verification inside webhookHandler, and
// :projectId is unused in the (agentId, platform) lookup.
@Post('/:agentId/webhooks/:platform', { skipAuth: true, allowBots: true })
async handleWebhook(...) { ... }

Verify with a route-metadata test

Add a regression test that fails when a future route is added without a scope. Iterate every route on the controller via ControllerRegistryMetadata and assert the gate.

import { ControllerRegistryMetadata } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { WidgetsController } from '../widgets.controller';

const UNAUTHENTICATED_HANDLERS = new Set<string>(); // add public handler names here

const metadata = Container.get(ControllerRegistryMetadata).getControllerMetadata(
  WidgetsController as never,
);
const routeCases = Array.from(metadata.routes.entries()).map(([handlerName, route]) => ({
  handlerName, route,
}));

describe('WidgetsController route access scopes', () => {
  it.each(routeCases)(
    '$handlerName is gated by a project-scoped widget:* check',
    ({ handlerName, route }) => {
      if (UNAUTHENTICATED_HANDLERS.has(handlerName)) {
        expect(route.accessScope).toBeUndefined();
        expect(route.skipAuth).toBe(true);
        return;
      }
      expect(route.accessScope).toBeDefined();
      expect(route.accessScope?.globalOnly).toBe(false);
      expect(route.accessScope?.scope.startsWith('widget:')).toBe(true);
    },
  );
});

Defense in depth (still required)

Decorator alone is not enough when handlers leak data via downstream calls. Service/repository methods should still filter by projectId (or user-scoped helpers like findByUser). The decorator gates who can call this URL; the service gates what they can read. Both, always.

Reference patterns

  • Project-scoped CRUD: packages/cli/src/workflows/workflows.controller.ts, packages/cli/src/credentials/credentials.controller.ts, packages/cli/src/modules/data-table/data-table.controller.ts.
  • Mixed global + project: packages/cli/src/controllers/project.controller.ts.

Source: SKILL.md on GitHub

No third-party reports yet.

Signed by skilld at 15cbd90. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 20 hours ago.

Activeupdated 2 months ago
  • TypeScript
  • n8n
  • rbac
  • authorization
  • access-control
  • rest-api
  • decorator
  • endpoint-security
  • permission-scopes

README badge

README badge for n8n-io/n8n/protect-endpoints

Applies n8n's RBAC scope decorators to REST endpoints, gating authenticated routes by @ProjectScope or @GlobalScope. Use when adding or reviewing @RestController routes to prevent authorization bypasses. Scopes follow the pattern `<resource>:<operation>` and are defined in @n8n/permissions.

Generated from the current SKILL.md.

What's the difference between @ProjectScope and @GlobalScope?
@ProjectScope checks if the user has the scope globally OR within the specific project in the URL; @GlobalScope ignores project relations entirely. Use @ProjectScope for routes with :projectId in the path, @GlobalScope for global-only operations.
Do I need to add the decorator to unauthenticated routes?
No. Public routes with skipAuth: true must omit the scope decorator and include a comment explaining why auth happens via an alternate mechanism (e.g., signature verification).
What happens if I add a route without a scope decorator?
The endpoint will be unprotected and vulnerable to IDOR/permission bypass. The SKILL.md states the responsibility is on you if you skip the decorator.
Where do I define a new scope if the resource doesn't exist yet?
Add the resource to packages/@n8n/permissions/src/constants.ee.ts, then update scope-information.ts, the project and global role files, and the three frontend files (rbac.store.ts, projectRoleScopes.ts, en.json) so the new scope appears in the UI.
Is the decorator alone enough to protect an endpoint?
No. The decorator gates who can call the URL, but the service/repository layer must still filter by projectId or user to prevent leaking data via downstream calls.

Generated from the current SKILL.md. These answers refresh after source changes.