越境・テナント境界
本書は 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が残る。APIsignup.tsの service-role 呼出しと、 mig050 の条件付き UPDATE(上限5の原子ゲート)は維持する。 - DEFINER 検証ヘルパは引数 tenant を呼出元と照合する。
external_partner_in_tenantとdispatch_stops_vehicle_validationはcurrent_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.sql・scripts/proofs/mig166-cross-tenant-vehicle-scope.sql・apps/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_labelはcoalesce(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_itemsはcurrent_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_colsにlogin_id/emailを追加(display_nameの自己更新は mig138 の意図どおり維持)。 - 最後の管理者ガードは権威ロールで発火判定する(
apps/api/src/routes/team.ts):effectiveRoleFromAppMetadata(app_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 POLICY・FOR ALL・view 経由)で理屈上いくらでも迂回できる(codex terra 敵対レビュー 3 巡で実証)。regex を足し続けるのをやめ、実カタログを見る assertion を proof に置く:pg_policiesでvehiclesの 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 FROM・FOR ALL・無引用名はいずれも狭い regex を抜ける)。発見 RPC は戻り列だけでなく本文も検査し、dollar-quote タグは regex で書かず実際に読む($f166$のような数字入りタグだと\$[a-z_]*\$は一致せず本文検査が素通りする)。 - 越境ラベルの proof には「最後の coalesce 段まで落ちる行」を置く:
plate_numberとshort_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.ts(vehiclesを読まない・RPC 引数・マップ・RPC 失敗を空車両に化かさない)・sign-out.test.ts、api はteam.test.ts(effectiveRoleFromAppMetadata+ 削除判定)。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.sql・
supabase/migrations/00000000000249_vehicle_column_grant.sql・
scripts/proofs/mig249-vehicle-column-grant.sql・scripts/no-unbounded-query-ratchet.mjs(投影ラチェット)。
- A 列 11 個の完全一致(列追加は既定 deny):
authenticatedのvehiclesに対する 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_vehicle(admin 限定)だけである。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(sentinelmig249_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 回はまった)。