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
*:updatepour les actions qui changent d'état commepublish/unpublish/buildsauf si la ressource doit les contrôler séparément (voirworkflow:publishcomme précédent). - Les routes sans
:projectIdet 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/ :
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.src/scope-information.ts— ajoute un nom d'affichage + description par scope.src/roles/scopes/project-scopes.ee.ts— ajoute aux rôles de projet. Respecte le précédentworkflowsauf 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).
src/roles/scopes/global-scopes.ee.ts— ajoute àGLOBAL_OWNER_SCOPES(admin hérite viaconcat()). Ne pas ajouter aux globals member/chat-user — ils reçoivent les scopes via les relations de projet.- Publication en espace personnel : si tu ajoutes un scope
<resource>:publish, ajoute-le aussi àPERSONAL_SPACE_PUBLISHING_SETTING.scopesdansconstants.ee.tspour que la limitation de propriétaire personnel corresponde àworkflow:publish. - 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— ajouteprojectRoles.<resource>:<op>(étiquette de colonne) etprojectRoles.<resource>:<op>.tooltip(description au survol) pour chaque op, plusprojectRoles.type.<resource>(l'en-tête du groupe).
- Snapshot — mets à jour
packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snappour 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 :
- Omettre le décorateur de scope.
- S'authentifier via vérification de signature/HMAC à l'intérieur du handler (ou un autre mécanisme spécifique à la route).
- 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.