SPRINT-7 DB public exposure — RLS security pass (T5-B Phase 1)¶
Status: STOP — Ryan review required. Do NOT expose api.last1.app.
Date: 2026-08-30
Authorization: T5-A closed; Ryan authorized T5-B Phase 1 only
Target: VM 103 last1app-db (192.168.1.103:8000 Kong, LAN-only today)
Pattern: Onset T5-1 Phase 1 (ONSET_DB_PUBLIC_EXPOSURE_RLS_SECURITY_PASS.md)
Verdict¶
BLOCK — not safe to expose¶
PostgREST returns veteran phone numbers, emails, org membership, and unit invite codes to the anon key without a user JWT. That is a hard stop before any public api.last1.app ingress.
Check-in row isolation for non-unit peers is PASS. Same-unit visibility and org-staff cohort rules appear intentional. The blocker is world-readable identity tables, not check_ins RLS for unrelated users.
Ryan must review this report (especially §3 cross-user/anon row counts) before Phase 2. Phase 2 (api.last1.app tunnel + CF proxy) is not authorized.
Executive summary¶
| Check | Result |
|---|---|
| Public tables with RLS disabled | 0 (135/135 public base tables: RLS ON) |
| Zero-policy tables | 0 |
Anon → check_ins / org SMS / last1_* data tables |
0 rows (PASS) |
Anon → users (phone/email) |
43 rows (BLOCK) |
Anon → user_profiles |
6 rows incl. WIN staff emails + last1_identity_id (BLOCK) |
Anon → organization_members |
30 rows (BLOCK) |
Anon → units (incl. invite_code) |
2 rows (BLOCK) |
Anon → organizations |
11 rows (directory by design; includes contact emails) |
Cross-user check_ins (non-unit) |
0 rows (PASS) |
Cross-user check_ins (same unit) |
12 rows (intentional unit visibility) |
| Realtime publication | 24 tables incl. check_ins, unit_messages, users |
| Realtime live WS RLS probe | Inconclusive (tooling); PostgREST RLS parity assumed |
Storage profile-photos |
Public bucket; profile images only (1 object) |
Storage unit-chat-images |
Public flag but RLS-scoped SELECT; anon list 0 objects |
Edge gateway verify_jwt |
Global false; cron/SMS functions callable anon → 200 (pre-exposure hardening) |
Live test users (real data on R940): 43 public.users, 20 with check-in history, 75 total check_ins.
1. Kong (:8000) attack surface¶
Self-hosted Supabase on VM 103 exposes standard Kong routes on :8000 (today LAN / Tailscale only).
| Surface | Path | Auth model | Sensitive-data risk |
|---|---|---|---|
| GoTrue | /auth/v1/* |
Anon or service JWT as apikey; user JWT from sign-in |
Issues tokens; no direct table reads |
| PostgREST | /rest/v1/* |
RLS per Authorization JWT (anon role if absent) |
Primary boundary — failed for identity tables |
| Storage | /storage/v1/* |
Bucket public flag + storage.objects RLS |
Profile photos public; unit chat scoped |
| Edge Functions | /functions/v1/* |
Gateway JWT off (FUNCTIONS_VERIFY_JWT=false); in-function auth varies |
Cron/SMS endpoints reachable |
| Realtime | /realtime/v1/* |
WebSocket + JWT; uses same RLS as DB for postgres_changes |
Exposed; subscribes to 24 tables |
LAN smoke (anon apikey, 2026-08-30):
| Path | HTTP | Notes |
|---|---|---|
/auth/v1/health |
200 | |
/auth/v1/settings |
200 | Providers: phone/twilio, google, apple (T5-A) |
/rest/v1/ |
403 | Root denied |
/storage/v1/bucket |
200 | Lists buckets |
/functions/v1/hello |
500 | Staging artifact absent |
/realtime/v1/websocket |
400 | Upgrade required (service up, not 503) |
22 Edge Functions deployed (+ main, _shared): see §6.
2. RLS policy audit¶
2.1 Baseline¶
- 135
publicbase tables: RLS enabled on all. - 0 tables with RLS off.
- 0 tables with RLS on and zero policies.
2.2 Sensitive tables (requested scope)¶
| Table | RLS | Policies | Assessment |
|---|---|---|---|
check_ins |
ON | 8 | Scoped: target user, unit members, org staff cohort |
org_check_ins |
ON | 2 | Anon 0 rows |
users |
ON | 5 | USING (true) ×2 — world-readable phones/emails |
user_profiles |
ON | 2 | USING (true) — staff emails, last1_identity_id |
units |
ON | 4 | Broad SELECT; exposes invite_code |
org_sms_threads / org_sms_messages |
ON | 2 each | Anon 0 rows |
unit_messages |
ON | 2 | Anon 0 rows |
organizations |
ON | 3 | Public directory (is_demo filter) |
organization_members |
ON | 3 | USING (true) on SELECT |
beneficiaries |
ON | 1 | Anon 0 rows |
last1_* (9 tables audited) |
ON | 1–3 each | Anon 0 rows on all tested |
sms_outbound / sms_check_ins |
ON | 2 | Anon 0 rows |
Migration origin (intentional on hosted, incompatible with public anon exposure):
20260202_1609_004_last1_auth_integration.sql—"Anon can read users"/"Users are viewable by everyone"20260203_173000_enable_rls_security_fix.sql—"Profiles are viewable by everyone"20260421234500_silo_demo_units_for_apple_review.sql— org/unit public SELECT for demo silo
2.3 Permissive USING (true) policies (hard-block subset)¶
| Table | Policy | Risk |
|---|---|---|
users |
Anon can read users |
All veteran phone numbers |
users |
Users are viewable by everyone |
Duplicate world read |
user_profiles |
Profiles are viewable by everyone |
Staff email, Last1 identity bridge |
organization_members |
Members can view org memberships |
Maps users ↔ orgs + roles |
3. Cross-user / anon live tests (HARD GATE)¶
Executed on VM 103 against http://127.0.0.1:8000 with production anon key from /opt/supabase/docker/.env and JWTs minted with stack JWT_SECRET (same verification as GoTrue).
3.1 Anon key, no user JWT¶
| Table | Rows returned | Leak? |
|---|---|---|
check_ins |
0 | No |
org_check_ins |
0 | No |
org_sms_threads |
0 | No |
org_sms_messages |
0 | No |
unit_messages |
0 | No |
direct_messages |
0 | No |
last1_organizations |
0 | No |
last1_programs |
0 | No |
last1_beneficiaries |
0 | No |
sms_outbound |
0 | No |
users |
43 | YES — phones/emails |
user_profiles |
6 | YES — PII |
organization_members |
30 | YES |
units |
2 | YES — invite codes |
organizations |
11 | Directory (by design) |
Sample anon users payload (redacted pattern): includes +18186932921, +13232136573, michelle@warriorsinneed.org, etc.
3.2 Cross-user JWT tests (real accounts)¶
| Test | Result | Pass? |
|---|---|---|
User A c6f84fd2-… (+18186932921, Ryan) JWT → check_ins WHERE target_user_id = User B 169082e7-… (review@radiocheck.app) |
0 rows | PASS |
User B JWT → check_ins WHERE target_user_id = User A |
0 rows | PASS |
User A JWT → check_ins WHERE target_user_id = User C a3cbfcca-… (jrdelacruz, same unit 8c022fa7-…) |
12 rows | By design (unit member visibility) |
User A JWT → all check_ins (limit 100) |
39 rows, 0 foreign to allowed unit cohort | PASS |
User A JWT → users table |
43 rows (all users' phones/emails) | FAIL |
Anon → users |
43 rows | FAIL |
Specific wording (Ryan-requested format):
User Ryan Curry (
c6f84fd2-ba39-4fec-828b-aa2287507ed6, +18186932921) with valid JWT attempted to read user review@radiocheck.app's (169082e7-8183-469f-9008-826cc705e1b5)check_ins→ 0 rows returned. PASS.User Ryan Curry with valid JWT attempted to read user jrdelacruz.a@gmail.com's (
a3cbfcca-7a12-45cd-a544-7026f27dcd9f, same unit)check_ins→ 12 rows returned. Intentional (shared unitH&S_ISC).Anon key (no JWT) attempted to read
check_ins→ 0 rows. PASS.Anon key attempted to read
users(phone/email) → 43 rows. BLOCK.
3.3 Warriors In Need (organizations)¶
| Test | Rows | Notes |
|---|---|---|
Anon → organizations |
11 | Includes WIN + demo/smoke orgs; contact_email visible |
Ryan JWT → WIN 7b975ed9-373e-4874-b8b1-88e1950027b5 |
1 | Expected (Ryan is org admin) |
Anon → organization_members |
30 | Exposes user↔org mapping without auth |
Cross-org denial on check-ins: unrelated users outside unit/cohort rules → 0 rows (PASS). Org directory and membership tables are not scoped to membership for SELECT.
4. Realtime RLS¶
4.1 Exposure¶
- Realtime WebSocket endpoint responds (HTTP 400 without Upgrade, not 503).
supabase_realtimepublication includes 24 tables, including:check_ins,unit_messages,unit_message_reads,unit_typing_statususers,organizations,org_check_ins,org_sms_messagesdirect_messages,silence_alerts,sms_check_ins, etc.
RadioCheck iOS subscribes to check-in and unit thread channels over Realtime.
4.2 RLS parity¶
Supabase Realtime postgres_changes enforces the same RLS as PostgREST for the subscribed role/JWT.
| Inference | Basis |
|---|---|
| Cross-user non-unit check-in streams should be blocked | PostgREST check_ins cross-user tests 0 rows |
| Same-unit check-in visibility | PostgREST returns unit peers' rows by policy |
users channel risk |
If app subscribes to users changes, world-readable RLS would broadcast profile/phone updates broadly |
4.3 Live WebSocket probe¶
Attempted Phoenix phx_join on check_ins filter target_user_id=eq.<review> via wscat (SSH tunnel to VM 103). No reliable response captured (tooling/timeouts). Not a PASS sign-off for Realtime in isolation.
Recommendation before exposure: after fixing §3 blockers, re-run Realtime subscription test with user A JWT on user B's filter and confirm no change events (or join error).
5. Storage¶
| Bucket | DB public |
Objects | Anon access test |
|---|---|---|---|
profile-photos |
true | 1 (…/profile.jpg) |
GET 200 (148 KB) — expected public profile image |
unit-chat-images |
true | 0 | Anon list → []; SELECT policy requires unit membership folder |
storage.objects RLS policies:
Unit members can view unit chat images— SELECT scoped to caller'susers.unit_idfolder- Profile upload/update/delete scoped to
auth.uid()folder
Assessment: Profile photos intentionally public (no PHI beyond avatar). Unit chat bucket flag is public but object RLS blocks anon listing/reads today (no objects present). Re-test after unit chat media exists.
6. Edge Function gateway auth (T2-1)¶
Global: FUNCTIONS_VERIFY_JWT=false (all functions). T2-1 documented per-function models (SPRINT7_T2-1_REPORT.md).
Anon POST {} probe (2026-08-30):
| Function | HTTP | Auth behavior |
|---|---|---|
delete-account |
401 | In-function JWT ✓ |
quick-check-in |
400 | Before auth (action validation) |
stripe-checkout / stripe-portal |
400 | Body validation before auth |
sync-user-from-last1 |
401 | HMAC ✓ |
revoke-from-last1 |
401 | HMAC ✓ |
check-in-proof-events |
401 | Cron secret ✓ |
stripe-webhook |
400 | Missing signature ✓ |
sms-webhook (form, no sig) |
403 | Twilio signature ✓ |
sms-cron |
200 | No gateway auth; runs SMS pipeline (sent:0) |
org-sms-cron |
200 | No gateway auth |
sms-checkin |
200 | Twilio XML path (review before public) |
verify-org-email |
200 | Returns {success:false} (no JWT gate) |
trigger-check-ins / silence-engine |
500 | Missing APNs (no data leak) |
Pre-exposure requirement: restrict cron/internal functions to LAN/cron bearer or enable gateway JWT per T2-1 recommendations before api.last1.app (not a PostgREST RLS blocker, but must be fixed for public Edge exposure).
7. Hard blockers (must fix before Phase 2)¶
| # | Finding | Evidence |
|---|---|---|
| B1 | users world-readable (anon + any authenticated JWT) |
Anon 43 rows with phone/email |
| B2 | user_profiles world-readable |
Anon 6 rows; WIN staff emails + last1_identity_id |
| B3 | organization_members world-readable |
Anon 30 rows |
| B4 | units expose invite codes to anon |
Anon 2 rows incl. invite_code=BC727972 |
Required remediation (policy direction)¶
- Drop or replace
USING (true)onusers,user_profiles,organization_memberswithauth.uid()-scoped (or unit/org-scoped) SELECT. - Restrict
unitsSELECT: hideinvite_codefrom anon/authenticated non-members (view or column-level guard). - Re-run §3 tests until anon
users= 0 and cross-user phone reads = 0. - Harden Edge cron endpoints before public ingress (parallel track).
Non-blockers (documented)¶
organizationspublic directory (11 rows) — product choice; confirm WINcontact_emailexposure acceptable or narrow columns.- Same-unit
check_insvisibility (12 rows Ryan→JRD) — intentional squad feature. - GoTrue SMTP still deferred (T5-A).
- Realtime WS probe inconclusive — re-test after B1–B4 fixed.
8. Ryan decision gate¶
| Action | Owner |
|---|---|
| Review this report (especially §3 row counts) | Ryan |
| Authorize policy migration to fix B1–B4 | Ryan |
| Re-run Phase 1 after fixes → expect SAFE TO EXPOSE | Agent |
Reply T5-B Phase 2 go only after updated PASS report |
Ryan |
STOP. Do not create public api.last1.app DNS/tunnel ingress until blockers are remediated and Ryan approves a SAFE TO EXPOSE verdict.
References¶
- SPRINT7_T5-A_REPORT.md (providers closed)
- SPRINT7_T2-1_REPORT.md (Edge auth models)
- SPRINT7_T0_AUDIT.md (schema scope)
- Onset pattern:
ONSET_DB_PUBLIC_EXPOSURE_RLS_SECURITY_PASS.md - Repro (partial):
scripts/last1app/t5-1-rls-security-pass.sh