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 has cross-tenant reach: an active operator not inside an impersonation session | The 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 not | Identity 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
| 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), the firm's document requirements | ||
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; the invite-client-user edge function (client page → Portal access) | view_clients | |
manage_work_orders | The work-order queue: read clients' portal work orders, accept (creates a project) or decline them | view_clients | |
| Buildings | view_buildings | Tracked buildings, units, violations | |
manage_buildings | Track and untrack, assign client, contacts, unit corrections, apartment matching (merge or keep apart, AI suggestions); approve or decline clients' portal building requests | 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; sign off a project (project_reviews) | 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; the Deadlines page and the building/apartment deadline panels | |
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.
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).
| Table | Read | Write | Notes |
|---|---|---|---|
client_contacts | view_clients; the client's own portal logins (client_contacts_select_portal) | manage_clients | tenant_id from the client (sync_tenant_id_from_client) |
invoice_payments | view_financials | manage_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_notes | view_financials | manage_invoices, as the caller | Append-only: select and insert grants only |
document_requirement_overrides | view_projects, view_documents or manage_settings | manage_settings | The checklist reads it wherever documents are shown; only Settings writes it |
project_reviews | view_projects or review_xrf_reports | review_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.
| 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 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_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 (cross-tenant read) and the public data, and may call the operator_* RPCs. 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; 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 user | Holds 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 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 |
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(). AllSECURITY DEFINER, all checkclient_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
orinside a staff one:<table>_select_portal, select-only, calling a client-scope helper and no key helper. The policy lint inpermissions-matrix.test.tsenforces 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 DEFINERfunctions that derive the client fromauth.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_usersandplatform_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
| Function | Key | Extra check |
|---|---|---|
generate-xrf-report, ingest-xrf-csv, generate-coc | perform_field_work | Probes the target through the caller's client |
generate-paint-chip-report | perform_field_work | Probe, then owner tenant or a field_execution collaborator |
generate-dust-wipe-report | perform_field_work | Probe, then owner tenant or a field_execution / clearance_sampling collaborator |
generate-abatement-report | perform_abatement_work | Probe, then owner tenant or an abatement collaborator |
generate-hpd-document, assemble-filing-package | manage_documents | Probe, then owner tenant only |
generate-proposal-pdf | manage_proposals | callerIsOwnerTenant |
generate-invoice-pdf | manage_invoices | callerIsOwnerTenant |
sign-document | None: a client-portal login, not a key | The 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-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 |
process-email-outbox, process-notification-digest | EMAIL_OUTBOX_CRON_SECRET or platform-operator JWT | verify_jwt = false; service-role key is not accepted as bearer |
handle-email-suppression, handle-email-unsubscribe, request-password-reset | None (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 write | How it is enforced |
|---|---|
| Tenants, profiles, client logins, clients, buildings, branding, audit log, impersonation sessions | The 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.
| Preview | Who | What changes | Server |
|---|---|---|---|
| Staff role | manage_tenant | AuthProvider 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 own | Unchanged: every query runs as the caller and RLS decides, so the preview can only hide things |
| Client portal | manage_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 id | The 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 onwhoami,my_permission_keysor 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 holdsview_clients(staff_has_permission()).- While it is honoured,
current_client_id()returns that client,has_permission()returns false andcurrent_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.
| Function | Gate |
|---|---|
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_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.