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 SheetOWASP Access ControlCasbin RBACAuth0 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_mismatchnot_resource_ownerrole_missing_permissionunauthenticated 这些拒绝原因。客户端返回的 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,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

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