Boogy 認証(サービス内認可)
Boogy の認証は 3 つのレイヤーで構成されています。いずれも手動で実装してはいけません — SDK はセキュリティ不変条件を維持する検証済みヘルパーを生成します。
三層モデル
- イングレス許可 — プラットフォームのイングレスモードが、リクエストがサービスに到達するかどうかを決定します。コードが実行される時点では、呼び出し元は既に既知の プリンシパル または匿名です。
- ガードがルートを保護 — ルートごとのチェックがハンドラの前に実行されます: ログインを要求するか、アドレス指定されたリソースの所有権を要求します。
- ヘルパーが行をスコープ — ハンドラは呼び出し元の行のみをリスト/ロードします。
呼び出し元を auth::current_principal() -> Option<String> で解決します。プリンシパルは 不透明 です — パース、プレフィックス除去、UUID の想定をしてはいけません。所有権カラムの値として、および auth::* への入力としてのみ使用してください。
ルートごとのイングレス: 制限付きサービス内のパブリックルート
レイヤー 1(イングレス)は通常、サービス全体で 1 つの mode になります。しかし、時には 1 つのルートだけが、サービス全体が拒否する呼び出し元から到達可能でなければならない場合があります — 標準的なケースは、それ以外は authenticated(または internal/mixed)のサービス内にある パブリック /webhook レシーバーです。ルートごとのオーバーライドで切り出します。サービス全体の残りは制限されたままです:
[ingress]
mode = "authenticated" # サービス全体のデフォルト — その他すべて
[[ingress.routes]]
path = "/webhook" # サービス相対パス; すべてのメソッド
mode = "public" # 誰でも /webhook に到達可能
セキュリティ契約(読んでください — フェイルクローズドです):
| ルール | 重要な理由 |
|---|---|
サービス相対のリテラルパス。 完全一致(/webhook)またはプレフィックス(/stripe/*、セグメント境界)。{param} キャプチャはなし。 | path は Router が見るパスであり、ハンドラと同じフレームです。 |
| 最も具体的な一致が優先; 正確に 1 つのモードが適用されます。 完全一致はプレフィックスより優先; 長いプレフィックスは短いものより優先。 | パブリックサブルートが兄弟を 拡大 することは決してありません: /webhook が public でも /data は緩和されません。 |
デフォルト = サービス全体のモード。 一致しないパス(または .. トラバーサル試行)は、制限されたデフォルトにフォールスルーし、決して 最も寛容なルートにはなりません。 | ルートをリストし忘れると、そのルートは保護されたままになり、露出しません。 |
各オーバーライドは独自のモード + 許可リストを持ちます。 オーバーライドは allowed_agents/allowed_origins を継承しません。 | ルートブロックにリストしてください; 同じ非空検証が適用されるため、タイプミスが静かにすべてを拒否することはありません。 |
パスごとであり、メソッドごとではありません(現時点)。 path = "/webhook" は GET、POST などに同様に適用されます。 | パブリックな /webhook は任意の動詞で到達可能 — したがって、ハンドラ内でリクエストを検証する必要があります(無関係な GET は何もすべきではありません)。 |
| エッジでホストによって強制されます。 イングレスは wasm がインスタンス化される 前 に実行されます。 | パブリックルートをコード内で自己ゲートしてはいけません; しかし、パブリックルートは 誰でも 到達できることを意味します — 別の方法で認証してください(例: HMAC 署名; boogy:boogy-webhooks を参照)。 |
| 委任ゲート + レート制限はサービス全体のまま。 | パブリックな切り出しは [ingress.delegation] ゲートをバイパスできず、レート制限バケットを共有します。 |
[[ingress.routes]] がないマニフェストは以前とまったく同じように動作します — これは純粋に追加的なものです。
逆: オープンサービス内の制限付きサブツリー(オーナーのみの /admin)
もう一つの方向 — デフォルトがオープン(authenticated)だが、/admin/* サブツリーには サービスオーナー(プロビジョナー)のみが到達可能なサービスです。罠: プロビジョナブル モジュールは 誰でも デプロイするため、マニフェストに ID(@alice、boogy://alice/services/*)をハードコードしてはいけません — そのリテラルオーナーは他のすべてのプロビジョナーにとって間違っており、イングレス許可リスト文字列はデプロイ時に置換されません。イングレスには「自分と同じオーナー」マッチャーはありません。
ゲートなしの caller_is_service_owner() 機能を使用します — ホストが呼び出し元がこのサービスのオーナーかどうかを証明します(エージェントトークン、エージェントレジストリに対してホスト側で解決されるか、自身のワークロードのいずれか)。ルートごとのイングレスやハードコードされた ID は必要ありません:
[ingress]
mode = "authenticated" # すべてのルート; ハンドラが /admin 自体をゲート
// require_operator(): ホスト証明済み、ハードコードなし。
fn require_operator() -> Result<(), ApiError> {
if caller_is_service_owner() { return Ok(()); } // オーナー: 自身のエージェント OR 自身のワークロード
// OBO: ユーザーに代わって動作するオーナーのバックエンド — 証明されたアクターはワークロード。
let id = current_identity();
let actor_owner = id.and_then(|i| i.actor).and_then(workload_owner);
if actor_owner == Some(self_identity().owner) { return Ok(()); }
Err(ApiError::forbidden("operator only"))
}
これが正しいプリミティブである理由: 人間のオーナーは自身のトークンで /admin に curl できます(wasm はエージェントのハンドルを解決できませんが、ホストはできます — それが機能の役割です)、さらにオーナーのバックエンドはワークロードとして機能します。フェイルクローズド: 匿名、異なるオーナー、または解決不能な呼び出し元 → false。(resend-base カタログモジュールを参照。)以前の「internal + 同一オーナーワークロード」パターンも機能しますが、人間による直接の curl を除外します — オーナーのみの画面には caller_is_service_owner を優先してください。
ガードとヘルパーのクイックリファレンス(検証済み)
| 項目 | 使用法 |
|---|---|
auth::required() -> Guard | 匿名の場合は 401。コレクションルート(list、create)に配置。 |
auth::owns_resource(table, owner_col, id_param) -> Guard | アイテムルート(GET/DELETE /things/{id})。行をロードし、見つからないか、あなたのものでない場合は 404、req.ctx に格納。複数ロードの場合は .slot("name")。 |
auth::find_owned(table, owner_col) -> Result<Vec<Row>, _> | インデックスエンドポイント用のプリンシパルスコープリスト。匿名の場合は 401。 |
auth::load_owned(table, owner_col, id) -> Result<Option<Row>, _> | MCP/JSON-RPC 用の単一ロード + 所有権チェック(ID はパスではなくボディ内)。None = 見つからないか、あなたのものではない。 |
auth::require_scope(scope) -> Guard | 粗い能力ゲート: 匿名の場合は 401、ログイン済みでスコープがない場合は 403。 |
所有権カラムは常に DEFAULT_OWNER_COL("owner_principal")です — owner_id/created_by を決して発明しないでください。アイテムルートは .group([auth::owns_resource("things", DEFAULT_OWNER_COL, "id")], |g| …) として配線してください。そのガードの背後にあるハンドラは ctx に格納された行 を読み取ります — 再フェッチしないでください。
トークンからのオーナールール: 作成時、所有権カラムを auth::current_principal() からスタンプします。リクエストボディからオーナーを決して読み取ってはいけません — クライアントが別のユーザーの ID を主張する可能性があります。
鉄則: 存在マスクによる拒否
存在しない場合と存在するがあなたのものではない場合の両方で 404 を返します — 決して 403 を返してはいけません。 プラットフォームのセキュリティ不変条件であり、スタイルの選択ではありません。403 と 404 の分割は列挙オラクルです: 認証されたユーザーが ID を調べ、どれが存在するか(403)とどれが存在しないか(404)を学習します。SDK ガードがこれを強制します。
「しかし UI は理由をユーザーに伝えたい。」 クライアントサイド で行ってください: 404 を「あなたには利用できません」として統一して扱い、そのメッセージをレンダリングします; ワイヤーステータスは 404 のままにします。SDK ガードをフォークしたりパッチしたりしてマスクを分割してはいけません。(require_scope の 403 は 別の軸 — リソース所有権ではなく粗い能力 — であり、正しいです。)
プログラム呼び出し元向け API キー(検証済みレシピ)
wit_glue! の隣で api_keys_glue!(bindings) を呼び出し、次に:
init_tablesでapi_key_routes::install_table()を実行。ApiKeyRoutes拡張トレイトを介して管理ルートをマウント:Router::new().with_api_key_routes()(/_keys)または.with_api_key_routes_at("/admin/keys")。- ルートをゲート:
.group([api_key_routes::guard], |g| ...)。
| 事実 | 詳細 |
|---|---|
| 二重認証情報 | guard はセッションベアラーまたは sk_* キーの両方を受け入れます; 両方とも current_principal() に統合されるため、owns_resource/find_owned は変更なしで動作します。 |
| キーの管理 | セッション ID が必要; sk_* キーはキーを作成できません。 |
| 権限昇格なし | キーはミント者が既に保持するスコープのみを運びます(そうでない場合は 403)。 |
| ストレージ | キーはサービス自身のストア内でハッシュ化されて保存されます; 自分自身のテーブルを決して作成しないでください。 |
| 形式 | sk_<env>_<…>_<crc>。 |
レッドフラグ
| 考え | 現実 |
|---|---|
| 「'あなたのものではない' の 403 は理由をユーザーに伝える。」 | ID 列挙オラクルです。両方に 404 を返し、クライアントサイドで説明してください。 |
| 「行をロードした後、ハンドラ内で所有権をチェックする。」 | owns_resource が 1 つのガードでロード + チェック + ctx ストアを行います。格納された行を読み取ってください; チェックを再実装しないでください。 |
| 「カスタム api_keys テーブルを追加する。」 | api_keys_glue! はハッシュ化され、分離され、スコープ対応のテーブルを提供します。それを使ってください。 |
| 「リクエストボディからオーナー ID を読み取る。」 | current_principal() からスタンプしてください。ボディは攻撃者によって制御されています。 |
「パブリックな [[ingress.routes]] ルートは still で wasm 内の認証チェックが必要。」 | パブリックとは誰でも到達できることを意味します — 別の方法で認証してください(webhook の場合は HMAC 署名)。オーバーライドは自己ゲートしません。 |
統合
← boogy:designing-boogy-services がレイヤー 1 に供給するイングレスモードを選択します。boogy:boogy-account-auth はプリンシパルの提供元です(ログイン → 読み取るトークン)。サービス間でユーザーに代わって行動するには、boogy:boogy-obo-delegation を参照してください。署名付きコールバックのためのパブリックルートごとの切り出しは、boogy:boogy-webhooks の前半部分です。