用 Claude Code 实现 RBAC:认证、授权、租户边界与审计日志
用 Claude Code 安全实现 RBAC:区分认证与授权,设计权限模型、Express 中间件、测试、租户边界和审计日志。
RBAC 不是给用户贴上 admin、editor、viewer 这些标签就结束了。真实事故往往来自更小的漏洞:把“已登录”当成“已授权”,某个查询忘了带 tenant_id,只在前端隐藏按钮,或者让编辑者用猜到的 ID 修改别人的对象。
本文把 RBAC 拆成适合交给 Claude Code 的任务粒度:认证与授权的区别、role / permission / resource / action、deny by default、租户边界、对象级授权、Express middleware、数据库 schema、测试、审计日志,以及什么时候应该从 RBAC 走向 ABAC。登录和 JWT 层可以参考 Claude Code JWT 认证指南,整体安全防护可以配合 Claude Code 安全最佳实践 阅读。
外部基准建议看 OWASP Authorization Cheat Sheet、OWASP Access Control、Casbin RBAC 和 Auth0 Core RBAC。OWASP 明确强调最小权限、默认拒绝、每个请求都验证权限、记录审计日志和编写授权测试。
先分清认证与授权
认证回答“这个人是谁”。密码、SSO、JWT、Session Cookie、多因素认证都属于认证。授权回答“这个人是否可以对这个资源执行这个操作”。用户已经登录,并不代表他可以删除发票、邀请管理员、读取其他客户的数据。
设计 RBAC 时,把四个词写清楚:
| 元素 | 示例 | 评审问题 |
|---|---|---|
| role | viewer, editor, billing_admin, owner | 是否按职责划分,而不是按头衔划分 |
| permission | article:update, invoice:read | 是否在代码中集中定义 |
| resource | article, invoice, user | 保护的对象是什么 |
| action | read, create, update, delete | 是否比 HTTP 方法更精确 |
最常见的坑是先做一个万能 admin,再不断添加例外。这样代码审查时很难判断某个例外是产品需求还是临时绕过。更稳定的方式是先定义 permission,再把 role 当作 permission 的集合。
flowchart TD
A["Authenticated actor"] --> B["Tenant boundary"]
B --> C["Role to permission map"]
C --> D["Resource and action check"]
D --> E["Object-level rule"]
E --> F["Allow"]
B --> X["Deny and audit"]
C --> X
D --> X
E --> X
用 deny by default 建立模型
deny by default 的意思是:没有明确允许的操作全部拒绝。让 Claude Code 修改授权代码时,先写拒绝规则比先写 admin 便利通道更安全。未知 permission、空 role、租户不一致、资源不存在、对象所有者不匹配,都应该失败关闭。
下面是可以复制运行的 Express 示例。示例为了测试方便从 header 读取身份信息,生产环境应替换为已经验证过的 JWT 或 session。
{
"name": "rbac-express-demo",
"type": "module",
"scripts": {
"dev": "tsx src/server.ts",
"test": "vitest run"
},
"dependencies": {
"express": "^5.1.0"
},
"devDependencies": {
"@types/express": "^5.0.3",
"@types/node": "^22.15.0",
"@types/supertest": "^6.0.3",
"supertest": "^7.1.1",
"tsx": "^4.19.4",
"typescript": "^5.8.3",
"vitest": "^3.1.4"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
TypeScript 中的 RBAC 核心
不要把 role 判断散落在 controller 里。把决策集中到 authorize,路由只传 permission 名称。这样 Claude Code 增加新资源时,改动范围和审查点都很清楚。
// src/rbac.ts
export const permissions = [
"project:read",
"article:read",
"article:create",
"article:update",
"article:delete",
"invoice:read",
"invoice:refund",
"user:manage"
] as const;
export type Permission = (typeof permissions)[number];
export type Role = "viewer" | "editor" | "billing_admin" | "owner";
export type Actor = {
id: string;
tenantId: string;
roles: Role[];
};
export type ResourceRecord = {
id: string;
tenantId: string;
ownerId?: string;
};
export type AuthorizationDecision = {
allow: boolean;
reason: string;
};
export const rolePermissions = {
viewer: ["project:read", "article:read", "invoice:read"],
editor: ["project:read", "article:read", "article:create", "article:update"],
billing_admin: ["project:read", "invoice:read", "invoice:refund"],
owner: [...permissions]
} as const satisfies Record<Role, readonly Permission[]>;
const knownPermissions = new Set<Permission>(permissions);
export function authorize(
actor: Actor,
permission: Permission,
record?: ResourceRecord
): AuthorizationDecision {
if (!knownPermissions.has(permission)) {
return { allow: false, reason: "unknown_permission" };
}
if (actor.roles.length === 0) {
return { allow: false, reason: "no_role" };
}
if (record && record.tenantId !== actor.tenantId) {
return { allow: false, reason: "tenant_mismatch" };
}
const roleAllows = actor.roles.some((role) =>
rolePermissions[role].includes(permission)
);
if (!roleAllows) {
return { allow: false, reason: "role_missing_permission" };
}
if (
permission === "article:update" &&
!actor.roles.includes("owner") &&
record?.ownerId !== actor.id
) {
return { allow: false, reason: "not_resource_owner" };
}
return { allow: true, reason: "allowed" };
}
export function auditAuthorization(input: {
actor?: Actor;
permission: Permission;
resourceId?: string;
decision: AuthorizationDecision;
}) {
console.info(
JSON.stringify({
type: "authorization",
actorId: input.actor?.id ?? "anonymous",
tenantId: input.actor?.tenantId ?? "unknown",
permission: input.permission,
resourceId: input.resourceId ?? null,
allow: input.decision.allow,
reason: input.decision.reason,
at: new Date().toISOString()
})
);
}
这里的重点是,API 检查的是 article:update,不是简单问“这个用户是不是 editor”。role 只是输入之一,后面仍要检查租户边界和对象所有者。
Express middleware 检查每个请求
授权应该在 handler 修改数据之前完成,而不是写在 controller 最后。middleware 能让每条受保护路由的授权点一眼可见。
// src/server.ts
import express, { type NextFunction, type Request, type Response } from "express";
import {
type Actor,
type Permission,
type ResourceRecord,
type Role,
auditAuthorization,
authorize,
rolePermissions
} from "./rbac.js";
declare global {
namespace Express {
interface Request {
actor?: Actor;
}
}
}
type Article = ResourceRecord & {
title: string;
body: string;
};
const articles: Article[] = [
{ id: "a1", tenantId: "tenant-a", ownerId: "user-1", title: "Roadmap", body: "Draft" },
{ id: "a2", tenantId: "tenant-a", ownerId: "user-2", title: "Release", body: "Ready" },
{ id: "b1", tenantId: "tenant-b", ownerId: "user-9", title: "Private", body: "Secret" }
];
function parseRoles(value: string | undefined): Role[] {
return (value ?? "")
.split(",")
.map((role) => role.trim())
.filter((role): role is Role => role in rolePermissions);
}
function authenticateForDemo(req: Request, _res: Response, next: NextFunction) {
const userId = req.header("x-user-id");
const tenantId = req.header("x-tenant-id");
if (userId && tenantId) {
req.actor = {
id: userId,
tenantId,
roles: parseRoles(req.header("x-roles"))
};
}
next();
}
function findArticle(req: Request): Article | undefined {
return articles.find((article) => article.id === req.params.articleId);
}
function requirePermission(
permission: Permission,
loadResource?: (req: Request) => ResourceRecord | undefined
) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.actor) {
return res.status(401).json({ error: "unauthenticated" });
}
const record = loadResource?.(req);
if (loadResource && !record) {
return res.status(404).json({ error: "not_found" });
}
const decision = authorize(req.actor, permission, record);
auditAuthorization({
actor: req.actor,
permission,
resourceId: record?.id,
decision
});
if (!decision.allow) {
return res.status(403).json({ error: "forbidden", reason: decision.reason });
}
return next();
};
}
export const app = express();
app.use(express.json());
app.use(authenticateForDemo);
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
app.get(
"/articles/:articleId",
requirePermission("article:read", findArticle),
(req, res) => {
res.json(findArticle(req));
}
);
app.patch(
"/articles/:articleId",
requirePermission("article:update", findArticle),
(req, res) => {
const article = findArticle(req);
if (!article) return res.status(404).json({ error: "not_found" });
article.title = String(req.body.title ?? article.title);
article.body = String(req.body.body ?? article.body);
return res.json(article);
}
);
app.delete(
"/articles/:articleId",
requirePermission("article:delete", findArticle),
(req, res) => {
const index = articles.findIndex((article) => article.id === req.params.articleId);
if (index >= 0) articles.splice(index, 1);
return res.status(204).send();
}
);
app.get("/admin/users", requirePermission("user:manage"), (_req, res) => {
res.json([{ id: "user-1" }, { id: "user-2" }]);
});
if (process.env.NODE_ENV !== "test") {
app.listen(3000, () => {
console.log("RBAC demo listening on http://localhost:3000");
});
}
生产环境中,即使身份提供商已经把 role 或 permission 放进 token,API 也不应直接信任它完成全部判断。服务器仍要重新检查租户、资源和对象级规则。
数据库 schema 要体现租户边界
很多授权漏洞发生在 middleware 之前:查询拿到了错误的行,handler 又默认它是安全的。把 tenant_id、复合唯一约束和审计日志放进 schema,可以让审查更直接。
CREATE TABLE tenants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE users (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
email TEXT NOT NULL,
UNIQUE (tenant_id, email)
);
CREATE TABLE roles (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
UNIQUE (tenant_id, name)
);
CREATE TABLE permissions (
id TEXT PRIMARY KEY,
resource TEXT NOT NULL,
action TEXT NOT NULL,
UNIQUE (resource, action)
);
CREATE TABLE user_roles (
user_id TEXT NOT NULL REFERENCES users(id),
role_id TEXT NOT NULL REFERENCES roles(id),
tenant_id TEXT NOT NULL REFERENCES tenants(id),
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE role_permissions (
role_id TEXT NOT NULL REFERENCES roles(id),
permission_id TEXT NOT NULL REFERENCES permissions(id),
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE articles (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
owner_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
body TEXT NOT NULL
);
CREATE TABLE authorization_audit_logs (
id TEXT PRIMARY KEY,
tenant_id TEXT,
actor_id TEXT,
permission TEXT NOT NULL,
resource_id TEXT,
allowed BOOLEAN NOT NULL,
reason TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_articles_tenant_owner ON articles(tenant_id, owner_id);
CREATE INDEX idx_audit_tenant_created ON authorization_audit_logs(tenant_id, created_at);
SaaS 产品通常需要租户自定义角色,所以 tenant_id + name 唯一比较自然。受强监管的系统则可能更适合全局固定 role,只允许租户内分配,因为审计更容易。
测试要覆盖拒绝路径
RBAC 测试不能只证明“允许的操作能成功”。更重要的是证明应该拒绝的请求确实失败:同 role 但不同租户、同租户但不同 owner、已登录但没有 permission、资源不存在、未登录。
// test/rbac.test.ts
import request from "supertest";
import { describe, expect, it } from "vitest";
import { app } from "../src/server.js";
const editorUser1 = {
"x-user-id": "user-1",
"x-tenant-id": "tenant-a",
"x-roles": "editor"
};
const editorTenantB = {
"x-user-id": "user-9",
"x-tenant-id": "tenant-b",
"x-roles": "editor"
};
const owner = {
"x-user-id": "owner-1",
"x-tenant-id": "tenant-a",
"x-roles": "owner"
};
describe("RBAC middleware", () => {
it("rejects unauthenticated requests", async () => {
const res = await request(app).get("/articles/a1");
expect(res.status).toBe(401);
});
it("allows an editor to update an owned article in the same tenant", async () => {
const res = await request(app)
.patch("/articles/a1")
.set(editorUser1)
.send({ title: "Updated roadmap" });
expect(res.status).toBe(200);
expect(res.body.title).toBe("Updated roadmap");
});
it("blocks cross-tenant access even when the role matches", async () => {
const res = await request(app).get("/articles/a1").set(editorTenantB);
expect(res.status).toBe(403);
expect(res.body.reason).toBe("tenant_mismatch");
});
it("blocks editors from managing users", async () => {
const res = await request(app).get("/admin/users").set(editorUser1);
expect(res.status).toBe(403);
expect(res.body.reason).toBe("role_missing_permission");
});
it("allows owners to delete tenant resources", async () => {
const res = await request(app).delete("/articles/a2").set(owner);
expect(res.status).toBe(204);
});
});
npm install
npm test
npm run dev
让 Claude Code 补测试时,要指定 tenant_mismatch、not_resource_owner、role_missing_permission、unauthenticated 这些拒绝原因。客户端返回的 reason 和审计日志中的 reason 应保持一致。
四个实际场景
第一个场景是 B2B 项目管理。owner 可以邀请成员和修改账单,editor 只能改项目内容。这里最重要的是租户边界,即使攻击者猜到 projectId,数据库查询和 middleware 都必须拒绝跨租户访问。
第二个场景是内部 CMS。编辑者可以修改自己的草稿,但删除已发布文章或修改他人文章需要更高权限。这就是在 RBAC 之上叠加对象级授权,因为 article:update 还要看 ownerId 和状态。
第三个场景是账单和退款。billing_admin 可以读取发票,但退款可能需要金额上限、审批状态或第二审批人。RBAC 负责入口,具体业务属性决定是否真的允许。
第四个场景是客服代操作。客服可以查看用户工作区排查问题,但不能改邮箱、支付方式或 owner role。所有代操作都应单独记录审计日志,并和用户本人的操作区分开。
失败例与修复
| 失败例 | 风险 | 修复 |
|---|---|---|
| 已登录就返回所有记录 | 横向越权 | 每次按 tenant_id 和资源 ID 过滤 |
| 只在 UI 隐藏按钮 | 直接调 API 仍然成功 | 在服务端 middleware 拒绝 |
一个巨大 admin 角色 | 权限审查失效 | 拆分 permission,重建 role |
| 只记录成功操作 | 探测行为不可见 | 允许和拒绝都记录 reason |
| 只测成功路径 | 回归时静默开洞 | 把拒绝矩阵自动化 |
前端按钮不是安全边界。隐藏删除按钮可以改善体验,但不能保护 API。真正的边界必须在服务端。
交给 Claude Code 的粒度
Claude Code 适合把清晰的授权策略转成代码、测试和可审查的 diff,但不应让它自行决定安全策略。可以这样写任务:
Help implement RBAC.
Only change src/rbac.ts, src/server.ts, and test/rbac.test.ts.
Requirements:
- An authenticated actor has userId, tenantId, and roles
- Permissions use the resource:action format
- Unknown permission, empty role list, and tenant mismatch deny by default
- article:update is allowed only for owner role or the article owner
- 403 responses include a reason, and audit logs use the same reason
- Add more denial tests than success tests
Do not:
- Add an admin bypass
- Treat UI visibility as authorization
- Modify the existing authentication flow
审查时看五点:authorize 是否是唯一决策点,每条受保护路由是否经过 middleware,每次对象查询是否带租户边界,审计日志是否避免输出 token 和敏感信息,测试是否覆盖拒绝路径。如果 Claude Code 生成很大的 diff,先把授权修改从无关重构中拆出来。
什么时候转向 ABAC
RBAC 适合表达职责,但当条件依赖属性时会变得笨重。例如大额退款需要双人审批、EU 个人数据只能从 EU 区域访问、企业版才能导出审计日志、工作时间外禁止某些操作。这些条件继续塞进 role 名称,会让 role 爆炸。
ABAC 是 Attribute-Based Access Control,也就是基于属性的访问控制。它会同时看 actor、resource、环境和请求属性。实践上可以让 RBAC 做粗粒度入口,再在需要业务条件的地方加入 ABAC 规则。即使用 Casbin 等策略引擎,也要先把 permission、租户边界、审计日志和测试写清楚。
总结
好的 RBAC 不是 role 越多越安全,而是拒绝规则足够清楚。区分认证与授权,用 resource:action 定义 permission,默认拒绝,检查租户边界,加入对象级规则,再用测试和审计日志验证。这样的结构也更适合 Claude Code,因为它能在明确边界内实现、补测试、生成可审查的差分。
如果要在完整 API 中落地,可以先用 Claude Code API 开发指南整理路由,再用 JWT 认证指南统一 actor 来源,最后叠加本文的 RBAC 层。需要咨询、培训或实现支援时,准备现有 role 表、受保护 API 列表、以及最怕暴露的危险操作,就能开始一次有效的权限评审。
实际试用本文方案时,请确认四件事:拒绝测试通过,跨租户 ID 返回 403,审计日志同时记录 allow 和 deny,Claude Code 没有新增绕过 authorize 的捷径。先选一个高风险操作,把 role、permission、resource、action、tenantId、ownerId 写成矩阵,再落到代码里,最容易发现缺口。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code Permission Receipt Pattern:记录权限、证据和回滚方式
Claude Code 权限 receipt:记录允许动作、需要批准的边界、验证命令、回滚说明,以及 Gumroad 和咨询 CTA 检查。
Claude Code/Codex 安全 Agent Harness 实战:权限、验证与回滚
用权限策略、执行计划、验证脚本和回滚日志,为 Claude Code 与 Codex 搭建更安全的 AI Agent 工作流。
Claude Code 子代理实战指南:安全委派并行文章与代码工作
用 Claude Code 子代理安全拆分文章和代码工作:委派规则、提示词模板、失败模式与检查清单。