Language: 日本語
「認可チェック漏れ」をレビューで探していませんか? TypeScript で型エラーにする認可カタログ設計
新しい API エンドポイントを追加したとき、**「この操作、ロール判定やテナントチェックをちゃんと通したっけ……?」** と不安になったことはありませんか?
新しい API エンドポイントを追加したとき、「この操作、ロール判定やテナントチェックをちゃんと通したっけ……?」 と不安になったことはありませんか?
controller や usecase のあちこちに、以下のような if 文が散らばっているコードベースをよく見かけます。
// 散らばる認可ロジック
if (user.role === "admin" || project.ownerId === user.id) {
// 処理を実行...
}
この形の何が一番危険かというと、「新しいエンドポイントを作ったとき、既存の認可ルールに合致しているかどうかを目視レビューと grep でしか確認できないこと」 です。
条件が増えるほど、「どのロールがどのリソースを触っていいのか」が人間の記憶に依存し、いつか必ず「認可の書き忘れ」というセキュリティインシデントを引き起こします。
この記事では、認可の条件を action × subject × check というマトリクスで一元管理し、「必要なコンテキストの渡し忘れ」を TypeScript のコンパイルエラー(TS2345)として即座に検知する型安全な認可カタログ設計 を紹介します。
得られること
- 散らばった認可ロジックを
action × subject × checkのマトリクスでデータ化する「認可カタログ」の作り方がわかる as const satisfiesとkeyof typeofを駆使し、値と型の一元管理(二重管理ゼロ)を実現する TypeScript テクニックが学べる- 引数やコンテキストの欠落を、実行時ではなくコンパイル時(TS2345)に確実に落とす設計パターンが身につく!
route には「意図」だけを宣言する
認可設計を見直すにあたって、まず 「route 側には何をしたいかという意図だけを書く」 ように分離しました。
// route 側は意図を宣言するだけ
@Authorize("project:archive")
async archiveProject(@Param("projectId") projectId: string) {
// ...
}
「対象のリソースが何で、誰がどうやって判定されるべきか」という具体的なルールは、すべて後述する 型付きの認可カタログ 側が一手に引き受けます。
| 要素 | 役割 | 具体例 |
|---|---|---|
| action | 何をしたいか(操作の識別子) | project:archive |
| subject | 何に対して行うか(対象リソースと ID) | project(projectId) |
| check | どう判定するか(必要な権限・能力) | archive_project capability |
route、リソース、判定方法をそれぞれ独立して型付けしたことで、「システム内にどんな認可ルールが存在するのか」を一覧として俯瞰できるようになり、差分レビューも容易になりました。
値から型を導出するカタログの定義
ここで絶対に避けたいのは、「認可アクションの enum や union 型を手動で二重管理すること」 です。 実行時に使う設定値と、TypeScript の型定義を別々に書いていると、必ずどちらか片方だけ更新される不整合が発生します。
そこで、TypeScript の as const satisfies を使って、「実行時のカタログ定義から型を自動導出する」 アプローチをとりました。
// 認可ルールの正本となるカタログ(値)
const ACCESS_SPECS = {
"project:archive": {
kind: "route",
subject: { kind: "project", param: "projectId" },
check: {
kind: "project-capability",
capability: "archive_project",
},
},
"task:delete": {
kind: "route",
subject: { kind: "task", param: "taskId" },
check: {
kind: "project-capability",
capability: "delete_task",
},
},
} as const satisfies AccessSpecRegistry;
// カタログの値から型を完全自動導出!
type AccessAction = keyof typeof ACCESS_SPECS;
type RouteAction = ActionOfKind<"route">;
TypeScript の恩恵
as const:"project:archive"や"route"といった文字列リテラル型をそのまま保持する。satisfies: カタログ全体の構造がAccessSpecRegistryインターフェースを満たしているかを静的検査しつつ、推論された厳密なリテラル型を壊さない。keyof typeof: カタログのキーからAccessActionの union 型を自動生成する。
値と型の正本が完全に同一なので、カタログに新しい action を 1 つ足すだけで、デコレータが受け付ける補完候補も、実行エンジンが扱う分岐も、型テストの対象もすべて自動で追従します。
コンテキストが欠落するとコンパイルエラーになる呼び出し契約
この型設計の真骨頂は、「認可に必要なコンテキスト(引数)を渡し忘れた瞬間にコンパイルエラー(TS2345)でビルドが止まる」 という点にあります。
たとえば、authorize 関数を実行する場合を見てみましょう。
// 正しい呼び出し(taskId が揃っている)
authorize(actor, "task:delete", {
type: "task",
taskId,
}, policy);
もし開発者がうっかり projectId だけを渡して、必須の taskId を渡し忘れたらどうなるでしょうか?
// 誤った呼び出し(taskId を渡し忘れた!)
authorize(actor, "task:delete", {
type: "task",
projectId,
}, policy);
コンパイラは即座に以下のエラーを吐いてビルドを落とします。
TS2345: Argument of type
'{ type: "task"; projectId: string; }'
is not assignable to parameter of type
'{ type: "task"; taskId: string; projectId?: string; }'.
Property 'taskId' is missing in type '{ type: "task"; projectId: string; }'.
task:delete という action を指定した時点で、第 3 引数(subject)には { taskId: string } が必須であると型が知っているため、コードを書いたその瞬間にエディタ上で赤波線が出ます。
ランタイムで undefined が渡って「なぜか全員認可が通ってしまう」「なぜか全員拒否される」といった恐ろしい事故を実行前に 100% 根絶できます。
型システムの守備範囲と人間の役割
もちろん、型システムがすべてを解決してくれるわけではありません。
| 領域 | 担当 | 理由 |
|---|---|---|
| 呼び出しの整合性 | TypeScript(コンパイラ) | action と subject の不一致、必要な ID の欠落、判定結果の使い回しを完全に防ぐ |
| ポリシー自体の正しさ | 人間(ドメイン設計) | 「archive_project をどのロールに付与すべきか」という業務判断は型では証明できない |
「誰に何の権限を与えるか」というドメインの判断は人間が責任を持って行います。 その代わり、「不整合な呼び出し」「コンテキストの渡し忘れ」といったケアレスミスはすべてコンパイラに押し付ける。この役割分担が極めて重要です。
認可レビューを目視からコンパイラへ移管する
認可の条件が増えていくと、「このエンドポイントで本当に必要なパラメータが渡されているか」をコードレビューで毎回追跡するのは不可能に近くなります。
条件を action × subject × check のカタログに集約し、引数の型をカタログから導出する。
この仕組みを一度整えてしまえば、「コンテキストの渡し忘れ」という最も危険なバグの検知をコンパイラに 100% 移管 できます。
レビューで人間が議論すべきなのは「この操作は誰に許可されるべきか(ドメインの妥当性)」だけであり、呼び出しの配線ミスで頭を悩ませる必要はもうありません。