構造でfail-closedにする:ガード・バインディング・トークン
はじめに
物理的な扉を開けるシステムにおいて、本当に興味深い問いは「ハッピーパスで動くか?」ではありません。「値が欠落したとき、token が失効したとき、決済は成功したのに権利の発行がされなかったとき、あるいはテストが状態を次のファイルに漏らしたとき、何が起きるのか?」です。
私たちのアクセス制御プラットフォームで最近行った一連の作業は、ほぼすべてがこの二つ目の問いに関するものでした。本稿では、そこから一般化できるエンジニアリングパターンを抽出します。すなわち、静かに fail-open しないガードの書き方、アイデンティティをユーザー入力ではなく「証明」に束縛する方法、偽の健全性シグナルを作り出さずにサードパーティの token ライフサイクルを管理する方法、そしてテストに嘘をつかせない方法です。
1. Fail-Closed なガード:文字列検査を真理値表に置き換える
アクセスチェックで繰り返し現れるアンチパターンは、セキュリティ上の判断を文字列の形から導出することです。
// ANTI-PATTERN: the decision depends on parsing, and parsing has a default branch.
function canEnter(state: string): boolean {
if (state.includes("verified")) return true;
if (state.startsWith("guest_")) return true;
return false; // looks fail-closed... until a new state name contains "verified"
}問題は、このコードが今日時点で間違っているということではありません。判断の表面が開いていることが問題なのです。将来追加される任意の文字列が偶然に部分一致テストを満たしてしまう可能性があり、レビュアーはケースを列挙できません。文字列検査は、認可の判断をテキストマッチングのヒューリスティックに変えてしまいます。
修正方法は、入力空間を有限にし、マッピングを明示することです ── つまり、文字どおりの真理値表にします。
type IdentitySource = "line" | "email_otp" | "anonymous";
type BindingKind = "synthetic" | "verified" | "none";
interface AccessDecision {
readonly allow: boolean;
readonly reason: string;
}
// Every (source, binding) pair is listed. There is no fallthrough branch
// that can accidentally evaluate to `true`.
const ACCESS_TABLE: Record<IdentitySource, Record<BindingKind, AccessDecision>> = {
line: {
synthetic: { allow: false, reason: "synthetic_binding_requires_proof" },
verified: { allow: true, reason: "ok" },
none: { allow: false, reason: "no_binding" },
},
email_otp: {
synthetic: { allow: false, reason: "synthetic_binding_requires_proof" },
verified: { allow: true, reason: "ok" },
none: { allow: false, reason: "no_binding" },
},
anonymous: {
synthetic: { allow: false, reason: "anonymous_never_allowed" },
verified: { allow: false, reason: "anonymous_never_allowed" },
none: { allow: false, reason: "anonymous_never_allowed" },
},
};
export function decideAccess(
source: IdentitySource,
binding: BindingKind,
): AccessDecision {
return ACCESS_TABLE[source][binding];
}これが信頼できる理由は3つあります。
- 網羅性がコンパイラによって検査される。
Record<IdentitySource, Record<BindingKind, ...>>は、新しい enum メンバーが追加されて処理されていない場合にコンパイルエラーになります。判断が未定義のまま新しい state がリリースされることはありません。 - デフォルトが拒否であり、それがデータとして表現されている。 関数の末尾に
else return trueが隠れている余地はありません。 - すべての拒否が機械可読な理由を持つ。 これによりロジックを再導出することなく、監査ログとサポートのトリアージが可能になります。
これに対するテストもまた有限になります。そこが要点です。
it("denies every combination that is not explicitly allowed", () => {
const sources: IdentitySource[] = ["line", "email_otp", "anonymous"];
const bindings: BindingKind[] = ["synthetic", "verified", "none"];
const allowed = sources.flatMap((s) =>
bindings.filter((b) => decideAccess(s, b).allow).map((b) => `${s}:${b}`),
);
expect(allowed.sort()).toEqual(["email_otp:verified", "line:verified"]);
});このアサーションはホワイトリストのスナップショットです。誰かがアクセスを広げた場合、テストは失敗し、どのペアが追加されたのかを正確に示します。
2. アイデンティティは入力ではなく証明に束縛する
マルチチャネルなシステムにおける、見落としやすい権限昇格の経路があります。ユーザーがチャネル A(たとえばメッセージングプラットフォーム)で認証し、その後メールアドレスを申告し、システムがそれをアカウントに束縛する、というものです。メールアドレスがアイデンティティキーになってしまえば、メールアドレスを推測できる人は誰でもその履歴を引き継げてしまいます。
修正は、あらゆるアイデンティティシステムで明示的に述べられるべきルールです。
識別子を principal に束縛してよいのは、システム自身がその管理権の証明を観測した場合、または過去の信頼できるイベントがすでに両者を関連付けている場合に限る。
実務上、これは2つのことを意味します。
interface BindingRequest {
principalId: string;
email: string;
/** How we learned about this email. */
provenance: "otp_verified" | "user_typed" | "imported";
}
async function bindEmail(req: BindingRequest): Promise<Result<void, BindError>> {
if (req.provenance !== "otp_verified") {
// Arbitrary, user-supplied emails are never bound. Full stop.
return err({ code: "UNPROVEN_IDENTIFIER" });
}
return ok(await persistBinding(req));
}そして非 synthetic な束縛 ── 既存の実世界の関係を主張する場合 ── では、正当な当事者にしか生成できなかったはずの履歴上の事実を要求します。
-- A binding to an existing guest identity requires at least one completed
-- check-in under that identity. Presence of a record is the proof;
-- absence is a denial, not a "probably fine".
SELECT EXISTS (
SELECT 1
FROM check_ins c
WHERE c.guest_email = normalize_email($1)
AND c.facility_id = $2
AND c.status = 'completed'
) AS has_prior_relationship;normalize_email() が読み取り時だけでなく書き込み時に適用されている点に注目してください。読み取り時にだけ正規化するのは罠です。大文字小文字や空白だけが異なる2行が2つのアイデンティティになり、正規化して検索するルックアップはプランナが選ぶインデックス次第で誤った方にマッチします。境界で一度だけ正規化し、正規形を保存しましょう。
3. 認可は一覧クエリではなくバックエンドに存在する
よくある近道として、あるリソースが制限対象になったので一覧エンドポイントから除外する、というものがあります。UI に表示されなくなり、チケットはクローズされます。
しかし、一覧表示と操作実行は別のエンドポイントです。メンバー限定リソースをカタログから隠しても、すでに ID を知っている呼び出し元には何の効果もありません。私たちが徹底しているルールはこうです。
// Layer 1: the list endpoint filters (UX — don't show what can't be used).
const visiblePlans = plans.filter((p) => p.audience === "public" || viewerIsMember);
// Layer 2: the action endpoint *re-checks* and rejects (security).
export async function createReservation(input: ReserveInput, ctx: Ctx) {
const plan = await loadPlan(input.planId);
if (plan.audience === "members_only" && !(await isActiveMember(ctx.userId, plan.facilityId))) {
return httpError(403, "PLAN_REQUIRES_MEMBERSHIP");
}
if (plan.deletedAt !== null) {
return httpError(410, "PLAN_DELETED");
}
return reserve(input, ctx);
}同じ理屈は論理削除(soft delete)にも当てはまります。論理削除された行は依然として存在し、ID で到達可能です。すべての書き込みパスは deleted_at IS NOT NULL を厳格な拒否として扱わなければならず、セレクタに値を供給するすべての読み取りパスはそれを除外しなければなりません。論理削除は状態であり、状態はドロップダウンを埋めるクエリだけでなく、遷移関数の中で扱われる必要があります。
4. 不変条件は「お金が動いた後」にも成り立たなければならない
今回のバッチで最も示唆に富むバグの一つは、購入上限が決済の前には強制されていたのに、後には強制されていなかったというものです。「上限チェック済み」から「権利発行済み」までの間は典型的な TOCTOU のギャップです ── 同時リクエスト、リトライされる webhook、ブラウザの戻るボタンはすべてそこに住んでいます。
一般的なパターンはこうです。2回チェックし、不変条件はアプリケーションではなくデータベースに所有させる。
-- The invariant is expressed where concurrency is actually resolved.
ALTER TABLE ticket_book_purchases
ADD CONSTRAINT ticket_book_purchase_unique_per_cycle
UNIQUE (user_id, ticket_book_id, billing_cycle);async function onPaymentSucceeded(event: PaymentEvent) {
const withinLimit = await countPurchases(event.userId, event.bookId) < LIMIT;
if (!withinLimit) {
// Do NOT issue. Refund/flag instead, and never tell the guest
// "your tickets are ready" when nothing was issued.
await markForRefund(event.paymentId, "purchase_limit_exceeded");
return;
}
try {
await issueEntitlement(event);
} catch (e) {
if (isUniqueViolation(e)) return; // idempotent replay, already issued
throw e;
}
}このバグの後半部分も同じくらい重要です。発行が失敗したときに、成功メッセージを描画してはいけません。「パスが有効になりました」と伝えられた利用者が、その後で施錠された扉に直面すれば、プラットフォーム全体への信頼を失います。UI の文言は、決済リクエストが 200 を返したという事実ではなく、永続化された権利から導出されなければなりません。
密接に関連する不変条件が**見積りの束縛(quote binding)**です。価格をユーザーに提示したなら、合計金額はその見積りに対して凍結されなければなりません。
interface BoundQuote {
quoteId: string;
total: number;
currency: string;
computedAt: string;
expiresAt: string;
inputsHash: string; // hash of plan/rate versions used
}
function confirm(quote: BoundQuote, live: PricingInputs) {
if (hashInputs(live) !== quote.inputsHash) return httpError(409, "QUOTE_STALE");
if (Date.now() > Date.parse(quote.expiresAt)) return httpError(409, "QUOTE_EXPIRED");
return chargeExactly(quote.total, quote.currency);
}管理者による料金の編集が、利用者がすでに目にした合計金額を動かせてしまってはなりません。見積りを明示的で、ハッシュ化され、有効期限を持つオブジェクトにすることで、「知らないうちに価格が変わった」という追跡不能なインシデントは、理由コード付きの 409 に変わります。
そして価格ドメインそのものについて。無効化された料金は 0 ではありません。 無効な設定を 0 として扱うのは、静かに商品を無料で売ることです。undefined と 0 は最下層まで別の型でなければなりません。
type Rate = { kind: "set"; amount: number } | { kind: "unset" };
function resolveRate(r: Rate): number {
if (r.kind === "unset") throw new PricingError("RATE_NOT_CONFIGURED");
return r.amount;
}5. Token のライフサイクル:本物のシグナルを計測する
ベンダー API との連携は特定の形で死にます。refresh token が機能しなくなり、それでもシステムは 30 分後に期限切れになる access token で呼び出しを続けるのです。下流の 401 から推論して「token が死んでいる」と検知すればノイズが増え、token の経過時間から推論すれば誤報が出ます。
正しいシグナルは、障害に最も近いものです。すなわち、直近のリフレッシュ試行の結果です。
ALTER TABLE integration_credentials
ADD COLUMN last_refresh_at timestamptz,
ADD COLUMN last_refresh_error text, -- NULL means the last refresh succeeded
ADD COLUMN consecutive_refresh_failures int NOT NULL DEFAULT 0;
CREATE INDEX ON integration_credentials (consecutive_refresh_failures)
WHERE last_refresh_error IS NOT NULL;function isDead(cred: Credential): boolean {
// Not "the token looks old" and not "some call returned 401":
// the refresh itself failed, repeatedly.
return cred.lastRefreshError !== null && cred.consecutiveRefreshFailures >= 2;
}これを堅牢にする2つの付随パターンがあります。
single-flight なリフレッシュ。 負荷がかかると、N 個の同時呼び出し元がすべて期限切れに気づき、すべてがリフレッシュします。ローテーションする refresh token を使っている場合、2回目のローテーションが1回目を無効化し、連携は自壊します。リフレッシュを credential ごとに1つの in-flight promise に集約し、その保証をすべての呼び出し元 ── バックグラウンドの cron、webhook、インタラクティブなパスのいずれにも ── 拡張してください。3つの入口のうち1つしかカバーしていない single-flight ロックは、ロックではありません。
const inflight = new Map<string, Promise<Token>>();
export function refreshSingleFlight(id: string, fn: () => Promise<Token>): Promise<Token> {
const existing = inflight.get(id);
if (existing) return existing;
const p = fn().finally(() => inflight.delete(id));
inflight.set(id, p);
return p;
}複数インスタンスのデプロイでは、水平スケールしても保証が維持されるよう advisory lock で裏付けます。
SELECT pg_try_advisory_xact_lock(hashtext('token_refresh:' || $1));イベントごとではなくダイジェストでアラートする。 死んだ credential は1時間に数千件の失敗を生成し得ます。その一つひとつがページャを鳴らせば、オンコールのエンジニアはチャンネルをミュートし、次の本物のインシデントは見えなくなります。credential ごと・ウィンドウごとに1つのダイジェストへ集約することで、S/N 比は生存可能な水準に保たれます ── アラート疲れは人間工学上の不満ではなく、セキュリティの障害モードです。
6. 沈黙こそ最悪の障害モード
このバッチのいくつかの変更は、1つのテーマを共有しています。かつては握りつぶされていた失敗が、いまは可視かつルーティングされたイベントを生成する、というものです。
- 鍵の配信失敗は、以前は
catchブロックで終了していました。いまは運用インシデントとして表面化します。 - 設定によって送信がブロックされた場合(チャネルが無効、認証情報が欠落)、プラットフォーム運用者にページが飛びます ── なぜなら、ブロックされた通知は利用者から見れば壊れた鍵と区別がつかないからです。
- サインアップ中のフォーム永続化失敗は、成功したように見えるページにユーザーを放置するのではなく、ユーザーに報告されるようになりました。
一般化できるルールはこうです。
type DeliveryOutcome =
| { status: "sent"; messageId: string }
| { status: "suppressed"; reason: "channel_disabled" | "quiet_hours" }
| { status: "blocked"; reason: "not_configured" | "missing_credentials" }
| { status: "failed"; reason: string; retryable: boolean };
async function deliver(msg: Message): Promise<DeliveryOutcome> {
const outcome = await transport.send(msg);
await auditLog.record({ messageId: msg.id, outcome, at: new Date().toISOString() });
if (outcome.status === "blocked") await pageOperator(msg, outcome.reason);
return outcome;
}suppressed と blocked は意図的に区別されています。抑制は意図されたポリシー上の結果です。ブロックは人間が修正しなければならない設定ミスです。この2つを「送られなかった」という1つのバケツにまとめてしまうと、ダッシュボードがグリーンのまま施設が1週間も入館鍵を配信できない、という事態が起こります。
同じ規律はトランスポート自体にも当てはまります。メールトランスポートは呼び出し元の制御フローに向かって決して throw すべきではありません。結果(outcome)を返すのです。通知レイヤーから throw するということは、メッセージを送れなかったせいで無関係なビジネストランザクションがロールバックされることを意味します。
7. 嘘をつけないテスト
ここでのテスト衛生に関する2つの修正は、一般化する価値があります。
テスト環境はファイル間で漏れてはならない。 import 時に合成のバックエンド URL を process.env にインストールする共有テスト環境モジュールは、その後に読み込まれるすべてのテストファイルへ染み出します。結果として、ある順序では通り別の順序では失敗するスイートができあがり、さらに悪いことに、本来ガードに到達すべきだったテストが静かにスタブに当たるスイートができあがります。
// Explicit lifecycle instead of import-time side effects.
export function withTestEnv(overrides: Record<string, string>) {
const saved = new Map<string, string | undefined>();
beforeEach(() => {
for (const [k, v] of Object.entries(overrides)) {
saved.set(k, process.env[k]);
process.env[k] = v;
}
});
afterEach(() => {
for (const [k, v] of saved) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
saved.clear();
});
}また、フィクスチャのパスは process.cwd() ではなく必ず import.meta.url から解決してください ── 作業ディレクトリに依存するテストは、ローカルでは通り、テスト対象のコードとは無関係な理由で CI で落ちます。
名前だけのテストは負の価値です。 it("rejects unauthorized access") という名前で、関数が何かを返したことだけを検証するテストは、テストがないより悪いものです。存在しない保証をカバレッジレポートに主張させてしまうからです。このバッチのレビューでは、そうしたテストが実際の振る舞いを検証するよう明示的に修正して回りました。
// Before: passes even if the guard is deleted.
it("rejects unauthorized access", async () => {
const res = await handler(req);
expect(res).toBeDefined();
});
// After: fails the moment the guard weakens.
it("rejects unauthorized access", async () => {
const res = await handler(reqWithoutMembership);
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: "PLAN_REQUIRES_MEMBERSHIP" });
expect(await countReservations()).toBe(0); // no side effect occurred
});最後のアサーションこそ、アクセス制御システムで最も重要なものです。単にエラーが返ったことではなく、副作用が起きなかったことを検証するのです。
8. 開発環境も守る
小さいけれど象徴的な変更があります。シークレット管理コマンドの1つの形式だけをブロックしていた pre-commit フックを、そのコマンド自体をブロックするよう広げました。シークレット取り扱いに対する部分的なガードはセキュリティ劇場です ── 攻撃者や急いでいる開発者は、単に別のフラグを使うだけです。
#!/usr/bin/env bash
set -euo pipefail
# Block the capability, not one spelling of it.
if git diff --cached --name-only | grep -qE '(^|/)\.env($|\.)'; then
echo "refusing to commit env files" >&2
exit 1
fi
if grep -rqE 'secrets (set|unset)' <<<"${COMMAND:-}"; then
echo "secret CLI is not permitted from this workflow" >&2
exit 1
fiそしてフック自体も優雅に劣化しなければなりません。git worktree の中で失敗し、依存関係のインストール全体を道連れにするフックインストーラは、単にチームによって無効化されるだけです。ビルドを壊すセキュリティコントロールは取り除かれます。堅牢にするか、さもなければ現実との接触に耐えられません。
9. スキーマの規律
同じバージョン番号を共有する2つのマイグレーションは、静かな乖離の生成器です。環境ごとに異なる順序で適用され、同じバージョンを名乗る異なるスキーマができあがります。CI で一意性を強制しましょう。
ls supabase/migrations | cut -d_ -f1 | sort | uniq -d | grep . && {
echo "duplicate migration version" >&2; exit 1; }そして運用系テーブルには保持ポリシーを与えてください。スケジューラや HTTP キューの内部テーブル(cron.job_run_details、net._http_response)は無制限に肥大化し、いずれデータベースを落とします ── 扉を開けることが仕事のシステムにおける可用性障害です。
DELETE FROM cron.job_run_details WHERE end_time < now() - interval '7 days';
DELETE FROM net._http_response WHERE created < now() - interval '3 days';まとめ
これらすべての変更を貫く一本の線は、異なるレイヤーに適用された同じ原則です。
| レイヤー | fail-open な版 | fail-closed な版 |
|---|---|---|
| ガード | state 文字列の部分一致 | 網羅的な真理値表、コンパイラ検査付き |
| アイデンティティ | ユーザー申告のメールを束縛 | 証明された識別子のみ束縛 |
| 認可 | 一覧から隠す | 操作時に再チェックして 403 |
| 上限 | 決済前にチェック | 決済後に再チェック、DB 制約を最終防衛線に |
| 価格 | undefined を 0 に強制変換 |
タグ付きユニオン、明示的なエラー |
| 見積り | 確定時に再計算 | ハッシュ化され期限を持つ束縛見積り |
| Token | 下流エラーから健全性を推論 | 直近のリフレッシュ結果を記録 |
| 通知 | 失敗を握りつぶす | 型付き outcome、監査ログ、blocked でページ |
| テスト | "defined" を検証 | ステータス・コード・副作用の不在を検証 |
どれも巧妙な手法ではありません。それこそが要点です。物理的なアクセスを制御するシステムでは、何かが偶然に true になり得る分岐を取り除くこと ── そして何かが壊れたとき、利用者より先に人間が気づくようにすること ── によって信頼が築かれます。