n8n:protect-endpoints

Par n8n-io · n8n

Applique les décorateurs de scope RBAC de n8n aux endpoints REST. À utiliser lors de la création d'un nouveau `@RestController`, de l'ajout d'une route `@Get`/`@Post`/`@Put`/`@Patch`/`@Delete` à un contrôleur existant, ou lors de la revue des autorisations d'endpoint. Tout endpoint authentifié doit être protégé par `@ProjectScope` ou `@GlobalScope`.

npx skills add https://github.com/n8n-io/n8n --skill n8n:protect-endpoints

Protéger les endpoints REST avec RBAC

Règle : tout endpoint authentifié sur un @RestController DOIT porter un décorateur d'accès-scope. Si tu ajoutes une route sans décorateur, la vulnérabilité IDOR/contournement de permissions est de ta responsabilité.

Décision

URL a :projectId  → @ProjectScope('<resource>:<op>')
URL sans project  → @GlobalScope('<resource>:<op>')
skipAuth: true    → pas de décorateur + commentaire expliquant l'auth alternatif

@ProjectScope réussit si l'utilisateur a le scope globalement OU dans le projet nommé dans l'URL. @GlobalScope ignore complètement les relations de projet.

Les deux décorateurs viennent de @n8n/decorators. Le middleware se trouve dans packages/cli/src/controller.registry.ts (createScopedMiddleware) et résout l'accès via userHasScopes dans packages/cli/src/permissions.ee/check-access.ts.

Appliquer le décorateur

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

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

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

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

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

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

Conventions :

  • Un décorateur par route, placé directement sous le décorateur de méthode HTTP.
  • Utilise le scope le plus spécifique adapté. Réutilise *:update pour les actions qui changent d'état comme publish/unpublish/build sauf si la ressource doit les contrôler séparément (voir workflow:publish comme précédent).
  • Les routes sans :projectId et qui ne sont pas des opérations globales uniquement sont généralement un mauvais design — signale-le.

Quand le scope n'existe pas encore

Ajoute la ressource et les opérations dans packages/@n8n/permissions/ :

  1. src/constants.ee.ts — ajoute à RESOURCES (ordre alphabétique) :
    widget: [...DEFAULT_OPERATIONS, 'execute'] as const,

    L'union Scope (type template-literal <resource>:<op>) se dérive automatiquement.

  2. src/scope-information.ts — ajoute un nom d'affichage + description par scope.
  3. src/roles/scopes/project-scopes.ee.ts — ajoute aux rôles de projet. Respecte le précédent workflow sauf si le produit dit autrement :
    • REGULAR_PROJECT_ADMIN_SCOPES, PERSONAL_PROJECT_OWNER_SCOPES, PROJECT_EDITOR_SCOPES → tous les scopes CRUDL+execute.
    • PROJECT_VIEWER_SCOPES → lecture/liste/execute uniquement.
    • PROJECT_CHAT_USER_SCOPES → execute uniquement (si applicable).
  4. src/roles/scopes/global-scopes.ee.ts — ajoute à GLOBAL_OWNER_SCOPES (admin hérite via concat()). Ne pas ajouter aux globals member/chat-user — ils reçoivent les scopes via les relations de projet.
  5. Publication en espace personnel : si tu ajoutes un scope <resource>:publish, ajoute-le aussi à PERSONAL_SPACE_PUBLISHING_SETTING.scopes dans constants.ee.ts pour que la limitation de propriétaire personnel corresponde à workflow:publish.
  6. Intégration frontend — trois fichiers dans l'éditeur ; en sauter un signifie que les nouveaux scopes n'apparaîtront pas dans l'interface de configuration des rôles de projet :
    • packages/frontend/editor-ui/src/app/stores/rbac.store.ts — ajoute <resource>: {} à scopesByResourceId (le typage échouera sinon).
    • packages/frontend/editor-ui/src/features/project-roles/projectRoleScopes.ts — ajoute la ressource à UI_OPERATIONS (opérations à afficher dans la matrice des permissions, dans l'ordre d'affichage) et à SCOPE_TYPES (l'ordre où le groupe de ressources apparaît sur la page).
    • packages/frontend/@n8n/i18n/src/locales/en.json — ajoute projectRoles.<resource>:<op> (étiquette de colonne) et projectRoles.<resource>:<op>.tooltip (description au survol) pour chaque op, plus projectRoles.type.<resource> (l'en-tête du groupe).
  7. Snapshot — mets à jour packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap pour inclure les nouvelles entrées <resource>:*.

Aucune migration BD nécessaire — AuthRolesService.init() synchronise les scopes/rôles à chaque démarrage. Les rôles d'équipe personnalisés créés dans l'interface ne sont pas automatiquement mis à jour ; mentionne-le dans la description de la PR.

Routes publiques / non authentifiées

{ skipAuth: true } ignore le middleware d'auth → req.user est undefined → ajouter @ProjectScope retournerait 401 à chaque appel. Les routes publiques (webhooks tiers, callbacks signés) doivent :

  1. Omettre le décorateur de scope.
  2. S'authentifier via vérification de signature/HMAC à l'intérieur du handler (ou un autre mécanisme spécifique à la route).
  3. Porter un commentaire expliquant pourquoi aucun scope n'est appliqué, pour que le prochain relecteur ne tente pas de le « corriger ».

Exemple :

// Callback webhook tiers : ne pas ajouter @ProjectScope. L'auth se fait
// via la vérification de signature par plateforme à l'intérieur de webhookHandler,
// et :projectId n'est pas utilisé dans la recherche (agentId, platform).
@Post('/:agentId/webhooks/:platform', { skipAuth: true, allowBots: true })
async handleWebhook(...) { ... }

Vérifier avec un test de métadonnées de route

Ajoute un test de régression qui échoue quand une route future est ajoutée sans scope. Itère sur chaque route du contrôleur via ControllerRegistryMetadata et affirme la limite.

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

const UNAUTHENTICATED_HANDLERS = new Set<string>(); // ajoute ici les noms de handlers publics

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

Défense en profondeur (toujours requise)

Le décorateur seul ne suffit pas quand les handlers fuient des données via des appels en aval. Les méthodes service/repository doivent toujours filtrer par projectId (ou utiliser des helpers limités à l'utilisateur comme findByUser). Le décorateur limite qui peut appeler cette URL ; le service limite ce qu'il peut lire. Les deux, toujours.

Patterns de référence

  • CRUD limité au projet : packages/cli/src/workflows/workflows.controller.ts, packages/cli/src/credentials/credentials.controller.ts, packages/cli/src/modules/data-table/data-table.controller.ts.
  • Mélange global + projet : packages/cli/src/controllers/project.controller.ts.

Skills similaires