有効なトークンでも他人の文書は読めない:設計原理
一言でいうと
認証・権限・所有権を分離し、同じ404でリソースの存在を隠します。
なぜ必要なのか
ログインしたユーザーが、アドレスの文書番号だけを変えて、ほかのチームの文書を読みました。トークンが有効だという事実と、特定の文書を読む権利は、別のものです。このラボでは、固定のトークン辞書を使って、認証サーバーの代わりにします。トークンを発行したり、JWTの署名を実装したりするラボではなく、認証の結果のあとの権限の境界を実装するラボです。
どう動くのか
リクエストは、Bearerヘッダーの形式の検査、トークンの照会、scopeの確認、所有者の確認の順序で進みます。認証情報がないか間違っていれば401で、WWW-Authenticateヘッダーを送ります。身元は確認できたものの、read権限がなければ403です。読む権限があるユーザーが、存在しない文書や他人の文書をリクエストすると、どちらも404です。公開レスポンスには、idとtitleだけを残して、内部の所有者とコストを漏洩させません。
헤더 → 인증 401 → scope 403 → 소유권/존재 404 → 공개 필드 200
契約を読んで失敗を予測するワークシート
以下は、実装をまるごと暗記するための答案ではなく、ステップごとのコードレビューです。各変更の断片は、意図的に契約を壊しています。変更したあとでも、正常な例は通ることがある点に注意してください。実行する前に、どの入力・例外・状態を観測すれば違いが表に出るかを予想し、実装したあとで、その予想と結果を比べます。
1. Bearerヘッダーを分離する
bearer(header)は、正確に'Bearer 'で始まり、そのあとに空白のないトークンが1つあるときに、トークンを返します。None・空のトークン・別のscheme・余分な空白は、ValueErrorです。
判断の根拠: ヘッダーを任意に複数の断片に分けると、空白のエラーを正常なトークンとして受け入れてしまうことがあります。
レビューする誤った変更の断片:
header[6:]
この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。
2. 身元をコピーして返す
principal(token, users)は、トークン辞書のユーザー{id, scopes}を返しますが、scopesのリストまでコピーします。知らないトークンは、ValueErrorです。
判断の根拠: 返されたscopesを変更して、元のユーザーの権限まで変わってしまうと、リクエスト間で権限が混ざります。
レビューする誤った変更の断片:
user["scopes"]
この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。
3. 権限は正確に比較する
require_scope(user, scope)は、scopesにscopeの文字列が正確にあるときはNone、なければPermissionErrorを出します。read-allは、readではありません。
判断の根拠: 部分文字列の比較は、より長い権限名を、別の権限と取り違えます。
レビューする誤った変更の断片:
if False:
この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。
4. 所有権を別に確認する
visible(user, document)は、documentがNoneではなく、ownerがuserのidと正確に同じときだけTrueです。
判断の根拠: リソースがないことと、他人の所有であることを、同じ判定にまとめます。
レビューする誤った変更の断片:
True
この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。
5. レスポンスのフィールドを許可リストで選ぶ
public_document(document)は、idとtitleだけを持つ新しい辞書です。ownerやinternal_costは含みません。
判断の根拠: 元のデータからフィールドを削除せず、新しいレスポンスを組み立てます。
レビューする誤った変更の断片:
("id", "title", "owner")
この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。
6. エラーをHTTPの契約に合わせる
authenticate(header, users)は、bearerとprincipalをつなぎます。ValueErrorはHTTPException(401)で、headersのWWW-Authenticateの値はBearerです。
判断の根拠: 認証の失敗とアプリケーションのエラーを、500の1つにまとめません。
レビューする誤った変更の断片:
HTTPException(403,
この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。
7. 拒否の順序を固定する
read_document(user, documents, document_id)は、read scopeがなければHTTPException(403)、存在しないか他人の文書ならHTTPException(404)、それ以外ならpublic_documentの結果です。
判断の根拠: 認証のあとでも、scopeと所有権は、それぞれ確認する必要があります。
レビューする誤った変更の断片:
HTTPException(403, "not found")
この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。
8. 実際のリクエストで境界を閉じる
create_app(users, documents)は、GET /documents/{document_id}でAuthorizationヘッダーを受け取って、authenticateとread_documentを呼び出すFastAPIアプリを返します。200・401・403・404と、非公開フィールドの除去を、実際のリクエストで検証してください。
判断の根拠: 関数が個別に正しくても、経路で呼び出しを抜かすと、アクセス制御は適用されません。
レビューする誤った変更の断片:
authorization: str | None = None
この断片が入った関数の公開契約と比べてみてください。成功例1つでは区別できないなら、拒否されるべき入力や、失敗したあとの状態を観測の対象に選びます。
現場での姿
固定のトークン辞書は、学習用の入力です。本番の認証には、有効期限・署名・取り消し・安全な保管が、追加で必要です。404を同じにするだけで、応答時間やアクセスログを通じたすべての推測が消えるわけでもありません。拒否されたリクエストが、元のデータまで変えていないかも、一緒に検査します。
次のラボですること
8つのステップが、1つの実行可能な成果物につながります。Bearerヘッダーを分離する → 身元をコピーして返す → 権限は正確に比較する → 所有権を別に確認する → レスポンスのフィールドを許可リストで選ぶ → エラーをHTTPの契約に合わせる → 拒否の順序を固定する → 実際のリクエストで境界を閉じる、という流れです。
各ステップは、関数やファイルが存在するという事実ではなく、実際の戻り値・例外・状態の変化を検査します。正解を見たあとには、わざと境界の比較や後始末のコードを変えて、どのテストが失敗するかを確認してください。前のテストが次のステップでも維持される理由を説明し、このラボが保証しない本番の条件を1つ書いてみてください。