SPRINT-7 T5-B Phase 1 — RLS security pass (POST-REMEDIATION)¶
Date: 2026-08-30 (re-run 23:25 UTC)
Status: SAFE TO EXPOSE (Phase 1) — Ryan review before Phase 2
Target: VM 103 last1app-db (192.168.1.103:8000)
Remediation: B1 Option A applied + B2–B5 + org_members recursion hotfix
Prior (pre-fix): SPRINT7_DB_PUBLIC_EXPOSURE_RLS_SECURITY_PASS.md (BLOCK)
Verdict¶
SAFE TO EXPOSE (Phase 1 gate)¶
Identity-table anon leaks are closed. JWT reads are scoped (own + same-unit + same-org co-members). Cross-org / cross-unit denial holds. Edge cron endpoints reject anon POST.
Phase 2 (api.last1.app tunnel + CF proxy) is NOT authorized in this message. Ryan reads row counts below first.
Applied fixes (2026-08-30)¶
| Fix | What changed |
|---|---|
| B1 users (Option A) | Dropped USING(true) policies. SELECT: own row + shares_unit_with() + shares_organization_with(). Added rc_user_exists_by_email, rc_call_sign_taken RPCs. |
| B2 user_profiles | Dropped world-read. Self-only SELECT (user_id::uuid = auth.uid()). |
| B3 organization_members | Membership-scoped SELECT via is_member_of_organization() (SECURITY DEFINER; fixes 42P17 recursion). |
| B4 units | Member/leader/creator/demo-scoped SELECT. Added join_unit_by_invite_code RPC. |
| B5 Edge | sms-cron + org-sms-cron require Authorization: Bearer $RADIOCHECK_CRON_SECRET. |
| iOS (archive clone) | joinUnitByInvite.ts, useUnit.ts → RPC join; auth.ts → email/call_sign RPCs. |
SQL: scripts/last1app/migrations/20260830120000_t5b_rls_identity_hardening.sql
Hotfix: scripts/last1app/migrations/20260830121500_t5b_fix_org_members_rls_recursion.sql
1. Anon + no JWT — identity tables (HARD GATE)¶
| Table | Rows returned | Expected | Result |
|---|---|---|---|
users |
0 | 0 | PASS |
user_profiles |
0 | 0 | PASS |
organization_members |
0 | 0 | PASS |
units |
0 | 0 | PASS |
(Pre-fix: 43 / 6 / 30 / 2 respectively — all BLOCK.)
Other anon probes (unchanged PASS): check_ins, org_check_ins, org_sms_*, unit_messages, last1_* → 0 rows each.
Anon organizations: 11 rows — public org directory by design (not an identity leak gate).
2. Cross-user JWT — isolation vs Option A¶
Test users (minted JWTs):
| Role | User | ID |
|---|---|---|
| User A | Ryan (+18186932921) | c6f84fd2-ba39-4fec-828b-aa2287507ed6 |
| User B (isolated) | +15203532111 | 1aaaedbb-51d4-49ff-9193-aff3eb9d8751 |
| WIN co-member A | ryan@warriorsinneed.org (example pair) | used for Option A proof |
| WIN peer | michelle@warriorsinneed.org | a1ecc736-e0aa-4922-9483-3197c86de3cc |
2.1 Must be zero (no shared org/unit)¶
| Test | Rows | Pass? |
|---|---|---|
User A JWT → users?id=eq.<User B> (phone/email) |
0 | PASS |
User A JWT → check_ins?target_user_id=eq.<User B> |
0 | PASS |
| User A JWT → unrelated-org user row | 0 | PASS |
User A JWT → all organization_members |
0 (vs 30 total in DB) | PASS |
2.2 Must NOT be world-readable¶
| Test | Rows | Pass? |
|---|---|---|
User A JWT → users (limit 100) |
4 of 43 total users | PASS (not all-users leak) |
2.3 Option A — org co-members see contact (by design)¶
| Test | Rows | Pass? |
|---|---|---|
WIN org member JWT → co-member users row (phone/email) |
1 | PASS |
Org 7b975ed9-373e-4874-b8b1-88e1950027b5 (Warriors In Need): co-member read confirmed.
2.4 Same-unit visibility preserved¶
| Test | Rows | Pass? |
|---|---|---|
User A JWT → same-unit squadmate users row |
1 | PASS |
User A JWT → same-unit squadmate check_ins |
4 | PASS |
User A JWT → all check_ins (incl. unit peers) |
39 | By design |
3. Edge functions — anon POST¶
| Function | HTTP | Expected | Result |
|---|---|---|---|
sms-cron |
401 | 401 | PASS |
org-sms-cron |
401 | 401 | PASS |
Body: {"error":"Unauthorized"}
4. Artifacts¶
| Artifact | Path |
|---|---|
| Re-run JSON | VM 103 /tmp/last1app-rls-pass-post-t5b.json |
| Re-run script | scripts/last1app/t5-1-rls-security-pass.sh |
| Apply script | scripts/last1app/apply-t5b-rls-remediation.sh |
| iOS patches (audit clone) | /tmp/sprint7-audit/RadioCheck/lib/joinUnitByInvite.ts, hooks/useUnit.ts, lib/auth.ts |
STOP¶
Phase 1 re-run: SAFE TO EXPOSE.
Ryan: review row counts above. Do not authorize Phase 2 (api.last1.app public ingress) until you reply T5-B Phase 2 go.
Note: iOS RPC join + signup RPC changes are in the audit clone only; ship with tonight's RadioCheck archive before cutover testing on device.