关于 n8n:protect-endpoints
面向 n8n REST 控制器开发的技能,为已认证路由应用 RBAC 访问范围规则,并说明新增权限范围所需的配置和测试。
主要功能
- 01为路由分配项目或全局范围
- 02说明如何添加权限范围
- 03涵盖未认证路由处理
- 04要求路由元数据回归测试
- 05强调服务层数据过滤
适用场景
- 01为 REST 路由添加访问检查
- 02在 n8n 中引入权限范围
- 03审查公开 Webhook 的认证
- 04防止跨项目数据泄露
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ n8n-io/ n8n --skill n8n:protect-endpoints安装后在 Claude Code 中输入/n8n:protect-endpoints确认已加载。
详细介绍
使用 RBAC 保护 REST 端点
规则: @RestController 上的每个已认证路由都必须带有访问范围装饰器。如果添加了不带装饰器的路由,IDOR/权限绕过的责任由你承担。
决策
URL has :projectId → @ProjectScope('<resource>:<op>')
URL has no project → @GlobalScope('<resource>:<op>')
skipAuth: true → no decorator + comment explaining alternate auth
如果用户在全局范围内 或在 URL 中指定的项目内 拥有相应范围,@ProjectScope 就会通过。@GlobalScope 则完全忽略项目关系。
这两个装饰器都来自 @n8n/decorators。中间件位于 packages/cli/src/controller.registry.ts(createScopedMiddleware),通过 packages/cli/src/permissions.ee/check-access.ts 中的 userHasScopes 解析访问权限。
应用装饰器
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(...) { ... }
}
约定:
- 每个路由使用一个装饰器,紧跟在 HTTP 方法装饰器下方。
- 使用适用范围内最具体的范围。对于
publish/unpublish/build等会更改状态的操作,复用*:update,除非该资源需要单独控制这些操作(参见先例workflow:publish)。 - 不带
:projectId且并非仅限全局操作的路由通常表明设计存在问题——请标记出来。
尚无对应范围时
在 packages/@n8n/permissions/ 中添加资源和操作:
src/constants.ee.ts— 将资源添加到RESOURCES(按字母顺序):widget: [...DEFAULT_OPERATIONS, 'execute'] as const,Scope联合类型(<resource>:<op>模板字面量类型)会自动派生。src/scope-information.ts— 为每个范围添加显示名称和说明。src/roles/scopes/project-scopes.ee.ts— 添加到项目角色中。除非产品另有要求,否则遵循workflow先例:REGULAR_PROJECT_ADMIN_SCOPES、PERSONAL_PROJECT_OWNER_SCOPES、PROJECT_EDITOR_SCOPES→ 所有 CRUDL+execute 范围。PROJECT_VIEWER_SCOPES→ 仅 read/list/execute。PROJECT_CHAT_USER_SCOPES→ 仅 execute(如适用)。
src/roles/scopes/global-scopes.ee.ts— 添加到GLOBAL_OWNER_SCOPES(管理员通过concat()继承)。不要添加到成员/聊天用户的全局范围中——他们通过项目关系获取范围。- 个人空间发布:如果添加了
<resource>:publish范围,还要将其追加到constants.ee.ts中的PERSONAL_SPACE_PUBLISHING_SETTING.scopes,使个人所有者的权限控制与workflow:publish保持一致。 src/roles/custom-role-scopes.ee.ts— 将资源和要在权限矩阵中显示的操作添加到PROJECT_CUSTOM_ROLE_OPERATIONS,并按显示顺序排列。编辑器的SCOPES/SCOPE_TYPES和保存时的允许列表PROJECT_CUSTOM_ROLE_SCOPES都由此派生:此处缺少资源,就无法在 UI 中显示;此处缺少范围,则保存时会被拒绝。- 前端接线 — 共涉及三个文件;漏掉任何一个,新范围都不会出现在项目角色配置 UI 中:
packages/frontend/@n8n/stores/src/rbac.store.ts— 在scopesByResourceId中添加<resource>: {}(否则类型检查会失败)。packages/frontend/editor-ui/src/features/roles/project/projectRoleScopes.ts— 将资源添加到SCOPE_TYPES(决定资源组在页面上的显示顺序)。packages/frontend/@n8n/i18n/src/locales/en.json— 为每个操作添加projectRoles.<resource>:<op>(列标签)和projectRoles.<resource>:<op>.tooltip(悬停说明),并添加projectRoles.type.<resource>(组标题)。
- 快照 — 更新
packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap,纳入新的<resource>:*条目。
无需数据库迁移——AuthRolesService.init() 会在每次启动时同步范围/角色。通过 UI 创建的自定义团队角色不会自动更新;请在 PR 描述中说明这一点。
公开 / 未经身份验证的路由
{ skipAuth: true } 会跳过身份验证中间件 → req.user 未定义 → 添加 @ProjectScope 会导致每次调用都返回 401。公开路由(第三方 Webhook、已签名的回调)必须:
- 省略作用域装饰器。
- 在处理程序中通过签名 / HMAC 验证进行身份验证(或使用其他特定于路由的机制)。
- 添加注释,说明为何不应用作用域,以免下一位审查者试图“修复”它。
示例:
// 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(...) { ... }
使用路由元数据测试进行验证
添加一个回归测试,确保未来新增路由但未设置作用域时测试会失败。通过 ControllerRegistryMetadata 遍历控制器上的每条路由,并断言其访问控制。
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);
},
);
});
纵深防御(仍然必需)
仅靠装饰器还不够,处理程序仍可能通过下游调用泄露数据。服务 / 仓库方法仍应按 projectId 进行过滤(或使用 findByUser 这类按用户作用域限定的辅助方法)。装饰器限制谁可以调用此 URL;服务限制他们可以读取什么。两者始终都要做到。
参考模式
- 项目作用域 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。 - 混合全局 + 项目作用域:
packages/cli/src/controllers/project.controller.ts。