Advanced (更新: 2026/6/1)

Claude CodeでRBACを実装する完全ガイド|認証・認可・テナント境界まで

Claude CodeでRBACを安全に設計する実践ガイド。認証と認可、権限モデル、Express実装、テスト、監査ログまで解説。

Claude CodeでRBACを実装する完全ガイド|認証・認可・テナント境界まで

RBACは「管理者、編集者、閲覧者」のような名前を付けるだけでは安全になりません。事故の多くは、ログイン済みユーザーをそのまま信用した、テナントIDをクエリに入れ忘れた、記事や請求書などの個別オブジェクトで所有者確認をしなかった、という小さな抜けから起きます。

この記事では、Claude Codeに任せやすい粒度まで分解して、認証と認可の違い、role / permission / resource / action、deny by default、テナント境界、オブジェクト単位の認可、middleware、DBスキーマ、テスト、監査ログ、ABACへ移る判断をまとめます。JWTやログイン基盤の前提はClaude Code JWT認証ガイドを、開発全体の守りはClaude Codeセキュリティ対策もあわせて確認してください。

外部の基準としては、OWASP Authorization Cheat SheetOWASP Access ControlCasbin RBACAuth0 Core RBACが参考になります。特にOWASPは、最小権限、deny by default、全リクエストでの認可確認、監査ログ、認可テストを明確に推奨しています。

認証と認可を分ける

認証は「あなたは誰か」を確認する処理です。パスワード、SSO、JWT、セッションCookie、多要素認証などがここに入ります。認可は「その人が、このリソースに、この操作をしてよいか」を判断する処理です。ログイン済みであることは、請求書の削除や他部署の顧客データ閲覧を許可する理由にはなりません。

RBACでは、判断材料を次の4つに分けると設計が崩れにくくなります。

要素設計時の注意
roleviewer, editor, billing_admin, owner肩書きではなく職務上の責任で分ける
permissionarticle:update, invoice:readコード上では文字列定数として固定する
resourcearticle, invoice, user何に対する操作かを明示する
actionread, create, update, deleteHTTPメソッドだけで判断しない

よくある失敗は、adminという万能ロールを先に作り、あとから例外を足し続けることです。例外が増えるとレビューで追えなくなり、やがて「管理者なら何でもできる」が仕様なのか一時対応なのか分からなくなります。最初にpermissionを細かく定義し、roleはpermissionの束として扱う方が、Claude Codeにもテストにも説明しやすくなります。

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は「明示的に許可されていない操作はすべて拒否する」という考え方です。RBACの実装で最初に固定すべきルールは、便利な管理者ショートカットではなく、この拒否ルールです。Claude Codeに実装を頼む場合も、「未知のpermission、空のrole、テナント不一致、リソース未取得は拒否」と先に書いておくと抜けが減ります。

以下はExpressでそのまま使える最小構成です。デモではヘッダーからユーザー情報を受け取っていますが、本番ではJWTやセッション検証済みの結果に置き換えてください。

{
  "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を直接if文に散らすと、どのAPIがどの権限に依存しているか追えなくなります。中核はauthorize関数に集約し、ルートやmiddlewareはpermission名を渡すだけにします。これならClaude Codeに「新しいresourceを追加して、テストを増やして」と頼んだときも、変更範囲が小さくなります。

// 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()
    })
  );
}

このコードで大事なのは、ロール名ではなくpermissionをAPIに渡す点です。editorだから許可、ではなく、article:updateという操作が許可されているかを調べます。さらに、同じarticle:updateでも、編集者は自分が所有する記事だけ更新できるように、オブジェクト単位の認可を重ねています。

Express middlewareで全リクエストを検査する

認可チェックはコントローラーの最後に書くのではなく、ルートに入る前のmiddlewareで実行します。OWASPが強調する通り、認可は「たまに確認する」ものではなく、保護対象の全リクエストで確認するものです。

// 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");
  });
}

実運用ではauthenticateForDemoを、JWT検証、セッション検証、またはIdP連携の結果に置き換えます。Auth0のようなIdPを使う場合も、API側でpermission claimを受け取るだけで終わらせず、テナント境界とオブジェクト単位の認可はサーバー側で再確認してください。

DBスキーマはテナント境界を前提にする

RBACをアプリコードだけで実装すると、データ取得時にテナント条件を入れ忘れる事故が起きます。DBスキーマにもtenant_id、複合ユニーク制約、監査ログを持たせると、レビュー時に見るべき場所が明確になります。

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);

roles.nameを全社でユニークにするか、テナントごとにユニークにするかはサービスの性質で変わります。SaaSならテナントごとのカスタムロールが必要になることが多いので、tenant_id + nameで一意にする設計が扱いやすいです。逆に金融や医療のように中央統制が強い領域では、ロール定義をグローバル固定にしてテナント側では割り当てだけ許可する方が監査しやすくなります。

テストで横方向の権限昇格を潰す

RBACのテストは「許可されるケース」より「拒否されるケース」を厚くします。特に、同じroleだが別テナント、同じテナントだが別所有者、ログイン済みだがpermission不足、存在しないresource、の4つは必ず入れます。

// 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のように、失敗理由が監査ログと一致するかまで見ます。問い合わせや研修で実装支援をする場合も、最初にこの失敗系テストの有無を確認します。

4つのユースケースで粒度を決める

1つ目は、BtoB SaaSのプロジェクト管理です。ownerはメンバー招待と請求設定を扱えますが、editorは記事やタスクを編集できるだけにします。ここではテナント境界が最重要です。URLのprojectIdを推測されても、DBクエリとmiddlewareの両方でtenant_idが一致しなければ拒否します。

2つ目は、社内CMSです。編集者は自分の記事を更新できますが、公開済み記事の削除や他人の記事の編集は編集長だけにします。これは単純なRBACだけでは足りず、ownerIdstatusを見るオブジェクト単位の認可が必要です。Claude Codeには「記事の所有者、公開状態、ロールを組み合わせたテストを追加して」と依頼すると効果的です。

3つ目は、請求・返金管理です。billing_adminは請求書を読めますが、返金は金額上限や二重承認が絡みます。ここでroleだけで全額返金を許すと危険です。RBACで入口を絞り、金額、契約プラン、承認状態などの属性を追加で見る設計にします。

4つ目として、カスタマーサポートの代理ログインがあります。サポート担当者はユーザー画面を閲覧できても、メールアドレス変更や決済情報操作はできないようにします。さらに、代理操作は必ず監査ログに残し、ユーザー本人の操作と区別できるようにします。

失敗例と落とし穴

失敗例何が危険か修正方針
ログイン済みなら全記事を返す横方向の権限昇格が起きるtenant_idresourceIdで毎回絞る
UIだけでボタンを隠すAPIを直接叩かれると通るサーバー側middlewareで拒否する
adminに例外を積みすぎる権限レビューが不能になるpermissionを分解しroleを再設計する
監査ログに成功だけ残す攻撃の兆候を見逃す拒否もreason付きで記録する
テストが成功系だけ仕様変更で穴が開くdenyケースを表にして自動化する

特に危ないのは、フロントエンドの表示制御を認可と勘違いすることです。ボタンを非表示にしても、APIがarticle:deleteを確認していなければ削除は実行できます。UIの制御は体験改善であり、セキュリティ境界ではありません。

Claude Codeに任せる作業粒度

Claude Codeに丸投げしてよいのは、方針決定ではなく、方針をコードとテストへ展開する部分です。たとえば次のように依頼すると、成果物をレビューしやすくなります。

RBACの実装を手伝ってください。
変更対象は src/rbac.ts、src/server.ts、test/rbac.test.ts のみです。

要件:
- 認証済みactorは userId、tenantId、roles を持つ
- permissionは resource:action 形式
- 未知のpermission、空role、テナント不一致はdeny by default
- article:updateはownerまたは記事所有者だけ許可
- 403時はreasonを返し、監査ログにも同じreasonを出す
- 成功系より失敗系テストを厚くする

やらないこと:
- adminバイパスを作らない
- UIの表示制御だけで認可済みと判断しない
- 既存の認証処理を変更しない

レビュー観点は、authorizeが唯一の判断点になっているか、DBクエリにテナント条件が入っているか、middlewareを通らないルートがないか、監査ログに個人情報やトークンを出していないか、テストが拒否ケースを十分に覆っているか、の5つです。Claude Codeが大きな差分を出した場合は、まず認可境界のファイルだけに分けてレビューすると事故を見つけやすくなります。

ABACへ移る境界

RBACは「職務に対する権限」を表すのに向いています。一方で、属性が増えるとrole名が爆発します。たとえば「平日9時から18時だけ返金可能」「10万円以上は二重承認」「EUテナントの個人情報はEUリージョンからのみ閲覧」「契約プランがEnterpriseのときだけ監査ログをエクスポート可能」のような条件は、ABACの領域です。

ABACはAttribute-Based Access Controlの略で、ユーザー、リソース、環境、リクエストの属性を見て許可を判断する方式です。日本語では「属性ベースのアクセス制御」と考えると分かりやすいです。RBACで入口を絞り、属性条件が増えたらABACやポリシーエンジンを検討します。Casbinのようなライブラリを使う場合も、最初にpermission名、テナント境界、監査ログ、テストケースを明確にしてから導入した方が、ブラックボックス化を避けられます。

まとめ

RBAC実装の品質は、ロール名の数ではなく、拒否条件の明確さで決まります。認証と認可を分け、permissionをresource:actionで定義し、deny by defaultを中核に置き、テナント境界とオブジェクト単位の認可をmiddlewareで必ず通します。DBスキーマ、監査ログ、失敗系テストまでそろえると、Claude Codeにも安全な作業粒度で依頼できます。

Claude Codeで認可まわりを実装するなら、API開発ガイドでルート設計を固め、JWT認証ガイドでactorの作り方をそろえ、その上でこの記事のRBAC層を重ねるのが現実的です。既存サービスの権限表レビュー、RBACテスト設計、Claude Codeを使った実装研修が必要な場合は、現在のロール一覧、API一覧、事故が怖い操作を整理した上で相談すると、初回レビューの精度が上がります。

この記事で紹介した内容を実際に試すときの確認ポイントは、npm testで拒否ケースが通ること、別テナントのIDをURLに入れても403になること、監査ログにallowとdenyの両方が残ること、そしてClaude Codeの差分に認可を迂回する例外が増えていないことです。手元の実装では、まず1つの危険な操作を選び、role、permission、resource、action、tenantId、ownerIdを紙に書き出してからコードへ落とすと、抜け漏れが見つけやすくなります。

#Claude Code #RBAC #認可 #セキュリティ #設計パターン
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。