SPRINT-7 T5-B — RLS remediation plan (DRAFT ONLY)¶
Status: APPLIED 2026-08-30 — Phase 1 re-run SAFE TO EXPOSE (rerun report)
Date: 2026-08-30
Prior: SPRINT7_DB_PUBLIC_EXPOSURE_RLS_SECURITY_PASS.md (Phase 1 BLOCK)
Target: VM 103 last1app-db (192.168.1.103:8000)
Executive summary¶
Phase 1 found RLS ON everywhere, but four identity tables use USING (true) (or equivalent world read) policies inherited from hosted migrations. Anon PostgREST reads 43 phones/emails, 6 staff profiles, 30 org memberships, and 2 unit invite codes.
This plan proposes scoped replacements grounded in how RadioCheck iOS and last1.app actually query Supabase (code audit via sprint clone repos Last-1-Enterprises/RadioCheck, Last-1-Enterprises/last1.app). last1.app server routes use service role and bypass RLS; proposals focus on anon + authenticated JWT clients.
Apply order: B1 → B3 → B2 → B4 (B4 needs companion RPC + small app patch for invite join) → B5 (Edge, parallel but before public ingress).
After Ryan applies: re-run Phase 1 only (scripts/last1app/t5-1-rls-security-pass.sh). Do not start Phase 2 until updated report says SAFE TO EXPOSE.
Code audit sources¶
| Repo | Role | Auth to Supabase |
|---|---|---|
| RadioCheck (iOS) | Squad check-ins, units, org roster | Anon key + synced Last1 JWT (lib/supabase.ts, lib/auth.ts) |
| last1.app (monolith) | Org admin, staff validation, RC dashboard | Service role (server/supabase.ts) — not gated by these policies |
| radiocheck-web | Dashboard org picker | JWT; reads own organization_members only |
Sensitive writes on users already go through patch_users_row / sync_user_from_last1 RPCs where RLS was tight (lib/radioCheckUserWrites.ts).
B1 — public.users (phones/emails world-readable)¶
What the app actually needs¶
| Access pattern | Who | Columns / use | Code |
|---|---|---|---|
| Own profile | Authenticated self | Full row (cadence, unit_id, call_sign, phone, email) | hooks/useUnit.ts (.eq('id', user.id)), lib/auth.ts, user store refresh |
| Same-unit squad roster | Unit members | Full row today (select('*')) for roster, leaderboard, check-in queue embed |
hooks/useUnit.ts L47–51, hooks/useCheckIns.ts L36–38 (target_user:users!target_user_id(*)), hooks/useCheckIns.ts L269–272 |
| Org member roster (VSO) | Org members | UI selects safe columns only (id, display_name, call_sign, rank, xp, profile_photo_url) but PostgREST embed requires row-level SELECT |
hooks/useOrganization.ts L49–56 |
| Contact discovery | Authenticated inviter | Matched contacts (phone → user); not a table scan | RPC find_contacts_on_app(p_phones) (SECURITY DEFINER) — hooks/useUnitInvites.ts |
| Signup dedup | Pre/post auth | Email / call_sign existence check | lib/auth.ts L70–74, L206–209 — may break if world read removed without RPC (see below) |
| Org staff RC dashboard | last1.app admins | Member call_sign / display via service role | last1.app/server/routes.ts — unaffected |
Intentional product behavior to preserve: same-unit check-in visibility already works via check_ins RLS; squad mates must remain readable to each other. Cross-unit users (e.g. Ryan → review@) must stay 0 rows.
Ryan decision (org phones): Org co-member SELECT on users grants row-level access (including phone/email) even if the UI only displays call_sign. Options:
- A (recommended for minimal app change): allow org co-member SELECT with explicit acceptance that org veterans see each other's contact fields.
- B (tighter): org roster via SECURITY DEFINER RPC returning safe columns only; requires iOS app change to stop embedding
usersonorganization_members.
This draft proposes A plus a backlog item for B.
Current policies (live VM 103, 2026-08-30)¶
-- Policy: "Anon can read users"
FOR SELECT USING (true);
-- Policy: "Users are viewable by everyone"
FOR SELECT USING (true);
-- (Keep) "Users can insert own record" — WITH CHECK (auth.uid() = id)
-- (Keep) "Users can update own record" — USING (auth.uid() = id)
-- (Keep) "Service role has full access to users" — ALL for service_role
Origin: 20260202_1609_004_last1_auth_integration.sql ("Anon can read users" / "Users are viewable by everyone").
Proposed migration (draft SQL)¶
Step 1 — helper functions (mirror existing is_member_of_unit pattern):
CREATE OR REPLACE FUNCTION public.shares_unit_with(p_user_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
AS $$
SELECT EXISTS (
SELECT 1
FROM public.unit_memberships um_self
JOIN public.unit_memberships um_peer
ON um_peer.unit_id = um_self.unit_id
WHERE um_self.user_id = auth.uid()
AND um_peer.user_id = p_user_id
)
OR EXISTS (
SELECT 1
FROM public.users me
JOIN public.users peer ON peer.unit_id = me.unit_id
WHERE me.id = auth.uid()
AND peer.id = p_user_id
AND me.unit_id IS NOT NULL
);
$$;
REVOKE ALL ON FUNCTION public.shares_unit_with(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.shares_unit_with(uuid) TO authenticated;
CREATE OR REPLACE FUNCTION public.shares_organization_with(p_user_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
AS $$
SELECT EXISTS (
SELECT 1
FROM public.organization_members om_self
JOIN public.organization_members om_peer
ON om_peer.organization_id = om_self.organization_id
WHERE om_self.user_id = auth.uid()
AND om_peer.user_id = p_user_id
);
$$;
REVOKE ALL ON FUNCTION public.shares_organization_with(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.shares_organization_with(uuid) TO authenticated;
Step 2 — replace world-read policies:
DROP POLICY IF EXISTS "Anon can read users" ON public.users;
DROP POLICY IF EXISTS "Users are viewable by everyone" ON public.users;
CREATE POLICY "Users can read own row"
ON public.users FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users can read squadmates in shared units"
ON public.users FOR SELECT
USING (public.shares_unit_with(id));
CREATE POLICY "Users can read co-members in shared organizations"
ON public.users FOR SELECT
USING (public.shares_organization_with(id));
Step 3 — signup helpers (if email/call_sign pre-check breaks in QA):
CREATE OR REPLACE FUNCTION public.rc_user_exists_by_email(p_email text)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
AS $$
SELECT EXISTS (
SELECT 1 FROM public.users u
WHERE lower(trim(u.email)) = lower(trim(p_email))
);
$$;
-- GRANT EXECUTE TO authenticated; wire auth.ts to RPC if direct SELECT fails
Re-tests (Phase 1 script + manual)¶
| Test | Expected after fix |
|---|---|
Anon GET /rest/v1/users?select=id,phone,email |
0 rows |
Ryan JWT → all users |
>0 but only own + same-unit + same-org cohort (not all 43 unrelated) |
Ryan JWT → users?id=eq.<review@ user_id> (different unit) |
0 rows |
Ryan JWT → users?unit_id=eq.<shared unit> or squadmate ids |
≥1 rows (same-unit PASS preserved) |
Ryan JWT → organization_members embed users(...) for WIN |
rows with user embed (org roster still works) |
| iOS: unit roster + pending check-ins screen | Manual smoke on device/staging |
| iOS: org members tab | Manual smoke |
B2 — public.user_profiles (staff emails, last1_identity_id)¶
What the app actually needs¶
| Client | Reads user_profiles? |
Scope |
|---|---|---|
| RadioCheck iOS | No direct .from('user_profiles') |
Identity via sync_user_from_last1 RPC / Edge |
| radiocheck-web | No | — |
| last1.app server | Yes | Service role — staff onboarding, is_system_admin, staff roles (server/last1OAuthRoutes.ts, server/validation-engine/staff-validation.ts, server/storage.ts) |
JWT clients do not need world read. Staff directory for org admins is served through last1.app API (service role), not PostgREST from mobile.
Optional future: org staff viewing peer staff_role / email_verified via PostgREST join — would need org-scoped SELECT (similar to B3), not USING (true).
Current policy (live)¶
-- Policy: "Profiles are viewable by everyone"
FOR SELECT USING (true);
-- (Keep) "Users can update own profile"
FOR UPDATE USING (user_id::text = auth.uid()::text);
Origin: 20260203_173000_enable_rls_security_fix.sql.
Proposed migration (draft SQL)¶
DROP POLICY IF EXISTS "Profiles are viewable by everyone" ON public.user_profiles;
CREATE POLICY "Users can read own profile"
ON public.user_profiles FOR SELECT
USING (user_id = auth.uid());
-- Optional — only if a JWT client must read org staff profiles directly:
CREATE POLICY "Org co-members can read staff profiles in shared org"
ON public.user_profiles FOR SELECT
USING (
EXISTS (
SELECT 1
FROM public.organization_members om_self
JOIN public.organization_members om_peer
ON om_peer.organization_id = om_self.organization_id
WHERE om_self.user_id = auth.uid()
AND om_peer.user_id = user_profiles.user_id
)
);
Recommendation: start with self-only SELECT; add org policy only if QA finds a broken JWT path.
Re-tests¶
| Test | Expected |
|---|---|
Anon GET /rest/v1/user_profiles?select=* |
0 rows |
Ryan JWT → all user_profiles |
1 row (own) unless org policy added |
| last1.app staff validation / WIN admin routes | unchanged (service role) |
B3 — public.organization_members (full org/role map)¶
What the app actually needs¶
| Access | Who | Code |
|---|---|---|
| List members of my org(s) | Org member | hooks/useOrganization.ts L49–56 — .eq('organization_id', resolvedOrgId) |
| Own membership rows | Self | radiocheck-web/src/pages/Dashboard.tsx — .eq('user_id', userId) |
| Join / leave / admin CRUD | Self or org admin | hooks/useOrganization.ts, RPC join_organization |
| last1.app org admin | Service role | server/routes.ts, staff-validation.ts |
No anon or global JWT need. A user should see memberships only for organizations they belong to (plus their own user_id rows across orgs).
Current policy (live)¶
(INSERT/DELETE policies are already scoped; live DELETE policy has a known typo om.organization_id = om.organization_id — fix separately if delete misbehaves.)
Proposed migration (draft SQL)¶
DROP POLICY IF EXISTS "Members can view org memberships" ON public.organization_members;
CREATE POLICY "Members can view memberships in their organizations"
ON public.organization_members FOR SELECT
USING (
user_id = auth.uid()
OR EXISTS (
SELECT 1
FROM public.organization_members om
WHERE om.organization_id = organization_members.organization_id
AND om.user_id = auth.uid()
)
);
Re-tests¶
| Test | Expected |
|---|---|
Anon GET /rest/v1/organization_members |
0 rows |
Ryan JWT → all organization_members |
only rows for orgs Ryan belongs to (not all 30) |
| Ryan JWT → WIN org members list + user embed | ≥1 rows with nested users |
Unrelated user JWT → WIN organization_id=eq.<win> |
0 rows unless member |
| iOS org tab / radiocheck-web dashboard | Manual smoke |
B4 — public.units (invite_code exposed)¶
What the app actually needs¶
| Access | Who | Needs invite_code? |
Code |
|---|---|---|---|
| Unit detail / settings | Unit member or leader | Yes (share code with squad) | hooks/useUnit.ts L31–35 select('*') |
| My squads list | Member / leader / creator | Often yes on unit card | hooks/useUserUnits.ts L52–57 |
| Join by code | Authenticated non-member | Lookup by code once, then join | lib/joinUnitByInvite.ts L10–15, hooks/useUnit.ts L119–124 |
| Managed units | Leader | Yes | hooks/useManagedUnits.ts |
| Demo silo (Apple review) | Demo user | Scoped demo units | can_view_demo_data() already in live policy |
Problem: Current "Units are viewable by everyone" lets anon enumerate all units + invite_code (BC727972, etc.). Tight RLS on units breaks join-by-code unless lookup goes through a SECURITY DEFINER RPC (cannot express "filter by invite_code in query" safely in RLS alone).
Current policy (live)¶
-- Policy: "Units are viewable by everyone"
FOR SELECT USING (
((COALESCE(is_demo, false) = false) AND (demo_visibility_user_id IS NULL))
OR (demo_visibility_user_id = auth.uid())
OR can_view_demo_data()
);
Note: demo clause is correct; breadth is wrong (any role including anon passes when not demo-gated).
Proposed migration (draft SQL)¶
Step 1 — member-scoped unit SELECT (no invite enumeration):
DROP POLICY IF EXISTS "Units are viewable by everyone" ON public.units;
CREATE POLICY "Unit members and leaders can view their units"
ON public.units FOR SELECT
USING (
public.is_member_of_unit(id)
OR leader_id = auth.uid()
OR created_by = auth.uid()
OR (
((COALESCE(is_demo, false) = false) AND (demo_visibility_user_id IS NULL))
OR (demo_visibility_user_id = auth.uid())
OR public.can_view_demo_data()
)
);
Step 2 — invite join RPC (replaces client table scan):
CREATE OR REPLACE FUNCTION public.join_unit_by_invite_code(p_invite_code text)
RETURNS public.units
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_unit public.units;
v_uid uuid := auth.uid();
v_code text := upper(trim(p_invite_code));
BEGIN
IF v_uid IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
IF v_code = '' THEN
RAISE EXCEPTION 'Missing invite code';
END IF;
SELECT * INTO v_unit
FROM public.units u
WHERE upper(u.invite_code) = v_code
AND u.status = 'active'
LIMIT 1;
IF NOT FOUND THEN
RAISE EXCEPTION 'Invalid invite code';
END IF;
INSERT INTO public.unit_memberships (user_id, unit_id, role)
VALUES (v_uid, v_unit.id, 'member')
ON CONFLICT (user_id, unit_id) DO NOTHING;
PERFORM public.patch_users_row(v_uid, jsonb_build_object('unit_id', v_unit.id));
RETURN v_unit;
END;
$$;
REVOKE ALL ON FUNCTION public.join_unit_by_invite_code(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.join_unit_by_invite_code(text) TO authenticated;
Step 3 — companion app change (RadioCheck repo, not applied here):
| File | Change |
|---|---|
lib/joinUnitByInvite.ts |
Replace .from('units').ilike('invite_code', ...) with supabase.rpc('join_unit_by_invite_code', { p_invite_code: code }) |
hooks/useUnit.ts joinUnit |
Same RPC |
Leaders still read invite_code via member-scoped SELECT on units.
Re-tests¶
| Test | Expected |
|---|---|
Anon GET /rest/v1/units?select=id,invite_code |
0 rows |
Ryan JWT → all units |
only units Ryan belongs to / leads (not all 2+) |
| Anon / random JWT → enumerate invite codes | 0 rows |
Authenticated RPC join_unit_by_invite_code('VALID') |
success; membership created |
| Authenticated RPC with garbage code | error, no leak of other codes |
| iOS: join squad + unit home shows code for leaders | Manual smoke |
B5 — Edge Functions: sms-cron / org-sms-cron anon POST → 200¶
What the app actually needs¶
These are pg_cron / internal workers, not user-facing endpoints. They use service role inside the function. They must not be callable from the public internet without a shared secret.
Reference implementation: check-in-proof-events already gates on Authorization: Bearer <RADIOCHECK_CRON_SECRET> (RadioCheck/supabase/functions/check-in-proof-events/index.ts — clone path; live on VM 103).
Current state: sms-cron/index.ts and org-sms-cron/index.ts have no inbound auth (T2-1 probe: anon POST → 200, SMS pipeline runs). RADIOCHECK_CRON_SECRET is already in edge .env (T2-1 vault).
Proposed change (draft — function code, not DB policy)¶
Add to top of handler in both functions (mirror check-in-proof-events):
const cronSecret = Deno.env.get('RADIOCHECK_CRON_SECRET')?.trim();
const inbound = req.headers.get('Authorization')?.replace(/^Bearer\s+/i, '').trim() ?? '';
if (!cronSecret || inbound !== cronSecret) {
return json({ error: 'Unauthorized' }, 401);
}
pg_cron: Ensure scripts/last1app/t2-1-activate-crons.sh (or equivalent) sends Authorization: Bearer $RADIOCHECK_CRON_SECRET when calling http://192.168.1.103:8000/functions/v1/sms-cron and .../org-sms-cron.
Optional hardening: Cloudflare/WAF deny /functions/v1/sms-cron and /functions/v1/org-sms-cron except cron source IP (belt-and-suspenders).
Re-tests¶
| Test | Expected |
|---|---|
Anon POST /functions/v1/sms-cron {} |
401 |
Anon POST /functions/v1/org-sms-cron {} |
401 |
POST with Authorization: Bearer $RADIOCHECK_CRON_SECRET |
200 (or 500 if Twilio/APNs missing — not 401) |
| pg_cron job after activation | Still 200 with secret header |
Suggested migration file (for Ryan to apply)¶
Create in RadioCheck repo (example name only — do not apply from agent):
supabase/migrations/20260830120000_t5b_rls_identity_hardening.sql
Contents: B1 helpers + policies, B2, B3, B4 RPC + policies in one transaction. Deploy Edge auth patch separately or in same release branch.
Apply checklist (Ryan)¶
| # | Action | Owner |
|---|---|---|
| 1 | Review org co-member phone exposure decision (B1 option A vs B) | Ryan |
| 2 | Apply SQL migration on VM 103 (fresh morning; snapshot/backup first) | Ryan |
| 3 | Deploy Edge cron auth patch + verify pg_cron headers | Ryan |
| 4 | Merge RadioCheck iOS RPC join change (B4) if included in release | Ryan |
| 5 | Re-run t5-1-rls-security-pass.sh → expect anon leaks 0 |
Agent (after Ryan says go) |
| 6 | Manual iOS smoke: squad roster, check-ins, org tab, join-by-code | Ryan |
| 7 | If PASS → authorize Phase 2 separately (T5-B Phase 2 go) |
Ryan |
STOP¶
No policies were changed in this session. This document is analysis + proposed SQL only.
Do not expose api.last1.app until Phase 1 re-run passes after remediation.
References¶
- Phase 1 report: SPRINT7_DB_PUBLIC_EXPOSURE_RLS_SECURITY_PASS.md
- Re-test script:
scripts/last1app/t5-1-rls-security-pass.sh - Edge auth model: SPRINT7_T2-1_REPORT.md
- Onset PASS pattern: ONSET_DB_PUBLIC_EXPOSURE_RLS_SECURITY_PASS.md
- Existing helper:
is_member_of_unit(uuid)— migration20260521150000_unit_memberships_visible_to_squadmates.sql