コンテンツにスキップ

越境・テナント境界

本書は AGENTS.md の分冊(正典・AGENTS.md と同格)。優先順位は「実コード → AGENTS.md(本書を含む)→ DESIGN.md → PRODUCT.md」。 節は AGENTS.md から逐語で移設した(PR #nnn・2026-09-16)。対象パス: apps/api/src/routes/team.ts` `apps/web/src/lib/db/collectors.ts` `supabase/migrations/*vehicle*。この領域に触るワーカー / レビュアは本書を読む。

QA-6 hardening(不変条件ラチェット・mig262)

  • dev verifier の404分類: 既存 CI ログでは404の有無を判別不能(rls-proofs は PR 専用、qa6Check の旧 PASS 行は status/code を出さない)。EXECUTE 拒否検査の 404/PGRST202 は PASS/FAIL に数えず softSkip、401|403 + 42501 だけ PASS、200/false も漏洩 FAIL とし、以後の qa6 応答には status/code を記録する(wl-gjzq)。
  • service-only RPC は実効 grant で閉じる。 register_signup_invite_attempt(uuid, integer)reserve_ocr_daily_quota(uuid, date, integer) / release_ocr_daily_quota(uuid, date)PUBLIC / anon / authenticated の全てから revoke し、service_role にだけ EXECUTE を付与する。 PUBLIC revokeだけでは default ACL による直接grantが残る。API signup.ts の service-role 呼出しと、 mig050 の条件付き UPDATE(上限5の原子ゲート)は維持する。
  • DEFINER 検証ヘルパは引数 tenant を呼出元と照合する。 external_partner_in_tenantdispatch_stops_vehicle_validationcurrent_user(DEFINER内部ではowner)ではなく coalesce(pg_catalog.current_setting('role', true), 'none') で PostgREST の client 文脈を判別する。 authenticated/anon では current_tenant_id() の NULL と引数tenant不一致を拒否し、それぞれ false / 'tenant' を返す。anon/PUBLIC の EXECUTE は撤去する。service/owner の内部処理、 同一tenantの staff/driver、歴史的snapshotのsoft-delete親所有検証、tenant/yard/factory/ok の分岐は維持する。
  • 退行検出: scripts/proofs/mig262-qa6-hardening.sql は実効grant・42501と行不変・実triggerを検証する。 qa6-catalog-sweep.sql のgrant allowlistへ是正済み3関数を戻してはならない。 policy/grant/DEFINER認可の縮小があるためリリース分類は夜間推奨

越境露出の列スコープ(不変条件ラチェット・mig166)

PostgreSQL RLS は列マスクを持たない。したがって「表を越境 SELECT できる」ことは「その表の将来の全列を開示する」ことと同義である。mig043 の permissive 越境 policy(vehicles・discharger 限定・列制限なし)の下に mig165 が台帳機密 19 列(保険料・保険証券番号・リース料・車台番号(VIN)・車庫住所・車検満了日等)を追加した結果、他テナントの全ロールから読める状態になった(QA スイープ 2026-07-26 で検出)。実体: supabase/migrations/00000000000166_cross_tenant_vehicle_column_scope.sqlscripts/proofs/mig166-cross-tenant-vehicle-scope.sqlapps/web/src/lib/db/collectors.ts

  • 越境の車両発見は RPC だけが経路: vehicles に越境 SELECT policy を作ってはならない。discover_collector_vehicles(p_tenant_id)(SECURITY DEFINER・search_path=''revoke public+grant authenticated)が唯一の経路で、RETURNS TABLE開示してよい列を明示列挙する(id / display_label / vehicle_type / max_capacity_kg)。select * を使わないのは、vehicles へ列を追加しても自動露出しないことが本 RPC の存在理由だからである。proof V166-4 が pg_get_function_result で戻り列を pin するため、戻り列を増やすと proof が落ちる。
  • VIN を越境させない: display_labelcoalesce(plate_number, short_code, vehicle_name)chassis_number を identifier fallback に使ってはならない(web の plateNumber ?? chassisNumber ?? shortCode を越境画面へ持ち込まない)。plate 無し(リフト等)は社内コード・車両名で表す。
  • fail-closed の呼出元検証: current_tenant_type() IS DISTINCT FROM 'discharger' で raise(claims 欠落の NULL も拒否)。p_tenant_id は NULL 拒否・自テナント拒否・tenants.type='collector' 必須。構内機器(is_yard_equipment)と非稼働(active=false)はマッチング対象外として除外する。
  • 越境読取の到達可能性クラス: 越境読取が可能な表は 3 クラス。列を足すときは自分の表がどのクラスか確認する。 (1) 公開マッチング情報: permits / service_areas / pricing_rules / tenants。 (2) 取引関係で開く業務データ: jobs / manifests / jwnet_submissions(この 3 表が旧「4 表」から落ちていた・2026-08-06 訂正)。 (3) 二者間の関係表(単一所有 tenant なし): job_assignments / job_collection_sites / jwnet_delegations。全テナント可読マスタ: item_types / jwnet_code_masters越境の形は current_tenant_type()tenant_id <> current_tenant_id() だけではない。主要な形は関係表 join / helper 関数で他テナント行へ到達で、述語だけを見る検査には映らない。判断は述語でなく到達可能性で行う。 これらの表へ列を足すときは必ず同じ再評価をする(RLS に列マスクは無いので越境可の表への列追加は即開示)。 tenants の現行列は id / name / created_at / updated_at / type のみで機密は無いが、課金プラン・契約条件・内部メモのような列を足したら全 discharger へ漏れる。 なお ocr_import_batches / ocr_import_itemscurrent_tenant_type() を使うが tenant_id = current_tenant_id() と同時に要求するので越境ではない(regex 検査の偽陽性に注意)。
  • ロール軸の再評価(wl-qrl): vehicles へ列を足すときは越境だけでなく driver に開いた SELECT も再評価する。RLS は列マスクを持たないので、driver 可視の表へ機密列を足すと即座に配布される。driver 経路は列を明示列挙し select('*') を使わない。
  • 同時に是正した権限ドリフト(mig166): vehicle_permit_areas の書込を admin 限定 → staff(admin|normal) へ(親 vehicles は mig043/137 で staff 書込可なので、normal の CSV 取込が「車両列は成功・許可行だけ 42501」の部分適用になっていた)。profiles の client 直 DELETE policy(mig8)を 撤去(team API の最後管理者ガードとセッション失効を迂回し auth.users が残るため)。profiles_guard_protected_colslogin_id / email を追加(display_name の自己更新は mig138 の意図どおり維持)。
  • 最後の管理者ガードは権威ロールで発火判定するapps/api/src/routes/team.ts): effectiveRoleFromAppMetadataapp_metadata.role・不明値は admin へ fail-closed)を発火条件と数え上げの両方に使う。profiles.role は表示コピーでドリフトし得るため基準にしない(ドリフト時にガードが飛ぶと実効 admin 0 人=テナント全体ロックアウトで、復旧は service key の手動実行のみ)。ロールバックは派生ロールを書き戻さず prevAppMeta を丸ごと復元する(派生値だとエラー経路で非 admin を admin へ昇格させる)。
  • signOut() の失敗を捨てないapps/web/src/contexts/AuthContext.tsx / lib/sign-out.ts): supabase.auth.signOut() は障害時に { error } を返しセッションを localStorage に残すSignOutResult{ error, deviceCleared } を返し、失敗時は scope:'local' で端末から破棄したうえで、signOutFailureMessage の 3 状態(成功/端末は消えたが他端末に残る/端末にトークンが残った)を必ず利用者へ出す。呼出元は 5 箇所すべてこのラッパ経由とし、fire-and-forget に戻さない。
  • DEFINER RPC は呼出元 tenant の実在まで確認する: current_tenant_type() だけを見ると、tenant_type='discharger' だが tenant_id を持たない(誤プロビジョニング・孤児の)ユーザーが越境閲覧できる(p_tenant_id = current_tenant_id() が NULL 比較になり自テナント guard を素通りする)。current_tenant_id() is null の拒否と tenants(id, type) の実在確認を必須にする。
  • 削除経路も権威ロールで判定する: decideMemberDeletion へ渡す role は app_metadata.role 由来にする。profiles.role を渡すとドリフト時に最後の実効 admin を削除でき、削除は auth.users ごと消すので降格より重い(復旧は service key の手動実行のみ)。降格経路だけ直して削除経路を残すのは不可。
  • signOut 失敗時の案内は「サイトデータの削除」: scope:'local' も Auth API へ HTTP を打ってから localStorage を消すため、通信断では端末にトークンが残る。localStorage はブラウザを閉じても残るので「ブラウザを閉じてください」では消えない。storage/lock の例外も結果へ畳む(呼出元は fire-and-forget なので throw すると利用者に何も伝わらない)。
  • ラチェットは速い事前検査・権威は proof のカタログ検査: テキストを見るラチェットは識別子の引用(public."vehicles"・引用関数名)・別構文(ALTER POLICYFOR ALL・view 経由)で理屈上いくらでも迂回できる(codex terra 敵対レビュー 3 巡で実証)。regex を足し続けるのをやめ、実カタログを見る assertion を proof に置く: pg_policiesvehicles の policy を名前の allowlistに固定(新設は allowlist 更新=人間レビュー強制)+ 越境述語の不在、pg_get_viewdef で台帳機密を authenticated へ晒す view/matview の不在(V166-18〜20)。Postgres の POSIX 正規表現で語境界は \y\b はバックスペースで一致しない — この取り違えで検査が空振りしていた)。
  • ラチェット側も引用識別子まで拾う(事前検査としての網): vehicles の policy は CREATE / ALTER・policy 名の引用有無・FOR SELECT / FOR ALL / コマンド省略を問わず要レビューとして落とす(ALTER POLICY での越境化・比較方向の反転・IS DISTINCT FROMFOR ALL・無引用名はいずれも狭い regex を抜ける)。発見 RPC は戻り列だけでなく本文も検査し、dollar-quote タグは regex で書かず実際に読む$f166$ のような数字入りタグだと \$[a-z_]*\$ は一致せず本文検査が素通りする)。
  • 越境ラベルの proof には「最後の coalesce 段まで落ちる行」を置く: plate_numbershort_code がともに NULL の車両が無いと、coalesce(plate, short_code, <secret>, name) の形で本文へ機密を差し込む改変が proof を通る(露出するのはその行だけ)。ラベル検査は車台番号だけでなく全台帳機密の値(保険会社名・証券番号・リース・車庫)に対して行う。
  • 既知の残存リスク(follow-up): 最後の管理者の競合窓。削除経路は「再カウント → deleteUser」が原子的でないため、2 人の admin が互いを同時に削除すると admin 0 人になりうる(team.ts のコメントに明記)。信頼済み admin の同時操作かつ手動復旧を要する稀ケースとして受容し、機械的な完全排他(DB 側の直列化)は別スライスとする。降格経路は可逆なので事後再カウント+巻き戻しで守っている。
  • 回帰: docker proof scripts/proofs/mig166-cross-tenant-vehicle-scope.sql(V166-SENT/1〜17)を scripts/proofs/*.sql 全流しで検証([[local-docker-proof-must-run-all-proofs]])。assertion は例外の有無だけで判定しない: 分岐ごとのメッセージ照合(V166-11)・ROW_COUNT 確認(V166-13/17)を入れる(「後段の別 guard が全分岐で raise するので guard を消しても通る」「policy を消して 0 行でも例外が出ないので通る」を防ぐ)。web は collectors.test.tsvehicles を読まない・RPC 引数・マップ・RPC 失敗を空車両に化かさない)・sign-out.test.ts、api は team.test.tseffectiveRoleFromAppMetadata + 削除判定)。prod-critical(migration + RLS + 越境)は敵対レビュー必須(担当は AGENTS.md 本体の Routing 節・2026-08-02 以降は Claude/[[adversarial-workflow-and-new-table-protected-cols]])。本スライスは実際に BLOCK 判定を受け、削除経路 P1・RPC の呼出元検証・ラチェット迂回・proof の teeth を指摘されて修正した

車両台帳の列権限境界(不変条件ラチェット・mig247/mig249)

vehicles は driver も staff も同じ PostgreSQL ロール authenticated で触るため、RLS では 台帳機密を分離できない(RLS に列マスクは無い=mig166 節と同じ制約)。そこで PostgreSQL の列権限authenticated を安全列だけに絞り、staff の台帳列は mig247 の SECURITY DEFINER RPC を唯一の 経路にする。実体: supabase/migrations/00000000000247_vehicle_ledger_staff_rpc.sqlsupabase/migrations/00000000000249_vehicle_column_grant.sqlscripts/proofs/mig249-vehicle-column-grant.sqlscripts/no-unbounded-query-ratchet.mjs(投影ラチェット)。

  • A 列 11 個の完全一致(列追加は既定 deny): authenticatedvehicles に対する SELECT / INSERT / UPDATE は A 列=id / tenant_id / plate_number / short_code / vehicle_name / factory_id / active / is_yard_equipment / is_secondary_dedicated / vehicle_type / max_capacity_kg の 11 個と完全一致でなければならない(部分集合検査ではなく is distinct from の等値検査)。table-level の SELECT/INSERT/UPDATE は revoke 済みで保持しない。 したがって vehicles へ列を足すと既定で deny 側に落ちるgrant を書かない限り authenticated からは読めない)。これは mig166 の「越境可の表への列追加は即開示」とは逆向きの 既定であり、vehicles では露出させたい列だけを明示的に A へ足すのが手順である。 A へ足す判断をしたら proof の v_a 配列を同じ変更で更新する(更新しなければ proof が RED)。 台帳機密(B 列)に足すときは proof の v_b 配列へ名前で追加し、実効権限ゼロを固定する。 DELETE は列権限を持たない行/RLS 契約なので本境界の対象外で、既存の table privilege を保持する。
  • 権威は proof の実効権限検査・投影ラチェットは事前検査: 判定の権威は has_column_privilege('authenticated', 'public.vehicles', <col>, <priv>)物理カタログ(pg_attribute)の全列に対して回す検査(V249-2)である。これは PUBLIC への grant と authenticated が継承する role 経由の grant を含んだ実効権限を見る。 information_schema.column_privileges は grantee 行の直接付与しか映さないので、それだけを 権威にしてはならない(PUBLIC grant・継承 role grant が素通りする。MUT-V249-4/5/6 がこの 3 経路を 実証する)。stage 3 の投影ラチェット(scripts/no-unbounded-query-ratchet.mjs の vehicles 直 select が A 列以外を投影しないことの機械検査)は速い事前検査であって権威ではない (mig166 節の「ラチェットは速い事前検査・権威は proof のカタログ検査」と同じ位置づけ)。
  • staff の台帳アクセスは mig247 RPC 経由・create は admin 限定: staff が B 列を読み書きする 経路は list_staff_vehicle_ledger(admin|normal)/ update_staff_vehicle(admin|normal)/ create_staff_vehicleadmin 限定)だけである。create の admin 限定は mig044 ("admin inserts vehicles" policy)の境界をそのまま DEFINER 経路へ引き継いだものであり、 RPC を足すときに admin|normal へ緩めてはならない。role guard は is distinct from で fail-closed にする([[definer-rpc-role-guard-null-fail-open]])。web から vehicles を直接 select('*') で読まない(A 列を明示列挙する)。
  • 越境ラベルとの分離: 越境(discharger 向け)の車両発見は従来どおり mig166 の discover_collector_vehicles だけが経路で、本節の A 列とは別の開示列集合である。A 列に chassis_number 等を足せないのと同様、越境 RPC の戻り列も増やさない。
  • fixture UUID ブロックは f249: f248 は mig248(dispatch_stop_manifest_evidence の 手動証跡救済)の proof が専有し、その fixture は top-level で commit されて残る。本 proof が f248 を共有すると tenant / profile の PK が衝突し、on conflict (id) do nothing によって 他 proof の fixture を黙って使う(=検査が別の前提で緑になる)。 [[docker-proof-uuid-collision-shared-db]] のとおりブロックは全 proof 横断で採番する。
  • 回帰: docker proof scripts/proofs/mig249-vehicle-column-grant.sql(V249-1 sentinel / V249-2 A 列完全一致・B 列実効ゼロ・table-level 不在・DELETE 保持 / V249-3a,3b staff・driver の A 列投影は成功し select * と B 列明示読取は 42501 / V249-4 staff は mig247 RPC で全 34 列に 到達 / V249-5a,5b service_role と owner は全列保持、および MUT-V249-1〜6=物理列追加・B 列 re-grant・A 列 revoke・PUBLIC UPDATE grant・PUBLIC SELECT grant・継承 role grant の 6 系統が RED になること)を scripts/proofs/*.sql 全流しで [NG]=0 かつ psql exit 0 で確認する。 dev 実機は scripts/verify-factory-scope-rls.mjs(sentinel mig249_vehicle_column_grant_applied() の preflight で未デプロイは skip)と scripts/verify-fleet-rls.mjs。prod-critical(migration + 権限境界)は敵対レビュー必須(担当は AGENTS.md 本体の Routing 節)。

QA 未監査領域の監査結果(2026-07-29・コスト / 請求 / マスタ / OCR / 認証)

v1.43.0 の QA で並行監査 2 本が結果を返さず未監査だった領域を、実カタログと docker 実機で監査した。 P0 は 0 件。見つかった 2 件は wl- イシューへ起票済み。

  • 監査列 created_by の client 偽装(P2・mig171 で修正済み・最終的に 4 表): vehicle_daily_logs は INSERT でも UPDATE でも 他人の profile を指定でき、dispatch_ride_alongs は INSERT で指定できる。どちらも fill トリガも INVOKER guard も無い。写経元は dispatch_stop_ride_alongs_fill(mig158)auth.uid() の profile を強制し UPDATE では OLD を保持)。vehicle_daily_logs は既存トリガが 4 つあり発火順は 名前順なので、fill を先に効かせるなら aaa_ 接頭辞を検討する(mig100 と同じ手)。
  • コスト明細一覧の無界クエリ(P2・mig171 と同じ PR で修正済み): 1 ページ 50 件 × 明細上限 40 行 = 最大 2,000 行で Supabase の 1000 行上限に到達しうる。.in() の ids は 50 以下なので URL 過大化ではなく行数側の打ち切りだった。fetchCostLineItemsForEntries として切り出し (hook はレンダーできないため配線をテストする唯一の手段)、fetchAllPages + seq に加えて id の tiebreakerで全件取得する。seq は 1 コスト内で一意だが複数 cost_entry を跨ぐと同値が 並ぶので seq だけでは全順序にならない。

問題なしを確定した領域(再監査不要)。

  • 税額の握りつぶし: OCR → draft → DB 列 → CSV の全経路で tax_amount が運ばれている (cost-draft.ts / mappers/vehicle-costs.ts / csv/master-export.ts を実測)
  • OCR の低信頼フラグ: scoring.ts が per-field 閾値で実際に算出しており常に空にはならない
  • numeric の非有限: mig170 のカタログ検査で漏れ 0 件
  • マスタの無界クエリ: vehicles(1 テナント数十台)と item_types(11 件)は 1000 行に到達しない。 partners / collection_sites はページング済み
  • 認証: signOut の失敗を捨てない契約(mig166)は維持されている
  • 請求(AR): mig202/203/217/231/241 でプレビュー、確定スナップショット、入金消込、排出事業者ポータル、確定明細(品目名・日付)まで実装済み。入金取得失敗は fail-closed、確定はポータルへ即公開、確定版 Excel も利用可能。メール送付は通知基盤待ちで未実装。

検証時の罠(再発防止): set_config('request.jwt.claims', ..., true)transaction-local なので psql -f の autocommit では次の文に残らず、current_setting(...)::jsonb が空文字で 22P02 になる。begin; ... rollback; で囲む(この罠で 4 回はまった)。