Multi-tenancy concepts
Cinderblock's tenant model is workspace-scoped. Every row in every public-schema table belongs to a workspace, and RLS evaluates membership on every read and write.
Workspace
The tenant boundary. A workspace has a slug, a name, a billing email, and a soft-delete window. Slugs are globally unique (mirrors GitHub / Slack) and live in URL paths (/app/[workspace_slug]/...). Reserved slugs (app, api, www, admin, …) live in app_private.reserved_slugs.
Roles (highest-privilege-first)
- owner — billing, role changes, workspace deletion, impersonation start
- admin — invite + role changes within {admin, member, guest}, impersonation
- member — create / edit tasks; sees own actor audit events only
- guest — read-only; no audit log access
The workspace_role enum is declared highest-first so native Postgres enum comparison (role <= 'admin') reads as "at-least-this-role" with no ordinal gymnastics.
URL is the source of truth
Workspace context lives in the URL path, not in a session GUC or a JWT claim. Every server-rendered route resolves slug → UUID, verifies membership via is_workspace_member(workspace_id), and 404s on miss. Queries pass workspace_idexplicitly; policies key off the row's own column.
This sidesteps a common Supabase pitfall: a session-scoped app.current_workspace_id GUC leaks between pooled connections (transaction-mode pgbouncer reuses connections across callers; set localdoesn't survive between supabase-js HTTP requests). The connection-pool safety tests in tests/09_pool_safety.sql assert the GUC stays unset.
Invitations
workspace_invitations.INSERT is closed at the policy layer (with check (false)). The only path is the invite-create Edge Function under service-role, with HMAC-signed tokens whose hash lives in token_hash. Even a row leak doesn't reveal the raw token.
Audit log
Append-only via grant model: cb_audit_writer has INSERT-only on audit_events, no SELECT/UPDATE/DELETE grants anywhere. Even a compromised Next.js process can't rewrite or read history.