Skip to content

ハウツー: 委任アクセスグラント

FT リファレンス: FT282 (NENE2-FT/grantlog) — 委任アクセスグラント: スコープ付き(read/write/admin)時間制限リソースアクセス、UNIQUE(grantor, grantee, resource) + CHECK(grantor != grantee)、IDOR → 404、ソフトデリート失効、使用回数追跡、GrantScope.satisfies() 階層、23 テスト / 71 アサーション PASS。

FT176 でも検証済み — 元の実装。

ユーザーごと、時間制限付き、失効可能なアクセス委任 — グランターがグランティーに名前付きリソースへのスコープ付きアクセスを制限された時間枠で付与します。


概要

委任アクセスグラントにより、1 人のユーザー(グランター)が別のユーザー(グランティー)にリソース識別子への時間制限付きスコープアクセスを付与できます。「document:42 を読み取り専用でユーザー 7 と 24 時間共有する、いつでも失効可能」のようなイメージです。

主要な特性:

  • マルチパーティ — グランターとグランティーは常に異なるユーザー。自己グラントは拒否されます。
  • ステートマシン — active → revoked(一方向)。expired 状態は expires_at から計算されます。
  • 不透明なリソースresource は自由形式の文字列。サーバーはそのまま保存します。
  • 冪等な一意性(grantor_id, grantee_id, resource) ごとに 1 つのユニークなグラント。
  • IDOR セーフ — すべての所有権チェックは存在列挙を防ぐために 403 ではなく 404 を返します。

スキーマ

sql
CREATE TABLE grants (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    grantor_id  INTEGER NOT NULL,
    grantee_id  INTEGER NOT NULL,
    resource    TEXT    NOT NULL,
    scope       TEXT    NOT NULL DEFAULT 'read',
    expires_at  TEXT    NOT NULL,
    revoked_at  TEXT,
    used_count  INTEGER NOT NULL DEFAULT 0,
    created_at  TEXT    NOT NULL,
    UNIQUE (grantor_id, grantee_id, resource),
    CHECK (scope IN ('read', 'write', 'admin')),
    CHECK (grantor_id != grantee_id)
);

CHECK (grantor_id != grantee_id) は多層防御の手段です — 明確なエラーレスポンスのために自己グラントはアプリケーション層でも拒否する必要があります。


ドメイン層

階層を持つ GrantScope enum

php
enum GrantScope: string
{
    case Read  = 'read';
    case Write = 'write';
    case Admin = 'admin';

    public function satisfies(self $required): bool
    {
        $rank = [self::Read->value => 0, self::Write->value => 1, self::Admin->value => 2];
        return $rank[$this->value] >= $rank[$required->value];
    }
}

Grant エンティティ — 計算済み状態メソッド

php
final readonly class Grant
{
    public function isExpired(string $now): bool  { return $this->expiresAt <= $now; }
    public function isRevoked(): bool             { return $this->revokedAt !== null; }
    public function isActive(string $now): bool   { return !$this->isExpired($now) && !$this->isRevoked(); }
}

先に失効を確認し、次に有効期限を確認します — 両方のパスは 403 を返しますが、システムの内部を公開せずにグランティーがアクセスが失敗した理由を理解できるように異なるエラーボディを持ちます。


HTTP エンドポイント

メソッドパス認証目的
POST/grantsX-User-Id(グランター)グラントを作成する
GET/grants/issuedX-User-Id呼び出し元が発行したグラントを一覧表示する
GET/grants/receivedX-User-Id呼び出し元が受け取ったグラントを一覧表示する
DELETE/grants/{id}X-User-Id(グランターであること)グラントを失効させる
POST/grants/{id}/useX-User-Id(グランティーであること)グラントを使用する

バリデーションルール

フィールドルール
grantee_idJSON 整数 > 0 であること。文字列 "2"、null、boolean、浮動小数点は拒否
resource空でない文字列。UTF-8 で 500 文字以下。そのまま保存(不透明)
scoperead / write / admin のいずれかであること
expires_at有効な ISO 8601。将来の日時であること。現在から 30 日以内
自己グラントgrantee_id == grantor X-User-Id → 422

厳格な整数フィールドパース

一般的な脆弱性は暗黙の型変換です — "2"(JSON 文字列)を 2(int)として受け入れる。明示的な型チェックを使用してください:

php
private function intField(array $body, string $key): ?int
{
    if (!array_key_exists($key, $body)) {
        return null;
    }
    // is_int() は "2", null, true, 2.5 に対して false を返す — PHP int のみ true
    return is_int($body[$key]) ? $body[$key] : null;
}

注意: 2.0(PHP float)は json_encode 後は 2(int)と区別できません — 単体テストで浮動小数点拒否をテストするには 2.5 を使用してください。


ステートマシン

         revoke()
active ─────────────→ revoked   (2 回目の revoke は 409)

  │ expires_at ≤ now

expired

revoked + expired → revoked が勝つ(先に revoked をチェック)

二重失効は 409 で拒否する必要があります。サイレントに受け入れてはいけません。 revoked_at タイムスタンプは 2 回目の呼び出しで変更してはいけません。


IDOR 保護パターン

php
// DELETE /grants/{id}
$grant = $this->repository->find($id);

// 「見つからない」と「あなたのグラントではない」の両方に 404 を返す
// ここでは 403 は返さない — 存在を漏洩する
if ($grant === null || $grant->grantorId !== $callerId) {
    return $this->responseFactory->create(['error' => "Grant #{$id} not found."], 404);
}

同じパターンが POST /grants/{id}/use にも適用されます — 呼び出し元がグランティーでない場合は 404 を返します。


マルチパーティの混同防止

シナリオ期待される動作
グランターが POST /grants/{id}/use を呼び出す(自分のグラント)404 — グランターはグランティーではない
グランティーが DELETE /grants/{id} を呼び出す404 — グランティーはグランターではない
ユーザー 3 がユーザー 1 と 2 の間のグラントで呼び出す404 — IDOR
X-User-Id: 0 または X-User-Id: -1401 — 非正の ID は拒否
X-User-Id なし401

セキュリティチェックリスト(ATK-01 〜 ATK-12)

#攻撃ベクター軽減策
ATK-01期限切れグラント(クロック境界)isExpired() 比較。テストで DB の expires_at を過去に設定
ATK-02失効グラントの状態バイパス使用前の isRevoked() チェック
ATK-03自己グラント(grantor == grantee)アプリ層 422 + DB CHECK
ATK-04間違ったグランティーがグラントを使用(IDOR)403 ではなく 404
ATK-05非グランターがグラントを失効(IDOR)403 ではなく 404。元のグラントは有効なまま
ATK-06作成時に過去の expires_atstrtotime($expiresAt) <= strtotime($now) → 422
ATK-07grantee_id の型混同is_int() 厳格チェック。"2"nulltrue2.5 を拒否
ATK-08resource のパストラバーサル不透明なストレージ。ファイルシステムアクセスなし
ATK-09resource/scope の SQL インジェクションパラメータ化クエリ。scope enum が注入値を拒否
ATK-10resource の Unicode/BIDIそのまま保存。ホモグリフと BIDI は異なるリソース
ATK-11二重失効(ステートマシン)2 回目の失効で 409。revoked_at は最初の後は不変
ATK-12グランターが自分のグラントをグランティーとして使用404 — パーティロールが厳格に強制される

テストアプローチ

  • ATK-01、ATK-02: 時間経過のシミュレートにスリープせず、DB を直接更新(UPDATE grants SET expires_at/revoked_at)して状態を強制します。
  • ATK-07: "2"(文字列)、nulltrue2.5(浮動小数点)をテストします。2.0 は PHP json_encode 後に int と区別できないのでテスト不可。
  • ATK-10: "\u{202E}"(BIDI オーバーライド)とキリル文字のホモグリフを使ってそのまま保存されることを確認します。
  • ATK-11: 2 回目の失効試行後も DB の revoked_at が変わっていないことをアサートします。

やってはいけないこと

アンチパターンリスク
UNIQUE (grantor_id, grantee_id, resource) なし同じペアが重複グラントを作成できる。グランティーは同じリソースに古いグラントと有効なグラントを持つ
失効時にハード削除監査履歴が失われる。アクセスが削除された時期や使用回数がわからなくなる
所有権チェックに 404 の代わりに 403 を返す不正な呼び出し元にグランドの存在が分かる。IDOR 列挙のサーフェス
CHECK (grantor_id != grantee_id) なし多層防御が欠如。アプリ層チェックがバイパスされると自己グラントが通り抜ける
自由形式のスコープ文字列を受け入れるタイポがサイレントに read にデフォルトする。GrantScope::tryFrom() で未知の値を拒否する
satisfies() 階層なしのスコープチェックwrite ユーザーが read チェックを個別に通過する必要がある。下位レベルすべてをチェックするために階層を使用する
expires_at の最大 TTL なしグランターが 100 年のグラントを作成できる。実質的に恒久的なアクセスでレビューなし
リソース長制限なし10MB のリソース文字列が低速なインデックスルックアップとメモリ割り当てを引き起こす
失効前に有効期限を確認失効 + 期限切れのグラントは「失効済み」を示すべき — ステートマシンでは失効が優先
使用回数をクライアント側で追跡クライアントが使用回数を報告する。サーバーがカウンターを所有する必要がある

MIT ライセンスの下で公開されています。