Blogへ戻る

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 satisfieskeyof 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 の恩恵

  1. as const: "project:archive""route" といった文字列リテラル型をそのまま保持する。
  2. satisfies: カタログ全体の構造が AccessSpecRegistry インターフェースを満たしているかを静的検査しつつ、推論された厳密なリテラル型を壊さない。
  3. 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% 移管 できます。

レビューで人間が議論すべきなのは「この操作は誰に許可されるべきか(ドメインの妥当性)」だけであり、呼び出しの配線ミスで頭を悩ませる必要はもうありません。

参考リンク