RBAC実装で横移動を防ぐ:ロール設計と認可ミドルウェアの作り方
ロール名を付けただけでは権限事故は防げません。permissionの設計、サーバー側ミドルウェアでの認可チェック、最小権限、UIの出し分け、ABACとの違いを、コピペで動くコードと失敗談つきで整理しました。
「ログインしてる人なら、まあ大丈夫でしょ」
僕が初めて作った管理画面は、この一行の油断で穴だらけでした。ある日、別の会社のユーザーが、URLの末尾の数字を1つ変えるだけで、よその請求書を開けてしまった。コードはちゃんと動いていた。ログイン認証も通っていた。なのに、です。
問題は「誰か」を確かめていなかったことじゃありません。「誰か」は分かっていたのに、「その人が、それを見ていいか」を一度もチェックしていなかった。これが認可、つまりRBAC(ロールベースアクセス制御)の話です。
ロールに「管理者・編集者・閲覧者」と名前を付けるのは、認可のスタート地点ですらありません。事故はいつも、もっと地味なところで起きます。テナントIDをクエリに入れ忘れた。記事の所有者を確認しなかった。UIでボタンを隠しただけで満足した。今日はこの「何をしてよいか」を、コピペで動く形まで落として整理します。
「あなたは誰か」を確かめるログインのほう(認証)は、Web認証の実装ガイドに分けてあります。この記事は徹頭徹尾、**ログイン後の「で、何をさせる?」**に絞ります。
この記事の要点
- 認証(誰か)と認可(何をしてよいか)は別物。ログイン済みは、削除や他社データ閲覧の許可理由にならない。
- RBACの核は role ではなく permission(
article:updateのような操作名)。ルートには permission を渡し、判断は1か所のauthorizeに集約する。 - 原則は deny by default(明示的に許可されていない操作は全部拒否)。テナント不一致・所有者違い・権限不足は、理由つきで弾く。
- 認可チェックはコントローラーの最後ではなく、サーバー側ミドルウェアで全リクエストに通す。UIのボタン制御はセキュリティ境界ではない。
- 「平日のみ」「10万円以上は二重承認」のような条件が増えたら、RBACで入口を絞りつつ ABAC(属性ベース)へ寄せる。
認証と認可は、玄関の鍵と部屋の鍵
たとえ話をします。認証はマンションの玄関(エントランス)の鍵です。建物に入っていい人かを確かめる。認可は各部屋の鍵。建物に入れたからといって、全部の部屋を開けていいわけじゃない。
僕の事故は、エントランスは厳重なのに、中の部屋が全部オートロックなしだった、という状態でした。ログイン(玄関)は通る。でも、請求書という「他人の部屋」のドアノブを回したら、そのまま開いてしまった。
RBACで認可を設計するとき、判断材料を次の4つに分けておくと、後で崩れにくくなります。
| 要素 | 例 | 設計時の注意 |
|---|---|---|
| role(役割) | viewer, editor, billing_admin, owner | 肩書きではなく、職務上の責任で分ける |
| permission(許可された操作) | article:update, invoice:read | コード上は文字列定数で固定する |
| resource(対象) | article, invoice, user | 何に対する操作かを明示する |
| action(行為) | read, create, update, delete | HTTPメソッドだけで判断しない |
ここで一番やりがちな失敗が、admin という万能ロールを先に作ってしまうこと。最初は楽です。でも「管理者なら何でもできる」に例外を足し続けると、半年後には誰も全体像を説明できなくなります。あれは仕様なのか、緊急対応の名残なのか。レビューで追えなくなった時点で、その権限表はもう壊れています。
順番を逆にしてください。先に permission を細かく定義し、role はその束として組み立てる。「editor とは article:read と article:create と article:update を持つ人」と定義する。こうしておくと、後で「この API はどの権限に依存している?」が一目で追えます。
deny by default を、設計の土台に置く
deny by default は「明示的に許可していない操作は、全部拒否」という考え方です。OWASPもこれを認可設計の基本に挙げています(OWASP Authorization Cheat Sheet)。
なぜ「拒否」を土台にするのか。許可リスト方式だと、新しい機能を足したとき「明示的に許可を書かない限り、自動で拒否される」からです。逆に「禁止リスト」方式だと、新機能を足すたびに「これも禁止、あれも禁止」と書き漏らした穴が事故になる。守りの初期値は、いつも「閉じている」ほうが安全です。
具体的には、認可関数の入口で次を全部 deny にします。
- 未知の permission(タイプミスや存在しない権限名)
- role が空(ログインはしたが、何の役割も割り当てられていない)
- テナント不一致(自社のIDで、他社のリソースに触ろうとした)
- リソースを取得できない(存在しないIDを指定された)
このルールを先に固めてから、最後に「許可」を返す。順番が大事です。許可条件から書き始めると、人は必ずどこかで拒否条件を書き忘れます。
permission を中核に、authorize 1か所で判断する
ここからコードです。まずは判断ロジックを authorize という1つの関数に集約します。role を直接 if 文に散らすと、どの API がどの権限に依存しているか、すぐ追えなくなる。だからルートは permission 名を渡すだけにして、判断は全部ここへ寄せます。
TypeScript で書くとこうなります。日本語コメントは僕が後から自分用に足したものです。
// 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";
// 認証済みの「行為者」。誰か(id)、どのテナントか、どの役割か。
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;
};
// role を permission の束として定義する。ここが設計の心臓部。
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 {
// --- ここから下が deny by default の門番たち ---
// 1. 知らない権限名は即拒否(タイプミス対策にもなる)
if (!knownPermissions.has(permission)) {
return { allow: false, reason: "unknown_permission" };
}
// 2. 役割が空なら拒否
if (actor.roles.length === 0) {
return { allow: false, reason: "no_role" };
}
// 3. テナント不一致は拒否(他社データへの横移動を止める)
if (record && record.tenantId !== actor.tenantId) {
return { allow: false, reason: "tenant_mismatch" };
}
// 4. どの役割もこの権限を持っていなければ拒否
const roleAllows = actor.roles.some((role) =>
rolePermissions[role].includes(permission)
);
if (!roleAllows) {
return { allow: false, reason: "role_missing_permission" };
}
// 5. オブジェクト単位の認可。
// 記事の更新は owner か、その記事の所有者だけ。
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()
})
);
}
このコードのキモは3つです。ひとつ、役割名ではなく permission を見る。「editor だから許可」ではなく「article:update という操作が許可されているか」を調べる。ふたつ、テナント境界をここで弾く。僕が事故った「URLの数字を変えたら他社の請求書」は、3番目の門番が止めます。みっつ、同じ article:update でも、所有者かどうかをもう一段見る(オブジェクト単位の認可)。役割が同じでも、他人の記事は触らせない。
サーバー側ミドルウェアで、全リクエストを検査する
authorize を作っただけでは、まだ事故は防げません。呼び忘れたら終わりだからです。だから認可チェックは、コントローラーの中にこっそり書くのではなく、ルートに入る前のミドルウェアで必ず通します。OWASPが言う「認可は、たまに確認するものではなく、保護対象の全リクエストで確認するもの」というのは、つまりこういうことです。
Express でそのまま動く形です。
// 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;
};
// デモ用のインメモリデータ。tenant-a と tenant-b が混在している。
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);
}
// デモ用の認証。本番ではここを JWT 検証やセッション検証に差し替える。
// ※「誰か」を確かめる処理。詳細は認証ガイドの記事へ。
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);
}
// permission を渡すだけで、認可・404・監査ログをまとめて処理するミドルウェア。
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 });
});
// ルート定義では permission 名を渡すだけ。判断は authorize に任せる。
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 検証やセッション検証の結果に差し替えます。ただし、Auth0 のような外部のIdP(ID基盤)から permission を受け取る場合でも、テナント境界とオブジェクト単位の認可は、必ずサーバー側でもう一度確かめてください。外から渡された claim を鵜呑みにするのは、玄関の鍵を相手に預けるようなものです。
ルート設計そのものに不安があるなら、Claude CodeでのAPI開発手順で土台を固めてから、この認可層を重ねると無理がありません。
UIの出し分けは「親切」、認可は「壁」
ここで強調したいのが、フロントエンドの表示制御を認可と勘違いしないことです。これは僕も一度やりました。「削除ボタンを管理者にだけ表示」して、安心していた。
でもボタンを隠しても、API が article:delete を確認していなければ、削除リクエストは普通に通ります。ブラウザの開発者ツールを開けば、誰でも直接 API を叩けるからです。
| UIの出し分け | サーバー側の認可 | |
|---|---|---|
| 役割 | 体験をよくする(混乱させない) | セキュリティ境界を守る |
| 破られると | 見えなくていいボタンが見える程度 | データが漏れる・壊れる |
| どこに置く | フロント | ミドルウェア(必須) |
| 省略していいか | 省いても事故にはならない | 省いたら即事故 |
正しい順番はこうです。まずサーバー側でガチガチに認可を固める。その上で、できないことを最初から見せない親切として、UIを出し分ける。逆にしてはいけません。UIの制御は、おまけです。本丸はサーバー側にあります。
権限のないユーザーに対しては、ボタンを消すか、押した瞬間に「権限がありません」と出す。どちらでも構いません。大事なのは、その裏で API が独立して 403 を返せること。フロントの状態に依存しない、独立した壁になっていることです。
拒否ケースを厚くテストする
RBACのテストは、「許可されるケース」より「拒否されるケース」を厚く書きます。許可が通るのは、正直あまり心配いりません。怖いのは、通っちゃいけないものが通るほうです。
最低でも次の4パターンは必ず入れます。同じ role だが別テナント、同じテナントだが別の所有者、ログイン済みだが権限不足、存在しないリソース。これが「横移動」(権限の横方向の昇格)を潰す要です。
// 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("未ログインは 401 で弾く", async () => {
const res = await request(app).get("/articles/a1");
expect(res.status).toBe(401);
});
it("同テナントの自分の記事は更新できる", 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("役割が同じでも、別テナントの記事は 403", async () => {
const res = await request(app).get("/articles/a1").set(editorTenantB);
expect(res.status).toBe(403);
expect(res.body.reason).toBe("tenant_mismatch");
});
it("editor はユーザー管理を触れない", 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("owner はテナント内のリソースを削除できる", async () => {
const res = await request(app).delete("/articles/a2").set(owner);
expect(res.status).toBe(204);
});
});
ローカルで動かす手順はこれだけです。
npm install
npm test
npm run dev
テストで僕が必ず見るのは、失敗理由(reason)が監査ログと一致しているかです。tenant_mismatch で弾いたなら、ログにも tenant_mismatch が残る。これがズレていると、後で「なぜ拒否されたのか」を追えなくなります。403 を返すだけでなく、なぜ拒否したのかをセットで残す。これが地味に効きます。
現場ごとに、権限の粒度を決める
RBACの設計に唯一の正解はありません。サービスの性質で、ちょうどいい粒度が変わります。僕が実際に迷った4つの例を挙げます。
1. BtoB SaaSのプロジェクト管理。 ここはテナント境界が最重要です。owner はメンバー招待と請求設定まで、editor はタスクや記事の編集まで。URLの projectId を推測されても、DBクエリとミドルウェアの両方で tenant_id が一致しなければ拒否する。僕の事故はまさにこれを怠った結果でした。
2. 社内CMS。 編集者は自分の記事を更新できるが、公開済み記事の削除や他人の記事の編集は編集長だけ。これは単純なロールだけでは足りず、ownerId と status(公開状態)を見るオブジェクト単位の認可が要ります。
3. 請求・返金管理。 billing_admin は請求書を読めるが、返金は別問題です。金額の上限や二重承認が絡む。「役割が billing_admin だから全額返金OK」は危険すぎる。RBACで入口を絞り、金額や承認状態は追加で見る設計にします。
4. カスタマーサポートの代理ログイン。 サポート担当はユーザー画面を閲覧できても、メールアドレス変更や決済情報の操作はできないようにする。さらに代理操作は必ず監査ログに残し、本人の操作と区別できるようにします。これを怠ると、後で「誰がやったのか」が永遠に分からなくなります。
RBACで足りなくなったら、ABACへ寄せる
最後に、RBACの限界の話を。RBACは「職務に対する権限」を表すのが得意です。でも、条件が増えると role名が爆発します。
たとえば「平日9時〜18時だけ返金可能」「10万円以上は二重承認」「EUテナントの個人情報はEUリージョンからのみ閲覧」。これを全部 role で表そうとすると、billing_admin_weekday_under_100k みたいな悪夢のような役割名が量産されます。
ここが ABAC(属性ベースのアクセス制御)の出番です。ABACは Attribute-Based Access Control の略で、ユーザー・リソース・環境・リクエストの属性を見て許可を判断する方式。「時刻」「金額」「リージョン」といった、role では表しきれない条件を扱えます。
| RBAC | ABAC | |
|---|---|---|
| 判断材料 | 役割 | 役割+各種の属性(時刻・金額・地域など) |
| 得意なこと | 職務に応じた権限 | 細かい条件の組み合わせ |
| 弱点 | 条件が増えると role が爆発 | 設計とデバッグが複雑になりがち |
| おすすめ | まずはここから | 条件が増えてきたら寄せる |
実務での進め方は「RBACで入口を絞り、属性条件が増えてきたらABACを足す」です。いきなり全部ABACにすると、今度は誰も認可ロジックを追えなくなる。Casbin のようなポリシーエンジンを使う場合も、先に permission名・テナント境界・監査ログ・テストケースを固めてから入れたほうが、ブラックボックス化を避けられます。認可まわりを含めた全体の守りは、Claude Codeの安全対策もあわせて確認してください。
よくある質問
Q. RBACとABAC、最初はどっちで作るべき? A. まずRBACです。ほとんどのサービスは、role と permission をきちんと分けるだけで十分守れます。「平日のみ」「金額上限」のような属性条件が実際に出てきてから、その部分だけABACを足してください。最初から全部ABACにすると、認可ロジックが複雑になりすぎて、かえって穴を見落とします。
Q. 認可チェックはコントローラーに書いてはダメ? A. 書いてもいいですが、それだけに頼るのは危険です。コントローラーの中だと「呼び忘れ」が起きます。ルートに入る前のミドルウェアで必ず通す形にして、コントローラー側のチェックは念のための二重化、という位置づけがおすすめです。
Q. UIでボタンを隠せば、認可になりますか? A. なりません。表示制御は体験をよくするための「親切」で、セキュリティ境界ではありません。ボタンを隠しても、ブラウザの開発者ツールから API を直接叩けば操作は通ります。サーバー側で必ず 403 を返せる状態を作った上で、UIはおまけとして出し分けてください。
Q. 監査ログには、成功した認可も残すべき? A. 残すべきです。拒否(deny)だけ記録すると、「正規の権限を悪用された」ケースを後から追えません。許可も拒否も、誰が・いつ・何を・どんな理由で、を両方残してください。ただしトークンやパスワードなどの機密情報はログに出さないこと。
Q. Claude Codeに認可の実装を任せて大丈夫?
A. 方針を自分で決めた上でなら有効です。permission名、テナント境界、deny by default の条件、拒否系テストを先に文章で固めてから「この範囲だけ実装して」と頼むと、レビューしやすい差分が返ってきます。逆に「いい感じに認可作って」と丸投げすると、admin バイパスのような抜け道を平気で足してくるので注意です。
実際に試した結果
冒頭の「URLの数字を変えたら他社の請求書」事故のあと、僕は認可の考え方を根っこから変えました。「このユーザーを信じるか」で悩むのをやめて、代わりに**「どの門番で止まったか」**を見るようにしたんです。
authorize に deny by default の門番を5つ並べて、テナント不一致を3番目で弾くようにしたら、横移動の事故はゼロになりました。認可チェックをコントローラーからミドルウェアに引き上げたら、「呼び忘れ」が構造的に起きなくなった。そして拒否ケースのテストを成功ケースより厚く書いたら、仕様変更のたびに穴が開く、あの不安がなくなりました。
賢いロール名をひねり出すより、転んでも被害が広がらない壁を先に作る。RBACは「役割をいくつ作るか」のゲームじゃなくて、「どこで・なぜ拒否するか」を設計するゲームです。手元で試すなら、まず一番怖い操作を1つ選んで、role・permission・resource・action・tenantId・ownerId を紙に書き出すところから。コードに落とす前に、抜けが見えてきます。
権限表のレビューやRBACテストの設計を、もう少し体系立てて学びたい人は、教材一覧に実装ベースの資料をまとめてあります。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeのチーム利用でコストが読めない時に作る予算ログ
チーム導入前に、誰が何に使い、どの成果が出たかを見える化する予算ログの作り方。
コミット前の3分チェック: Claude Codeが触った範囲を確認してから確定する
Claude Codeが勝手に広げた変更を、コミット前に3分で見抜く確認手順。差分の範囲、検証ログ、ステージするファイルの絞り込みを順番に解説します。
Claude Codeをチーム導入する前に作る「リスク台帳」の中身
Claude Codeを個人実験で終わらせずチーム導入するための、権限・CI・公開の事故を防ぐリスク台帳の作り方を実例とコードで解説します。