Skip to content
moaz
Case study 01 · 2025–2026

Raptor

Three roles, one database, zero cross-tenant reads. Deny-by-default row-level security, shipped in the same migration as every table.

The problem

I built a fitness coaching platform for Egypt with three roles: admins, coaches, and clients. They share one Postgres database, but each role must only see its own slice of the data. That rule has to hold for every screen and every request, including requests that do not come from the expected app flow.

The product is a React Native app using Expo Router, and it also runs on the web from the same codebase. That made a single data model useful, but it also meant the database boundary had to be explicit. The app could not be the only place deciding which rows a user was allowed to see. I treated the database policies as part of the product implementation, alongside the app, payments, video, and operations work.

Architecture

Expo appSupabase AuthGoogle + emailPostgRESTPostgresRLSEdge Functions (27)LiveKit videoRealtimeservice role
The request path, plus the server-only services around the shared Postgres database.

How the isolation works

The tenancy model is based on coach_id. A coach owns a slice of the shared data, and a client belongs to that coach's slice; the current role is resolved through current_app_role(). Every table that needs protection enables row-level security, and its policies are written in the same migration that creates the table. The default is deny-by-default, so a new table does not become readable by accident while the rest of the schema is still being secured.

This puts the boundary in Postgres rather than relying on a filter in a React component or a particular PostgREST query. Admin, coach, client, anonymous, and service contexts can then be tested against the same database rules. The service role is reserved for server-only work, while normal requests use the authenticated context. I also keep the harness close to the migrations: it provisions a throwaway database, applies the schema and seed, impersonates the relevant context, and checks both allowed and denied operations. The resulting assertion is about what the database returned, not what the client intended to request.

HOW THE ISOLATION WORKS - STEP 4 OF 4

Isolation lives with the schema.

The migration creates the table, enables RLS, and writes the deny-by-default policy before a request can read it.

  1. 01

    Three roles, one schema

    Admin, coach, and client share one Postgres schema.

  2. 02

    Tenant id on every row

    Every protected record carries the coach boundary with it.

  3. 03

    Policies ship with the table

    RLS and deny-by-default policies land in the same migration.

  4. 04

    A cross-tenant request is denied

    Coach B can ask, but Coach A's clients return zero rows.

Decisions

One schema with RLS

One shared Postgres keeps the data model and migration path together. RLS makes the tenancy rule part of the database, where it applies regardless of which app surface made the request.

Rejected
Per-tenant schemas or app-layer filtering only.

Policies ship with the table, deny-by-default

I enable RLS and add the policy in the same migration as the table. That keeps the interval between creating data and protecting it out of the normal development path.

Rejected
A follow-up security pass migration.

Money as integer minor units

Payment amounts are stored as integer minor units so the value is unambiguous when it reaches Paymob settlement. I do not want floating-point or decimal formatting choices to become part of reconciliation.

Rejected
numeric(10,2) columns.

LLM features behind guards and coach review

There are nine LLM and vision features. Their output is schema-validated, prompt-injection guards run before the feature is trusted, token cost is accounted per call, and a coach reviews generated plans before a client sees them.

Rejected
Auto-publishing generated plans without human review.

Security

Numbers

288
policies
source: supabase/migrations
115
migrations
source: git log
58
tables with RLS
source: harness
341
cross-tenant assertions
source: cases.test.ts
300
commits
source: git
27
edge functions
source: supabase/functions

Harness output

The harness starts with a throwaway database, applies a Postgres shim, the 115 migrations, and a seed, then runs Vitest. Its helpers impersonate anonymous, user, and service contexts with set local role and request.jwt.claims. Each case runs inside a transaction and always rolls back. It runs on plain Postgres, so Supabase is not required for the test itself. CI provides a postgres:16 service container and runs npm run test:rls.

The table below is the real output of the last run: 341 assertions in 61 suites, generated from the vitest JSON report.

341 / 341 passed · 913 ms · 58 tables · 288 policies · generated 2026-09-03
341 cases
suitecasespassedmsdetails
tenant isolation — cross-tenant reads return 0 rows4417
positive controls — owners and coaches see what they should336
water_logs (0096) tenancy227
support_tickets (0110) tenancy4410
privilege escalation & mass-assignment are rejected556
profile bootstrap — signup creates the profile server-side224
service_role is the trusted server path222
schema invariant — every public table has RLS enabled (§2)112
custom access token hook — role is injected server-side from the DB221
invitations — coach-scoped, server-side assignment (§2)556
assignment functions (0006) — service-side coach_id writes (§2)9910
plans v2 (0010) — templates, library, hierarchy, clone (§2)161632
coach applications (0011) — client applies, admin reviews, role flip (§2)101011
messages (0012) — coach⇄client DMs, server-set sender, rate limit (§8)7720
media (0013) — owner/coach/admin read, writes are service-role-only (§2/§7)6616
plans v3 (0014/0015) — weeks, system templates, clone, duplicate (§2)101031
completion logging (0016) — sessions, set logs, derived metrics (§2)111124
profiles & goals (0017) — athlete_profile + coach_profile (§2)6610
food logging (0019) — diary, targets, daily roll-up, streak (§2)121222
food preferences (0020) — athlete-owned likes/avoids, coach-readable (§2)6610
workout logging (0021) — exercise PRs + athlete→coach feedback notes (§2)6612
exercise tracking modes (0098) — bodyweight snapshot, assisted loads, PR folding5510
workout note delete keeps the chat copy (0056) — FK SET NULL is not an edit112
workout note: hide vs delete-for-everyone (0060)225
nutrition system templates (0022) — clonable global starters (§2)226
one published plan per type (0024) — publishing supersedes the previous112
exercise unit prefs (0025) — per-exercise kg/lb, athlete-owned336
body metrics (0026) — coach-verified, athletes never self-write (§2/§4)7711
ai usage ledger (0027) — owner-only read, service-role-only write (§9)223
InBody OCR confirm (0026 + 12b) — coach confirms, athlete cannot self-verify333
InBody insights + comments (0028) — coach-only analysis, coach→client comments6614
coach AI plan_insights (0029) — coach-only nudges, athlete never sees them (§2)224
coach analytics (0031, Phase 15) — coach-fenced KPI engine + coach-only summary (§2)6612
notifications (0032, Phase 17) — in-app feed, prefs, event triggers (§2/§8)111125
chat safety (0034, Phase 18) — message reports + ban enforcement (§2/§8)8811
chat engagement (0036, Phase 18 Slice 2) — reactions + soft edit (§2/§8)111114
message replies (0037, Phase 18 Slice 2) — quote a message in your thread (§8)333
ban appeals (0038, Phase 18 Slice 3) — a banned user appeals; admin resolves (§2/§8)6610
chat acknowledgments (0039, Phase 18 Slice 3) — per-person disclaimer gate (§8)334
device tokens (0040, Phase 17 Slice 2) — push registry, owner-only, server-forced owner (§2)9911
app achievements (0073, E1) — minted trophies, owner-only raw read, public RPC (§2)558
coach transformations (0077, E3) — coach-curated, consent-gated showcase (§2)449
transformation submissions (0084) — client→coach, coach-approves (§2)4416
transformation manager (0087) — multi-card, photo rows, public media fix, scan pick, nudge (§2)7720
transformation client actions (0090) — request removal + reframe proposal (§2)5516
voice notes (0043, Phase 18) — audio media readable by chat participants (§2/§7/§8)446
public profiles (0044, Phase 19) — opt-in portfolio via field-allowlist RPCs (§2)131334
public leaderboards (0045, Phase 20) — opt-in physique/outcomes boards via field-allowlist RPCs (§2)7710
leaderboard period window + self-rank (0052, Slice G1)669
leaderboard snapshots (0103) — shared-fn refresh parity + deny-all snapshot (§2)226
coach_requests (0053, Slice G2) — request-a-coach funnel (§2)121214
admin console RPCs (0054, Slice G3) — in-function admin fence555
conversation previews + read-state (0058) — chat list aggregation (§2/§8)557
conversation summaries (0102) — O(clients) previews + AFTER DELETE recompute (§2/§8)226
conversation receipts (0091) — Sent/Delivered/Seen via the peer read-state (§2/§8)7710
presence (0092) — online/last-seen behind a pairing fence (§2)6610
security hardening (0061–0067)448
@handle (0069)445
calls & meetings (0083) — slots + booking asymmetry + lifecycle (§2)121214
push queue (0104) — durable enqueue + batch claim + reaper (§2/§9)335
admin_actions (0108) — append-only audit ledger, admin-read only (§7.4)444

Source: content/raptor-run/raptor-vitest.json · the generic harness is open source

What I'd change

The app repository is private. The generic harness is public at the link above. This site's source is separate from the app repository.