ロールだけではリソースを守れない
一言でいうと
認証はリクエストの主体を確認し、認可は、その主体がこの組織のこのリソースに対してこの操作をしてよいかを判断します。writerロール1つだけを検査すると、同じロールを持つユーザーが他の人の注文を読んだり変更したりする、水平の権限昇格が生じます。
なぜロールの検査では不十分なのか
ロールベースのアクセス制御は、機能の範囲を絞りますが、リソースの関係を表現できません。AliceとBobが、どちらもwriterであっても、BobがAliceの注文をキャンセルする理由はありません。組織の管理者も、自分の組織の中でのみ管理権限を持たなければなりません。したがって、注文のowner_idとorg_id、身元のsub、org_id、role、リクエストの操作を、一緒に比較します。別の組織なら、管理者であっても先に拒否し、同じ組織なら、所有者または許可された管理者のポリシーを適用します。
認証の欠如は401、認証されているが関係が合わなければ403です。存在しないリソースと権限のないリソースのレスポンスを同じにして、列挙攻撃を減らすかどうかも、ポリシーとして決めます。今回の課題の読み取りの境界は、権限のないIDを403で一貫して処理します。テストは、同じ組織の別の所有者、別の組織の管理者、正常な所有者の3つを、必ず含めます。
現場で見落としやすい漏えい
認証ヘッダー全体をログに出力したり、トークンのパースエラーに原文を含めたりすると、観測システムが秘密のストアになってしまいます。モジュールのimport時にデバッグ用のprintが実行されることも、採点ツールやワーカーのログに、機密性のある環境値を流してしまうことがあります。ライブラリのimportは静かでなければならず、エラーのレスポンスは、unauthorized、forbiddenのように、分類だけを提供します。監査ログには、トークンの代わりに、機密でない主体のIDとポリシーの結果を記録します。
権限の判定をどこに置くのか
同じルールでも、コードのどの層に置くかが、漏れる場所を作ります。置き場所は3つあり、それぞれ防ぐものが異なります。
パスの前のミドルウェア: 認証やロールの確認のように、リソースを見なくてもよいものは、ここが適しています。すべてのパスに一括でかけられるので、抜け落ちる場所がありません。ただし、リソースの関係は、ここでは判定できません。注文を1つ読まなければ、所有者がわからないからです。
処理関数の中: リソースを読んだ後で、所有者と組織を比較する場所です。表現力は最も高いのですが、パスごとに人が書かなければならないので、抜け落ちやすくなります。新しいパスを追加した人がその行を忘れると、そのパスだけが開いてしまいます。そのため、この方式を使うときは、権限の検査を経ていないパスがないかを、テストで確認する仕組みが一緒にある必要があります。
ストアのクエリの中: 取得の条件に、所有者と組織を入れる方式です。アプリケーションの検査とストアの範囲が食い違いえないことが最大の利点で、前の2つの場所を抜かしても、他人のデータは出てきません。その代わり、「なし」と「権限なし」が同じになることを受け入れなければならず、それは、列挙攻撃を減らす方向には有利ですが、ユーザーになぜできないのかを説明することは難しくなります。
実務では、3つを重ねて使います。ミドルウェアで認証を保証し、ストアのクエリで範囲を切り、処理関数で操作ごとのポリシーを判定します。1層だけを置くと、その層を抜かしたパスがそのまま穴で、重ねて置けば、1つを忘れても残りが捕まえます。
そして、拒否したものを数えておきます。正常に拒否されるリクエストは、いつも少しはありますが、その数が急に増えたら、誰かが他人のものを探っているか、私たちのポリシーがたった今誤って変更されたかです。どちらも知るべきことで、数えなければ、どちらも知ることができません。
実務での判断基準
権限のテストは、「writerが成功する」で終わってはいけません。最も危険な隣り合う関係と、テナントの境界を、反例として固定しなければなりません。リソースの所有権は、データベースの取得条件にも含めて、アプリケーションの検査とストアの範囲が食い違わないようにします。次のモジュールでは、こうした拒否と成功を、同じtraceのコンテキストで観察しつつ、秘密は残さない方法を扱います。