Database & RLS
The database is a self-hosted PostgreSQL (via Supabase). It is the enforcement point for multi-tenant isolation.
Conventions
- Every table:
id UUID PRIMARY KEY DEFAULT gen_random_uuid(). - Every brand-scoped table:
tenant_id UUID NOT NULLandbrand_id UUID NOT NULLreferencingbrands(id). - Mutable tables:
created_at+updated_atwith an auto-update trigger. - Immutable streams (
player_events,balance_transactions):created_atonly — never updated or deleted. - RLS policies always include tenant/brand isolation.
- Migrations are sequential, in
supabase/migrations/, namedNNN_description.sql, and added to the SQL test harness (supabase/tests/run.sh).
Migrations
Migrations are plain SQL applied in order. New ones are written to be
idempotent where practical (CREATE TABLE IF NOT EXISTS, ADD COLUMN IF NOT EXISTS, CREATE OR REPLACE FUNCTION, DROP POLICY IF EXISTS before
CREATE POLICY) so re-applying a batch is safe.
After a schema change, regenerate the DB types:
supabase gen types typescript --local > packages/db/src/types.tsRow-Level Security
RLS is the core of isolation. Rather than repeating tenant/brand subqueries in
every policy, policies call SECURITY DEFINER helper functions:
| Helper | Returns |
|---|---|
auth_tenant_ids() | Tenant ids the current user can see (all, for super_admin). |
auth_brand_ids() | Brand ids the current user can see (all, for super_admin). |
auth_effective_permissions(user,brand) | The user’s effective permissions on a brand. |
auth_has_permission(perm, brand) | Whether the user holds perm on a brand. |
auth_has_tenant_permission(perm,tenant) | Whether the user holds perm in a tenant (super-aware). |
A typical policy:
CREATE POLICY "Users can view assigned brands' players"
ON players FOR SELECT
USING (brand_id IN (SELECT auth_brand_ids()));Write policies additionally check a permission, e.g.
WITH CHECK (auth_has_permission('players:write', brand_id)).
All helpers set a locked search_path and are SECURITY DEFINER so they can
read the RBAC tables regardless of the caller’s own RLS.
The Platform Owner
auth_tenant_ids() / auth_brand_ids() return everything for a
super_admin, and auth_has_tenant_permission short-circuits true for them — so
the Platform Owner operates cross-tenant through the normal RLS-scoped app, with
no separate admin bypass. See ADR-013 and
RBAC & permissions.
Testing the data layer
supabase/tests/run.sh applies every migration + seed to a clean Postgres and
runs SQL assertions for brand isolation, brand scoping, write permissions, RBAC,
and per-user overrides — no Docker/Supabase stack required. Run it against a
throwaway database:
DATABASE_URL=postgresql://user@host:port/db supabase/tests/run.shAdd each new migration’s filename to the MIGRATIONS list in that script.