4. Two-client model: RLS for reads, service role for privileged writes
Date: 2026-06-06
Status
Accepted — amended 2026-08-06 (see Amendments below; the rule of thumb still stands).
Context
The app handles PII and money across three audiences (admins, contractors, facility contacts) and is multi-tenant-ready (company_id on every table). We need defense in depth: a bug in application logic must not be able to leak one contractor's or one company's data to another. Supabase offers Row Level Security (enforced in Postgres) and a service-role key (which bypasses RLS).
Decision
Exactly two ways to construct a Supabase client, both in src/db/clients/:
createUserClient(accessToken)— anon key + the user's JWT, so all RLS policies apply. Used for reads in Server Components and user-scoped queries. This is the safety net.createServiceClient()— service-role key, bypasses RLS.import 'server-only'makes a client-bundle import a build error. Used only in server actions, webhook handlers, and cron handlers, and only after the action independently verifies the caller's identity and role (or checks the request HMAC / shared secret).
RLS policies (migration 0003) scope every table by company_id and role via SECURITY DEFINER helper functions in a private app schema (app.is_admin(), app.is_owner(), app.current_company_id(), app.current_contractor_id()) — placed in app so they are not exposed through the PostgREST API. Contractor self-service writes (logging hours, leave requests) go through RLS-permitted policies; all admin/owner writes go through the service client behind explicit role checks. Audit logging is enforced by database triggers, independent of either client.
Rule of thumb: RLS guards the read path; explicit role checks guard the write path. Never rely on only one.
Consequences
- Cross-tenant / cross-role reads are blocked by Postgres even if app code is wrong.
- The service client is powerful and quarantined to
server-onlymodules behind verification; it must never be imported into client components. - Some access rules that can't be a single-table policy (contractor → invoices) are expressed as a contractor self-select policy on line items plus an
EXISTSpolicy on invoices (migration 0005), avoiding a SECURITY DEFINER view. - Integration tests must assert RLS per role/tenant against a real Postgres (planned in the testing phase); the security advisor is run after every schema change (currently clean).
Amendments
2026-08-06 — the user client is createServerSupabase(), and hours-table self-service writes no longer go through RLS.
createUserClient(accessToken)never got wired up. The App Router made the cookie-backedcreateServerSupabase()(src/db/clients/server.ts) the user client instead;user.tssat unused with zero importers for two months while describing itself as the defense-in-depth path, which is worse than not existing — it reads as coverage that isn't there. Deleted. The two-client model is unchanged; only the constructor's name and its token source are.- Migrations 0102 (
time_entries) and 0103 (time_segments) removed every write policy from both hours tables and revoked the write grants fromanon/authenticated. So for those two tables the sentence "contractor self-service writes go through RLS-permitted policies" no longer holds: every write is a service-role server action, behind the locked / approved / invoiced / ended-assignment guards (ADR-0050 §3). The policies were never on a live path — nothing in the product ever posted hours under the contractor's own JWT — but they granted a capability only an attacker would use: a contractor couldPATCHtheir own already-invoiced hours straight over PostgREST, and any staff admin couldPATCHorDELETEanyone's, never meeting an app guard.
The rule of thumb survives both, with the write half doing more of the work than it did in 2026-06: RLS guards the read path; explicit role checks guard the write path. Leave rules on the tables that carry billed or paid money, and the read path is the only thing RLS should be asked to enforce.