Language: 日本語
「認可チェックしたはず」がバグを生む。認可結果を boolean ではなく Branded Types で縛る TypeScript 設計
認可関数を実装するとき、戻り値に `boolean` を返していませんか?
認可関数を実装するとき、戻り値に boolean を返していませんか?
// よくある認可チェック
const isAllowed: boolean = await canAccessUser(actor, userId);
if (!isAllowed) {
throw new ForbiddenException();
}
// その後、更新処理を呼ぶ...
await userService.updateUser(userId, dto);
一見すると何の変哲もない、どこにでもあるコードに見えますよね。
しかし、この設計には 「認可の証拠が次の層へ一切引き継がれない」 という致命的な落とし穴があります。
canAccessUser が true を返した瞬間、その true からは 「誰が・何に対して・どの操作(閲覧なのか更新なのか削除なのか)を認可されたのか」 という文脈が完全に消滅します。
もし開発者(あるいは AI エージェント)が、「user:view(閲覧権限)をチェックした直後に、間違えて updateUser を呼んでしまった」としても、TypeScript コンパイラは何も警告してくれません。userId はどちらもただの string だからです。
この記事では、認可結果を boolean で終わらせず、Authorized<Action, Value> という Branded Types(幽霊型)で「認可された証拠」を値そのものに固定し、未認可の呼び出しをコンパイルレベルで 100% 根絶する型設計 を解説します。
得られること
booleanによる認可チェックが引き起こす「判定と実行の不整合」の仕組みがわかるAuthorized<Action, Value>という Branded Types を使って、認可の証拠を値に閉じ込めるパターンが学べる- Service 層のシグネチャを型安全にし、未認可データの混入をコンパイル時に確実に弾く設計が身につく!
型だけを見ても「認可済みか」がわからない問題
まず、従来の Service 層のシグネチャを見てみましょう。
// Before: 引数に何でも渡せてしまう危険な Service
async updateUser(userId: UserId, dto: UpdateUserDto): Promise<void> {
// ...
}
ここに定義されているのは、ただの UserId(実体は string)です。
この関数を見ただけでは、以下の重要な事実がまったく読み取れません。
- この
userIdは、呼び出し元で本当に認可チェックを通した値なのか? - どの action(閲覧なのか、更新なのか、削除なのか)で認可されたのか?
- そもそも、この Service に渡してよい正規の値なのか?
すべては「呼び出し側がちゃんとチェックしてくれているはずだ」という 人間の記憶と善意(あるいはコードレビューの目視)に完全に依存した状態 です。
認可したという事実(証拠)を、型として安全に次の層へ運ぶことはできないでしょうか?
認可結果を型として運ぶ 3 つの道具
この課題を解決するために、以下の 3 つの TypeScript テクニックを組み合わせます。
1. spec の kind ➔ 認可結果の形状を決める判別子(discriminant)
2. conditional type ➔ action から戻り値の型(AccessResult)を自動導出
3. branded type ➔ 認可された証拠を値に固定(Authorized<Action, Value>)
※ アクションのカタログ定義(ACCESS_SPECS)は、前回の記事で作成した型安全なカタログをそのまま使用します。
1. spec の kind による分類
認可ルール(spec)ごとに kind を持たせます。
self: 本人または特定リソースに対する操作(単一のUserId等が返る)scope: 複数テナントやプロジェクトにまたがる操作(アクセス可能なScopeが返る)public-proof: 署名や公開トークンの検証結果が返る
2. AccessResult の導出
action に応じて、返るべき Result 型を conditional type で 1:1 にバインドします。
type AccessResult<A extends AccessAction> =
SpecOf<A> extends { kind: "self" }
? SelfAccessResult<A>
: SpecOf<A> extends { kind: "scope" }
? ScopeAccessResult<A>
: SpecOf<A> extends { kind: "public-proof" }
? PublicProofAccessResult<A>
: never;
これで、「user:manage なのに Scope が返る」といった action と result の食い違いは型レベルで完全に排除されます。
3. Authorized<Action, Value> による証拠のバインド
Result から UserId を取り出した瞬間に、ただの string(raw value)に戻ってしまうと、せっかくの認可の証拠が消えてしまいます。
そこで、以下のような Branded Types(ブランド型) を使います。
// 認可された証拠を幽霊型として焼き付ける
export type Authorized<A extends AccessAction, T> = T & {
readonly __brand: unique symbol;
readonly __action: A;
};
これで、値そのものに「user:update というアクションで認可された証拠」が固定されます。
Service 層のシグネチャを型安全に変える
この Branded Types を導入すると、Service 層のシグネチャは以下のように進化します。
| 比較項目 | Before(従来) | After(Branded Types) |
|---|---|---|
| 引数の型 | UserId(素の文字列) | Authorized<"user:update", UserId> |
| 未認可値の受け入れ | 誤って渡しても素通り 💥 | コンパイルエラー(TS2345)で拒絶 ⚡️ |
| 認可アクションの制約 | なし(閲覧権限の ID も渡せる) | "user:update" で認可された ID のみ許可 |
// After: 認可済みの証拠を持つ値しか受け付けない堅牢な Service
async updateUser(
userId: Authorized<"user:update", UserId>,
dto: UpdateUserDto
): Promise<void> {
// この中に入った時点で、認可チェック済みであることが型レベルで 100% 保証されている!
}
未認可の生 UserId や、誤って "user:view" で認可した UserId を渡そうとすると、コンパイル時に即座に弾かれます。
証拠(Brand)の偽造を防ぐルール
Branded Types を使う上で 1 つだけ重要な注意点があります。それは 「証拠の偽造やコピーを防ぐこと」 です。
たとえば、以下のようにスプレッド構文で中身を書き換えたオブジェクトを作ったとします。
// 危険な操作: 中身を書き換えてもブランドが残ってしまうリスク
const tampered = { ...authorizedScope, actorId: "attacker" };
認可の証拠だけが残ったまま中身が改ざんされては困ります。 そのため、「Brand を付与(mint)できるのは認可ガード(Guard / Policy)の境界内だけに限定する」 という厳格なルールを徹底します。
【 認可ガード(Guard / Policy)】
│
│ ① 認可ロジックを実行し、成功時のみ Authorized<Action, Value> を mint ⚡️
▼
【 Controller 】
│
│ ② 認可済みのブランド付き値をそのまま Service へ渡す
▼
【 Service 】
│
│ ③ 引数に Authorized<Action, Value> を要求する
│ (※未認可の生データを渡すとコンパイルエラー!)
▼
型で排除できる 4 つの事故
この設計によって、開発現場で起きがちな以下の 4 大事故がすべてコンパイル時に未然に防がれます。
- 未認可の生 ID を直接 Service に渡してしまう事故
- 別のアクション(閲覧用など)で認可した結果を、更新・削除 Service に使い回す事故
- 複数リソース(Scope)が必要な場所に、単一リソースの ID を渡してしまう事故
- 未検証のトークンや proof を、検証済みとして扱ってしまう事故
注意深さに頼らない境界設計
「ちゃんと if 文で認可チェックを通したか」を人間の注意深さやコードレビューの目視に委ねている限り、認可の漏れや取り違えはいつか必ず再発します。
特にリファクタリング時や急ぎのバグ修正では、「別のエンドポイントからコピペして引数がズレていた」「認可を通した変数ではなく、スコープ外の生 ID をそのまま渡してしまった」といった事故が極めて起きやすいです。
認可結果を boolean で終わらせず、Authorized<Action, Value> という型で値に焼き付ける。
「ミスをしたコードは、そもそもコンパイルが通らない」 という絶対の型境界を敷くことこそが、大規模なコードベースにおいて最も信頼できるセキュリティの防壁になります。