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 DEFINERfunctions 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:
| Helper | Returns | Notes |
|---|---|---|
current_tenant_id() | The caller's tenant, or null | The 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 null | From an active client_users row. Null for staff |
is_platform_operator() | Whether the caller is an active operator | Impersonation 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
| Class | Policy shape | Examples |
|---|---|---|
| Public reference | Any signed-in user may read (USING (true) is allowed only here). Writes are service-role or migration only | buildings, units (select), public_events, document_types, permissions, license_types, XRF catalogs, compliance_programs, map_* taxonomy |
| Tenant-owned | tenant_id = (select current_tenant_id()) and a permission key. A few field and project tables add a narrow collaborator branch and a portal branch | clients, projects, inspections, documents, invoices, licenses |
| Platform / locked | Operators only, or no authenticated grant at all | tenants (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
| Layer | Where | Role |
|---|---|---|
| RLS and triggers | supabase/migrations/ | The boundary. Every read and write is checked here |
| Edge functions | supabase/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-service | map_tenant_overlay() | Returns nothing without view_map; the service answers 403 |
| UI | usePermission, useAnyPermission, src/auth/routeAccess.ts | UX 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.
| Area | Key | Opens | Implies |
|---|---|---|---|
| Team and settings | manage_tenant | Invite, deactivate and assign staff; edit roles. A last-admin guard keeps at least one active holder per tenant | view_team |
view_team | Staff roster and role matrix | ||
manage_settings | Company and client branding, report templates (tables and storage) | ||
view_audit_log | audit_log, impersonation_sessions | ||
| Clients | view_clients | Clients, portal logins list, client branding | |
manage_clients | Create and edit clients | view_clients | |
manage_portal_access | client_users writes | view_clients | |
| Buildings | view_buildings | Tracked buildings, units, violations, deadlines | |
manage_buildings | Track and untrack, assign client, contacts, unit corrections | view_buildings | |
| Projects | view_projects | Every project in the tenant | |
manage_projects | Create and edit projects, order decisions, event links | view_projects | |
advance_projects | Change phase, phase_substate, side_state (trigger-enforced) | view_projects | |
manage_collaborators | Invite and revoke collaborator tenants | view_projects | |
| Field work | view_field_jobs | Every field job, its capture data, and those jobs' projects | |
manage_inspections | Create, schedule, assign and cancel jobs; log contact attempts. Only this key reassigns a job | view_field_jobs | |
perform_field_work | XRF, rooms, checklists, samples, chain of custody, photos, notes, field reports | ||
perform_abatement_work | Abatement scope, work, crew, time, materials, safety checks | ||
review_xrf_reports | Move an XRF review; a new review may start as anything but pending only with this key | view_field_jobs | |
manage_equipment | XRF instruments, laboratory partners | ||
manage_floor_plans | The floor-plan queue: claim, upload, approve | ||
| Documents | view_documents | general and field_report documents, document orders, filing packages, the documents bucket | |
manage_documents | Upload, generate, sign, notarize, void and supersede a general or field_report document; share it with a client; orders; filing packages | view_documents | |
| Obligations | view_obligations | RPO, compliance window, exemptions, monitoring visits, obligations | |
manage_obligations | Record those facts | view_obligations | |
| Licenses | view_licenses | Licenses, license checks, license files (also readable by the person they are about) | |
manage_licenses | Add and edit licenses and files; override a failed check | view_licenses | |
| Financials | view_financials | Invoices, line items, vendor payments, proposals, rate cards, abatement rates | |
manage_invoices | Invoices, line items, vendor payments, invoice numbers | view_financials | |
manage_proposals | Proposals, their lines and PDFs | view_financials | |
manage_rate_cards | Rate cards, abatement rates | view_financials | |
| Notifications | manage_notifications | The tenant notification log, manual notify, template preview | |
| Map | view_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.
| Preset | Keys |
|---|---|
| Tenant Admin | Every key |
| Project Manager | view_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 |
| Inspector | perform_field_work, view_map. Scope comes from assignment |
| Back Office | view_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 jobabatement_crew.profile_id: crew members on an abatement jobfloor_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_iduse project-level assignment. - Projects: only the projects of those jobs.
view_field_jobsalso 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.
| Trigger | Rule |
|---|---|
projects_check_update_permissions | Changing phase, phase_substate or side_state needs advance_projects; changing any other column needs manage_projects |
inspections_check_dispatch_permissions | Changing assigned_inspector_id or project_id needs manage_inspections; a crew member with a capture key can still record the visit |
documents_share | Turning client_visible on or off needs manage_documents, whatever the document's access class |
tenant_buildings_guard_contacts | The four on-site contact columns need manage_buildings, while client assignment stays open to more staff |
documents_guard_immutable, documents_stamp_void | A 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 class | Read | Write |
|---|---|---|
general | view_documents | manage_documents |
field_report | view_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() |
financial | view_financials | manage_proposals or manage_invoices |
credential | view_licenses, manage_inspections, perform_abatement_work, or the profile the document is about | manage_licenses |
level narrows further, independent of access class:
platform_internal: platform operators only.platform_to_tenant: that tenant'smanage_settingsholders.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
| Caller | Access |
|---|---|
| Edge functions as service role | Bypass RLS. Each calls authorize(supabase, req, 'key') first (see below) and, for anything touching an owner's financials, callerIsOwnerTenant |
| Platform operator, not impersonating | Keeps is_platform_operator() policy branches and the public data. Holds no tenant keys |
| Platform operator impersonating | has_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 user | Holds 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 tenant | Keeps 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
| Function | Key | Extra check |
|---|---|---|
generate-xrf-report, ingest-xrf-csv, generate-dust-wipe-report, generate-paint-chip-report, generate-coc | perform_field_work | Probes the target through the caller's client |
generate-abatement-report | perform_abatement_work | Same |
generate-hpd-document, assemble-filing-package | manage_documents | Same |
generate-proposal-pdf | manage_proposals | callerIsOwnerTenant |
generate-invoice-pdf | manage_invoices | callerIsOwnerTenant |
notify, preview-email-template | manage_notifications | notify also restricts a human caller to recipients inside its own tenant |
invite-staff | manage_tenant | Tenant taken from the caller's current_tenant_id(), never the body |
sync-orchestrator | manage_tenant, and a platform operator or service role | A scoped CRON_SECRET is accepted for the nightly per-feed call only |
process-email-outbox, process-notification-digest | Service-role bearer | verify_jwt = false |
handle-email-suppression, handle-email-unsubscribe, request-password-reset | None (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_brandingselect: the tenant's own app chrome (also its portal users)xrf_report_assetsselect: shared and tenant report artworknotification_logrecipient update: marking your own notification readaudit_loginsert: logging your own attributed action- Personal rows:
saved_views,notification_preferences, your ownprofilesrow - 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
- Migration: insert into
public.permissionswithcategory,sort_orderandimplies. Add it topermission_preset_grantsfor Tenant Admin and any other preset that should hold it, and backfill existing preset bundles the way20260928110000does. - Enforce it in the relevant policies or functions. The lint test fails for a catalog key that nothing enforces.
- Add it to
PERMISSION_KEYSinsrc/auth/permissionKeys.ts. The matrix test checks the mirror. - Gate the UI:
usePermission('key')at the action, andsrc/auth/routeAccess.tsif it opens a page. - Edge functions:
authorize(supabase, req, 'key'). The key is typed. - Add a row to
db-tests/tests/permissions-matrix.test.ts.
Adding a tenant table
- Add
tenant_id uuid not null references public.tenants (id). For a child table, add aBEFORE INSERTtrigger that copiestenant_id(andproject_idwhere relevant) from the parent, so a client cannot spoof it. - Write the policies in the pattern above, using the area's existing keys before inventing new
ones. Every policy on a table with
tenant_idmust callhas_permission(; the policy lint inpermissions-matrix.test.tsfails otherwise. alter table … enable row level security, grant toauthenticatedonly what the policies need, and revoke fromanon.- Add a leakage case to
db-tests/tests/leakage.test.tsand a matrix row for the key. - Add the table to data-model.