Advanced (更新: 2026/6/1)

用 Claude Code 实现 RBAC:认证、授权、租户边界与审计日志

用 Claude Code 安全实现 RBAC:区分认证与授权,设计权限模型、Express 中间件、测试、租户边界和审计日志。

用 Claude Code 实现 RBAC:认证、授权、租户边界与审计日志

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 时,把四个词写清楚:

元素示例评审问题
roleviewer, editor, billing_admin, owner是否按职责划分,而不是按头衔划分
permissionarticle:update, invoice:read是否在代码中集中定义
resourcearticle, invoice, user保护的对象是什么
actionread, 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 写成矩阵,再落到代码里,最容易发现缺口。

#Claude Code #RBAC #authorization #security #design patterns
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

让 Claude Code 真正进入可验证的工作流

先用免费 PDF 固定基础,再用 Gumroad 教材复用工作流;如果涉及团队导入、权限或收入路径,可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。