Skip to main content

Permissions and tenancy

How Complied decides what a caller may see and do: tenant isolation first, then permission keys inside a tenant, then record-level scope, and the special callers (edge functions, platform operators, portal clients, collaborator tenants). Read it before writing a policy, a migration that adds a table, or an edge function that runs as the service role.

The model​

  • A tenant owns roles (permission_bundles).
  • A role is a set of permission keys (permission_bundle_grants).
  • Every staff profile holds exactly one role (profiles.permission_bundle_id).
  • The only thing ever checked is has_permission('key'): in RLS policies, triggers, SECURITY DEFINER functions and edge functions.
  • No policy reads a role's name. Names are labels an admin can rename freely.

Tenant membership alone opens nothing: every policy on a table with a tenant_id checks a key.

Tenancy​

Isolation is Postgres RLS on tenant_id, not a database per customer. Three helpers, all SECURITY DEFINER with a pinned search_path and EXECUTE revoked from anon, resolve the caller:

HelperReturnsNotes
current_tenant_id()The caller's tenant, or nullThe active impersonation target first, then the caller's own active profile's tenant. A deactivated profile, a portal user, a non-impersonating operator and an anonymous caller get null
current_client_id()The caller's client, or nullFrom an active client_users row. Null for staff
is_platform_operator()Whether the caller has cross-tenant reach: an active operator not inside an impersonation sessionThe is_platform_operator() or … branch in RLS, and the operator console's RPCs
is_platform_operator_account()Whether the caller is an active operator, impersonating or notIdentity only: whoami()'s account type and start_impersonation(). Impersonation never changes who is an operator

Because current_tenant_id() follows an impersonation session and is_platform_operator() is false for its length (20261025100000_operator_console.sql), an operator acting as a tenant sees exactly what that tenant sees through every ordinary tenant_id = current_tenant_id() policy; no policy special-cases impersonation, and the operator OR-branches no longer mix other tenants' rows into the tenant's lists.

A tenant's subdomain (secureenv.<domain>) is a hint, never an authorization: AuthProvider signs a login out when the host names a different tenant than the account's, but RLS is what scopes data.

The three data classes​

ClassPolicy shapeExamples
Public referenceAny signed-in user may read (USING (true) is allowed only here). Writes are service-role or migration onlybuildings, units (select), public_events, document_types, permissions, license_types, XRF catalogs, compliance_programs, map_* taxonomy
Tenant-ownedtenant_id = (select current_tenant_id()) and a permission key. A few field and project tables add a narrow collaborator branch and a portal branchclients, projects, inspections, documents, invoices, licenses
Platform / lockedOperators only, or no authenticated grant at alltenants (writes), platform_operators, impersonation_sessions, email_outbox, email_send_log, email_suppressions, map_data_version

USING (true) never appears on tenant, user or financial data. Public reference tables never take a tenant's own facts: tenant-authored facts about a building live on tenant_buildings, and about an apartment in the obligation tables.

Layers​

LayerWhereRole
RLS and triggerssupabase/migrations/The boundary. Every read and write is checked here
Edge functionssupabase/functions/_shared/auth.ts authorize(key)Service-role functions check the key, then probe the target through the caller's own client so RLS decides visibility. Money functions also require the owner tenant (callerIsOwnerTenant)
map-servicemap_tenant_overlay()Returns nothing without view_map; the service answers 403
UIusePermission, useAnyPermission, src/auth/routeAccess.tsUX only: hides what RLS would refuse. The route guard, sidebar, command palette and post-login landing all read one route map

The browser loads the caller's keys with my_permission_keys(), which applies implication and impersonation, so a UI gate cannot disagree with has_permission().

The catalog​

Keys are grouped into areas. In each area a view_* key reads and the manage_* / perform_* keys write. A key can imply others (permissions.implies, one level, not transitive): manage_projects answers true for view_projects. Postgres needs a row to be visible before it can update it, so a manage key without its view key would be a broken grant. The Roles page shows implied keys as checked and locked.

AreaKeyOpensImplies
Team and settingsmanage_tenantInvite, deactivate and assign staff; edit roles. A last-admin guard keeps at least one active holder per tenantview_team
view_teamStaff roster and role matrix
manage_settingsCompany and client branding, report templates (tables and storage), the firm's document requirements
view_audit_logaudit_log, impersonation_sessions
Clientsview_clientsClients, portal logins list, client branding
manage_clientsCreate and edit clientsview_clients
manage_portal_accessclient_users writes; the invite-client-user edge function (client page → Portal access)view_clients
manage_work_ordersThe work-order queue: read clients' portal work orders, accept (creates a project) or decline themview_clients
Buildingsview_buildingsTracked buildings, units, violations
manage_buildingsTrack and untrack, assign client, contacts, unit corrections, apartment matching (merge or keep apart, AI suggestions); approve or decline clients' portal building requestsview_buildings
Projectsview_projectsEvery project in the tenant
manage_projectsCreate and edit projects, order decisions, event linksview_projects
advance_projectsChange phase, phase_substate, side_state (trigger-enforced)view_projects
manage_collaboratorsInvite and revoke collaborator tenantsview_projects
Field workview_field_jobsEvery field job, its capture data, and those jobs' projects
manage_inspectionsCreate, schedule, assign and cancel jobs; log contact attempts. Only this key reassigns a jobview_field_jobs
perform_field_workXRF, rooms, checklists, samples, chain of custody, photos, notes, field reports
perform_abatement_workAbatement scope, work, crew, time, materials, safety checks
review_xrf_reportsMove an XRF review; a new review may start as anything but pending only with this key; sign off a project (project_reviews)view_field_jobs
manage_equipmentXRF instruments, laboratory partners
manage_floor_plansThe floor-plan queue: claim, upload, approve
Documentsview_documentsgeneral and field_report documents, document orders, filing packages, the documents bucket
manage_documentsUpload, generate, sign, notarize, void and supersede a general or field_report document; share it with a client; orders; filing packagesview_documents
Obligationsview_obligationsRPO, compliance window, exemptions, monitoring visits, obligations; the Deadlines page and the building/apartment deadline panels
manage_obligationsRecord those factsview_obligations
Licensesview_licensesLicenses, license checks, license files (also readable by the person they are about)
manage_licensesAdd and edit licenses and files; override a failed checkview_licenses
Financialsview_financialsInvoices, line items, vendor payments, proposals, rate cards, abatement rates
manage_invoicesInvoices, line items, vendor payments, invoice numbersview_financials
manage_proposalsProposals, their lines and PDFsview_financials
manage_rate_cardsRate cards, abatement ratesview_financials
Notificationsmanage_notificationsThe tenant notification log, manual notify, template preview
Mapview_map/map and its smart search

public.permissions is the authority. src/auth/permissionKeys.ts mirrors it for type-checking in the frontend and the edge functions, and db-tests/tests/permissions-matrix.test.ts fails if the two drift.

Presets​

permission_preset_grants is the only copy of each preset's default keys. A new tenant is seeded by seed_default_permission_bundles() (a trigger on tenants), and the Roles page's "Reset to defaults" reads the same table. A preset role records its origin in permission_bundles.preset, so a rename does not break the reset.

PresetKeys
Tenant AdminEvery key
Project Managerview_team, manage_clients, manage_buildings, manage_projects, advance_projects, manage_collaborators, manage_inspections, perform_field_work, perform_abatement_work, manage_equipment, manage_floor_plans, manage_documents, manage_obligations, view_licenses, view_financials, manage_proposals, view_map
Inspectorperform_field_work, view_map. Scope comes from assignment
Back Officeview_team, view_clients, view_buildings, view_projects, view_field_jobs, manage_documents, view_obligations, manage_licenses, manage_invoices, manage_proposals, manage_rate_cards, manage_notifications, view_map

Record-level scope: field-job assignment​

Assignment is the one record-level rule. It reuses columns that already exist; there is no assignments table:

  • inspections.assigned_inspector_id: the inspector on a job
  • abatement_crew.profile_id: crew members on an abatement job
  • floor_plans.assigned_to_artist_id: the artist on a floor plan

my_inspection_ids(), my_project_ids() and my_building_ids() are SECURITY DEFINER so policies can use them without recursing through RLS. Without view_field_jobs or view_projects, a user sees:

  • Field jobs: only the jobs assigned to them, plus those jobs' capture rows. Children with a nullable or missing inspection_id use project-level assignment.
  • Projects: only the projects of those jobs. view_field_jobs also opens the project of any job it can see, because the capture tables' tenant-sync triggers resolve through the project.
  • Tracked buildings: only the buildings of those projects.

Money and obligations are never opened by assignment; they always need their own view key.

Keys on the record tables added with the redesign​

No new key: each table takes the keys of the area it belongs to (20261021090000-090400).

TableReadWriteNotes
client_contactsview_clients; the client's own portal logins (client_contacts_select_portal)manage_clientstenant_id from the client (sync_tenant_id_from_client)
invoice_paymentsview_financialsmanage_invoices, as the caller (created_by)Owner tenant only, no collaborator or portal branch (D4.3). Reaching the total marks the invoice paid, running as the caller, whose key already allows that update
invoice_collection_notesview_financialsmanage_invoices, as the callerAppend-only: select and insert grants only
document_requirement_overridesview_projects, view_documents or manage_settingsmanage_settingsThe checklist reads it wherever documents are shown; only Settings writes it
project_reviewsview_projects or review_xrf_reportsreview_xrf_reports, as the caller (reviewed_by)Append-only. tenant_id from the project, so the reviewer must be able to see the project

The new columns on tenant_buildings (property manager), inspection_contact_attempts (contact name, outcome, notes), unit_rpo_records (window guards, stove knob covers), unit_compliance_window (friction surfaces) and invoices (due_date) take their tables' existing policies.

Column-level guards​

Some tables let anyone with row access update the row but restrict particular columns, using a BEFORE UPDATE trigger. Service-role writes (no auth.uid()) skip these checks.

TriggerRule
projects_check_update_permissionsChanging phase, phase_substate or side_state needs advance_projects; changing any other column needs manage_projects
inspections_check_dispatch_permissionsChanging assigned_inspector_id or project_id needs manage_inspections; a crew member with a capture key can still record the visit
documents_shareTurning client_visible on or off needs manage_documents, whatever the document's access class
tenant_buildings_guard_contactsThe six on-site contact columns (occupant, superintendent, property manager) need manage_buildings, while client assignment stays open to more staff
documents_guard_immutable, documents_stamp_voidA document past draft only moves forward and may only change sign, share, void and version fields (documents)

Documents: access class and level​

document_types.access_class, not view_documents or manage_documents alone, decides who reads and writes a document. The policy is documents_select; writes go through document_writable(), which the storage policies share.

Access classReadWrite
generalview_documentsmanage_documents
field_reportview_documents, or view_field_jobs, or an assigned job on the document's project, or a collaborator grant whose role is in the type's collaborator_roles (plus one of those keys on their own side)manage_documents; the report generators write as the service role after their own authorize()
financialview_financialsmanage_proposals or manage_invoices
credentialview_licenses, manage_inspections, perform_abatement_work, or the profile the document is aboutmanage_licenses

level narrows further, independent of access class:

  • platform_internal: platform operators only.
  • platform_to_tenant: that tenant's manage_settings holders.
  • platform_published: every active tenant's staff, never the portal.
  • user: also readable by the profile the document is about, whatever their permissions.

The /documents route opens for any of view_documents, view_financials, view_licenses or view_field_jobs; holding one shows the documents that key reads. The client-portal rule, sharing and versioning are in documents.

Special callers​

CallerAccess
Edge functions as service roleBypass RLS. Each calls authorize(supabase, req, 'key') first (see below) and, for anything touching an owner's financials, callerIsOwnerTenant
Platform operator, not impersonatingKeeps is_platform_operator() policy branches (cross-tenant read) and the public data, and may call the operator_* RPCs. Holds no tenant keys
Platform operator impersonatinghas_permission returns true for every key while the operator has an active, unexpired impersonation_sessions row; current_tenant_id() returns the target; is_platform_operator() is false, so the operator sees that tenant only and the operator_* RPCs refuse. Sessions carry a required reason and are audited
Client-portal userHolds no keys. Scoped by client scope (below), and only while the client_users row is active. Reads its client's buildings and projects, documents shared with it, and non-draft invoices billed to it; a filing package only when it contains a document the client can see
Collaborator tenantKeeps a project_collaborators role branch on the project's field tables and additionally needs its own key (for example perform_field_work). Financial tables never have a collaborator branch. clearance_sampling has no field-table RLS branch; it appears only in document collaborator_roles
Triggers and service role with no auth.uid()Skip the two column-level guard triggers

Client scope (the portal)​

Keys answer "what may this staff role do across the tenant?". A portal login asks a different question, "which records are mine?", so it holds no keys and no staff role change can widen what it sees. Built in 20261001080000_client_scope.sql:

  • Helpers, shaped like my_project_ids(): current_client_id(), my_client_tenant_id(), my_client_building_ids(), my_client_project_ids(). All SECURITY DEFINER, all check client_users.is_active, so a login whose access is turned off loses it on its next query.
  • Portal policies are their own policies, never an or inside a staff one: <table>_select_portal, select-only, calling a client-scope helper and no key helper. The policy lint in permissions-matrix.test.ts enforces the shape and fails any other policy that mentions client scope.
  • The portal never writes a table. Its writes (work orders, building requests) go through SECURITY DEFINER functions that derive the client from auth.uid() and check every id handed to them.
  • Columns, not just rows: portal surfaces read through functions that name client-safe columns (portal_projects(), portal_obligation_facts()), because RLS picks rows only.
  • One user, one account type: a trigger keeps a user id in only one of profiles, client_users and platform_operators. whoami() answers kind, tenant, client and keys in one call, for routing and display only. A signed-in user with no account type is signed out.

Every login of one client company sees the same data; there are no client sub-roles.

Collaborator roles are field_execution, abatement, clearance_sampling and lab_coordination (domain/src/collaboration/collaboratorRoles.ts). Field-table writes use has_active_collaboration_grant_for_role(project, tenant, roles): field_execution covers inspections, XRF, floor plans and inspection_events (a visit's links to the violations it was created for; the Work tab matches visits to tracks by service and no longer reads them, the report functions still do until #403 C-3); lab_coordination adds samples and chain of custody; abatement adds the abatement tables. Project reads use has_active_collaboration_grant(project, tenant), which any active grant satisfies.

Edge function authorization​

FunctionKeyExtra check
generate-xrf-report, ingest-xrf-csv, generate-cocperform_field_workProbes the target through the caller's client
generate-paint-chip-reportperform_field_workProbe, then owner tenant or a field_execution collaborator
generate-dust-wipe-reportperform_field_workProbe, then owner tenant or a field_execution / clearance_sampling collaborator
generate-abatement-reportperform_abatement_workProbe, then owner tenant or an abatement collaborator
generate-hpd-document, assemble-filing-packagemanage_documentsProbe, then owner tenant only
generate-proposal-pdfmanage_proposalscallerIsOwnerTenant
generate-invoice-pdfmanage_invoicescallerIsOwnerTenant
sign-documentNone: a client-portal login, not a keyThe caller's current_client_id() must be the request's client and its client_users row active; record_document_signature() repeats both under the row lock
notify, preview-email-templatemanage_notificationsnotify also restricts a human caller to recipients inside its own tenant
invite-staffmanage_tenantTenant taken from the caller's current_tenant_id(), never the body
process-email-outbox, process-notification-digestEMAIL_OUTBOX_CRON_SECRET or platform-operator JWTverify_jwt = false; service-role key is not accepted as bearer
handle-email-suppression, handle-email-unsubscribe, request-password-resetNone (public entry points)Svix signature; one-time token; unauthenticated by necessity

A caller-scoped probe only proves the project is visible, and projects_select shows it to every collaborator grant whatever its role. So the five functions above that write with the service-role client after the probe also call callerMayActOnProject(). Its per-function role table lives in supabase/functions/_shared/projectAccess.ts, and it fails closed on any lookup error.

authorize() accepts either the service-role key as the bearer or a user token whose owner passes has_permission(key) or is_platform_operator(), evaluated against a client scoped to that caller's own token.

The operator console​

/hq (GitHub #292, platform console) is Complied's own staff reading across tenants. It is not opened by permission keys, which are a tenant's: the route guard (RequireOperator, operatorAccess() in src/auth/routeAccess.ts) admits an operator account outside an impersonation session, canOpenPath() refuses /hq for any set of keys, and an operator signs in onto /hq (defaultLandingPath()). The server is the boundary all the same:

Read or writeHow it is enforced
Tenants, profiles, client logins, clients, buildings, branding, audit log, impersonation sessionsThe existing is_platform_operator() RLS branches
operator_list_users(search, tenant, kind, limit)SECURITY DEFINER, raises unless is_platform_operator(). Every login (staff, portal, operator) with auth.users.last_sign_in_at, which authenticated cannot read
operator_tenant_summaries()Same gate. Per-tenant counts in one query
operator_storage_summary(), operator_database_summary()Same gate. storage.objects by bucket and tenant (paths start with the tenant id), table and database sizes
operator_set_profile_active(profile, active, reason), operator_set_portal_login_active(client_user, active, reason)Same gate, a required reason, then an audit_log row with target_tenant_id = the tenant (so its view_audit_log holders read it) and acting_tenant_id null (a platform action). The last-admin guard still applies

Every one ships revoke execute … from public, anon and a grant to authenticated; a tenant admin calling them gets an exception, never a slice of the answer (db-tests/tests/operator-console.test.ts). On profiles and client_users the operator RLS branch covers reads only (20261026090000_operator_writes_via_audited_rpcs.sql): an operator outside a View-as session cannot insert, update or delete them directly, so every operator change goes through the audited functions above. Inside a View-as session the operator passes the tenant's own branch, as its admin would.

View as (impersonation). start_impersonation(tenant, reason, minutes) (1–480 minutes, reason required, closes any earlier session) and stop_impersonation() write impersonation_sessions and an impersonation.start / impersonation.stop row in audit_log that the tenant reads. whoami() returns the active session as impersonation (tenant name, reason, start, end); the staff shell draws its "Viewing as" banner from it, and the browser re-asks whoami() when it expires. During the session the operator holds every key for that tenant and nothing beyond it.

Previews​

The top bar's View as control (ViewAsMenu, viewAsChoices() in src/auth/routeAccess.ts) offers an operator in the console View as tenant (the impersonation above), and a tenant admin (manage_tenant) or an operator viewing a tenant two read-only previews. Neither swaps a token or signs in as anyone; both live in the browser tab (sessionStorage, src/lib/viewAsPreview.ts) and apply only to the login and tenant that started them.

PreviewWhoWhat changesServer
Staff rolemanage_tenantAuthProvider exposes the role's keys (its grants plus implies), intersected with the caller's own, as permissionKeys; usePermission, canOpenPath, the rail, the role workspace and the command palette read them. realPermissionKeys keeps the caller's ownUnchanged: every query runs as the caller and RLS decides, so the preview can only hide things
Client portalmanage_tenant and view_clients/portal/preview/:clientId calls start_portal_preview(), then /portal opens for staff (portalAccess()), with PortalPreviewScope giving the portal pages the client's idThe portal's reads are scoped to the client by the database (below)

Read-only. src/data/previewGuard.ts wraps the Supabase client's fetch: while a preview is on, a table write, a POST to an RPC not in READ_RPCS, a storage upload or delete, and every edge function call are answered in the browser with a 403 "Previewing — changes are off". The RPC lists are an allowlist; previewGuard.test.ts fails for an RPC in src/data that is in neither list. The shared Button also disables submit buttons and <Button write> meanwhile.

Client scope for a preview (20261025110000_view_as_previews.sql). The portal's reads never name the client; they rely on current_client_id(), the my_client_* helpers and the *_select_portal policies. So instead of a second set of read functions, a preview makes those helpers answer for the previewed client, one request at a time:

  • The preview tab sends x-complied-preview-client: <client id> on PostgREST requests (not on whoami, my_permission_keys or the preview controls).
  • portal_preview_client_id() honours it only when the request runs in a read-only transaction (PostgREST runs GET/HEAD and STABLE RPCs read-only, every write read-write), the caller is not a portal login, the caller's tenant (staff_tenant_id(): their own, or the one an operator is viewing) owns the client, and the caller holds view_clients (staff_has_permission()).
  • While it is honoured, current_client_id() returns that client, has_permission() returns false and current_tenant_id() null, so every staff branch closes and the request sees exactly the client's portal rows (no draft invoices, only shared documents, no other client's buildings).
  • A write is never read-only, so the header is ignored there and the portal's write functions refuse the caller as not a portal login: a preview cannot act as the client even if the browser guard were bypassed.

staff_tenant_id() and staff_has_permission() are the previous bodies of current_tenant_id() and has_permission(), kept under their own names so the preview check does not recurse.

FunctionGate
start_portal_preview(client)SECURITY DEFINER, volatile. Refuses a portal login, a caller without a tenant or view_clients, and a client outside the caller's tenant (one message for "not yours" and "no such client"). Writes portal_preview.start to audit_log with the tenant as target, so its view_audit_log holders read it
portal_preview_client_id()SECURITY DEFINER, stable, the rule above. authenticated only
tenant_public_branding(slug)SECURITY DEFINER, stable, anon. One exact, active slug's display name, logo storage path and sidebar style, nothing else; no pattern match, no list

The sign-in page's branding. On a tenant subdomain the login page shows the tenant's name and logo before sign-in. The branding bucket stays private: a policy anon could read logos with would also let it list every tenant's folder. Instead the tenant-login-branding edge function calls tenant_public_branding() with the anon key and signs only the path it returned, for 10 minutes, with the service role. If the function cannot be reached the page still gets the name from the RPC.

db-tests/tests/portal-preview.test.ts covers the client preview (own client exactly, other tenant's admin, staff without view_clients, a portal login, an operator in and out of a session, a write) and tenant_public_branding().

Deliberately key-free​

These stay readable or writable without a key, and the policy lint allowlists the tenant-scoped ones:

  • tenant_branding select: the tenant's own app chrome (also its portal users)
  • xrf_report_assets select: shared and tenant report artwork
  • notification_log recipient update: marking your own notification read
  • audit_log insert: logging your own attributed action
  • Personal rows: saved_views, notification_preferences, your own profiles row
  • Public reference data: buildings, units (select), public_events, catalogs

Policy pattern​

-- read: tenant, then the area's view key (or assignment)
using ((select public.is_platform_operator())
or (tenant_id = (select public.current_tenant_id())
and ((select public.has_permission('view_field_jobs'))
or inspection_id in (select public.my_inspection_ids()))))
-- write: tenant (or collaborator) AND the write key AND visibility
with check (tenant_id = (select public.current_tenant_id())
and (select public.has_permission('perform_field_work'))
and (...same visibility...))

Always wrap the helpers in (select …). Postgres then evaluates them once per statement as an InitPlan (the assignment sets as a hashed SubPlan) rather than once per row. For the same reason the documents read policy is written inline rather than through helper functions: a SQL function whose body has a subquery is never inlined.

Every SECURITY DEFINER function ships a paired REVOKE EXECUTE keeping it off roles that should not call it (typically anon); scripts/ci/guardrails.sh blocks a new one in a diff without it.

Adding a key​

  1. Migration: insert into public.permissions with category, sort_order and implies. Add it to permission_preset_grants for Tenant Admin and any other preset that should hold it, and backfill existing preset bundles the way 20260928110000 does.
  2. Enforce it in the relevant policies or functions. The lint test fails for a catalog key that nothing enforces.
  3. Add it to PERMISSION_KEYS in src/auth/permissionKeys.ts. The matrix test checks the mirror.
  4. Gate the UI: usePermission('key') at the action, and src/auth/routeAccess.ts if it opens a page.
  5. Edge functions: authorize(supabase, req, 'key'). The key is typed.
  6. Add a row to db-tests/tests/permissions-matrix.test.ts.

Adding a tenant table​

  1. Add tenant_id uuid not null references public.tenants (id). For a child table, add a BEFORE INSERT trigger that copies tenant_id (and project_id where relevant) from the parent, so a client cannot spoof it.
  2. Write the policies in the pattern above, using the area's existing keys before inventing new ones. Every policy on a table with tenant_id must call has_permission(; the policy lint in permissions-matrix.test.ts fails otherwise.
  3. alter table … enable row level security, grant to authenticated only what the policies need, and revoke from anon.
  4. Add a leakage case to db-tests/tests/leakage.test.ts and a matrix row for the key.
  5. Add the table to data-model.