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 is an active operatorImpersonation never changes who is an operator

Because current_tenant_id() follows an impersonation session, an operator acting as a tenant sees exactly what that tenant sees through every ordinary tenant_id = current_tenant_id() policy; no other policy special-cases impersonation.

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)
view_audit_logaudit_log, impersonation_sessions
Clientsview_clientsClients, portal logins list, client branding
manage_clientsCreate and edit clientsview_clients
manage_portal_accessclient_users writesview_clients
Buildingsview_buildingsTracked buildings, units, violations, deadlines
manage_buildingsTrack and untrack, assign client, contacts, unit correctionsview_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 keyview_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
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.

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 four on-site contact columns 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 and the public data. 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. Sessions carry a required reason and are audited
Client-portal userHolds no keys. Scoped by current_client_id(), 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

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; 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-dust-wipe-report, generate-paint-chip-report, generate-cocperform_field_workProbes the target through the caller's client
generate-abatement-reportperform_abatement_workSame
generate-hpd-document, assemble-filing-packagemanage_documentsSame
generate-proposal-pdfmanage_proposalscallerIsOwnerTenant
generate-invoice-pdfmanage_invoicescallerIsOwnerTenant
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
sync-orchestratormanage_tenant, and a platform operator or service roleA scoped CRON_SECRET is accepted for the nightly per-feed call only
process-email-outbox, process-notification-digestService-role bearerverify_jwt = false
handle-email-suppression, handle-email-unsubscribe, request-password-resetNone (public entry points)Svix signature; one-time token; unauthenticated by necessity

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.

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.