Skip to content

ハウツー: 資産チェックアウト / チェックイン管理

追記専用の監査ログを持つ排他的な資産保持追跡を示します。 フィールドトライアル: FT194 (../NENE2-FT/assetlog/)。


パターンまとめ

懸念事項アプローチ
排他的保持holder_id INTEGER — NULL = 利用可能、非 null = 保持中
チェックアウト競合更新前に holder_id IS NOT NULL の場合 409
誤った保持者のチェックインholder_id != userId の場合 403
監査ログすべての状態変更時に追記専用の asset_history
IDOR 防止公開 API は holder_id を隠す;表示には管理者キーが必要
管理者キーhash_equals() による定数時間比較、空キーはフェイルクローズ
ユーザー識別X-User-Id ヘッダー;ctype_digit() + 長さガード、正規表現なし

ルート

メソッドパス認証説明
POST/assetsX-Admin-Key資産を作成する
GET/assetsすべての資産を一覧表示する
GET/assets/{id}単一資産を取得する
POST/assets/{id}/checkoutX-User-Id資産をチェックアウトする
POST/assets/{id}/checkinX-User-Id資産をチェックインする
GET/assets/{id}/history監査履歴

データベーススキーマ

sql
CREATE TABLE assets (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    name       TEXT    NOT NULL,
    holder_id  INTEGER,           -- NULL = 利用可能
    created_at TEXT    NOT NULL,
    updated_at TEXT    NOT NULL
);

CREATE TABLE asset_history (
    id       INTEGER PRIMARY KEY AUTOINCREMENT,
    asset_id INTEGER NOT NULL,
    user_id  INTEGER NOT NULL,
    action   TEXT    NOT NULL,   -- 'checkout' | 'checkin'
    acted_at TEXT    NOT NULL,
    FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE
);

排他的チェックアウトパターン

php
public function checkout(int $assetId, int $userId): string
{
    $asset = $this->findById($assetId);
    if ($asset === null) return 'not_found';
    if (!$asset->isAvailable()) return 'unavailable';   // 409

    $now = $this->now();
    $this->pdo->prepare(
        'UPDATE assets SET holder_id = :uid, updated_at = :now WHERE id = :id AND holder_id IS NULL'
    )->execute([...]);

    $this->appendHistory($assetId, $userId, 'checkout', $now);
    return 'success';
}

WHERE holder_id IS NULL ガードにより、同時リクエスト下での二重チェックアウトを防止します(SQLite は書き込みをシリアライズ;MySQL/PgSQL はトランザクションまたは SELECT FOR UPDATE が必要)。


IDOR 防止

php
// 公開レスポンス — holder_id なし
public function toPublicArray(): array
{
    return ['id' => $this->id, 'name' => $this->name, 'available' => $this->isAvailable(), ...];
}

// 管理者レスポンス — holder_id を含む
public function toAdminArray(): array
{
    return [..., 'holder_id' => $this->holderId];
}

ハンドラーが isAdmin() をチェックして正しいプロジェクションを選択します:

php
fn (Asset $a) => $isAdmin ? $a->toAdminArray() : $a->toPublicArray()

管理者キー(フェイルクローズ)

php
private function isAdmin(ServerRequestInterface $request): bool
{
    if ($this->adminKey === '') return false;   // キー未設定 → 拒否
    $provided = $request->getHeaderLine('X-Admin-Key');
    return $provided !== '' && hash_equals($this->adminKey, $provided);
}

ユーザー ID バリデーション

php
private function resolveUserId(ServerRequestInterface $request): ?int
{
    $raw = $request->getHeaderLine('X-User-Id');
    if ($raw === '' || !ctype_digit($raw) || strlen($raw) > 18) return null;
    $id = (int) $raw;
    return $id > 0 ? $id : null;
}

ctype_digit() は O(n) で ReDoS セーフです。長さキャップで整数オーバーフローを防止します。


エラーマッピング

リポジトリ結果HTTP ステータス
success200 / 201
not_found404
unavailable409 Conflict
not_holder403 Forbidden
already_available409 Conflict

テストノート

  • AppFactory::create(?PDO, ?string) はユニットテスト用のインメモリ SQLite を受け入れます。
  • withParsedBody($body) はテストリクエストで呼び出す必要があります — Nyholm PSR-7 は JSON を自動解析しません。
  • 公開の一覧/取得のアサーションで holder_id キーが存在しないことを確認します(assertArrayNotHasKey)。
  • ライフサイクルテスト: チェックアウト → 競合 → チェックイン → 別ユーザーによる再チェックアウト。

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