Roles & Access Control
Who can access what: the role model, the four enforcement layers, and the rules per resource — in one place.
This page collects every access rule the platform enforces (and the ones it merely displays), so you can answer “can a viewer do X?” without spelunking through route handlers and database policies. It documents the current, observed behavior of the code — including the places where enforcement is weaker than the UI suggests, which are listed honestly in Known gaps at the end.
The role model
There is exactly one persisted role in the platform: the role column on organization_members, restricted by a database CHECK constraint to three values:
| Role | Rank | Meaning |
|---|---|---|
viewer |
1 | Read-only participant in the organization. |
member |
2 | Working participant — can contribute data and actions. |
admin |
3 | Organization administrator — manages members, datasets, and the org itself. |
Facts that follow from the schema and code, worth internalizing:
- There is no
ownermembership role and no platform-wide super-admin. “Ownership” exists only as a separate axis on resources —datasets.owner_idnames the user who created a dataset. (Two spots in the code still test for an'owner'role —web/app/api/datasets/route.tsand the dataset detail client — but the CHECK constraint means that value can never occur; those checks are dead code.) - Roles are per organization. A user who is
adminin one org andviewerin another has both, each applying within its org. - The “workspace role” is the org role, verbatim. The authenticated layout loads your
organization_membersrows and hands them to the workspace shell asWorkspaceRole(web/lib/workspaces/permissions.ts). When the URL carries an?org=scope, your role for that org applies; with no org selected, your highest role across all your orgs applies.normalizeWorkspaceRolemaps any unknown or missing value toviewer. - The two “role workspaces” are not access control. Data Explorer and Integrity Engineer are personas — every signed-in user can switch between both (
app/(authenticated)/layout.tsxhardcodes the list). Roles gate what you can do inside them, not which one you can enter.
The four enforcement layers
A request passes through up to four layers; each has a distinct job, and knowing which layer enforces a rule tells you how strong the rule is.
| Layer | Where | What it enforces |
|---|---|---|
| 1. Middleware | web/middleware.ts |
Nothing, deliberately. It refreshes the Supabase session cookie and performs convenience redirects (signed-in visitors at /login → workspace landing; bare /w/<workspace> → its landing module). It never blocks a request or checks a role. |
| 2. Layout session guard | app/(authenticated)/layout.tsx |
A valid session, or redirect('/login'). This one gate covers every page in the (authenticated) route group — all of /w/*, /datasets/*, /organizations/*, /profile, /settings, /debug. |
| 3. Server-side checks | Pages and API route handlers | The real authorization: 401 without a session, 403 (or redirect('/forbidden')) without the required membership or role. The one server-side role gate for pages is withWorkspaceData({ requiredRole }) in web/lib/workspaces/data.ts — the only code in the app that sends anyone to /forbidden. |
| 4. Postgres RLS | supabase/migrations/ |
The database backstop: row-level policies that scope reads and writes by organization membership even if a handler forgets to check. Routes that use the service-role client bypass this layer entirely and must do their own membership check (most do — see API authentication). |
What each role can do
The summary matrix. “Any member” means viewer counts too — most membership checks do not distinguish roles.
| Capability | viewer | member | admin |
|---|---|---|---|
Sign in; use every (authenticated) page; switch workspaces |
✅ | ✅ | ✅ |
| Use Campaigns, Insights, Investigate, Assets, Ask, History, Evidence, Organizations modules | ✅ | ✅ | ✅ |
| Actions module enabled in the sidebar | ❌ | ✅ | ✅ |
| New Dataset wizard (per module registry) | ❌ | ✅ | ✅ |
| Read org-scoped data (datasets, images, notes, findings) | ✅ | ✅ | ✅ |
| Create a work-order note; record a finding decision | ✅ | ✅ | ✅ |
| Edit/close/delete a note or work order | author only | author only | author only |
| Create annotations / import COCO mappings (RLS) | ❌ | ✅ | ✅ |
| Create a dataset (becomes its owner) | ✅ | ✅ | ✅ |
| Edit or delete a dataset | owner only | owner only | ✅ any in org |
Edit or delete asset-register rows (pds_assets, nozzles) |
❌ | ❌ | ✅ |
| Create an organization (creator becomes its admin) | ✅ | ✅ | ✅ |
| View an org’s member list | ✅ | ✅ | ✅ |
| Add or remove members; rename or delete the org | ❌ | ❌ | ✅ |
No capability in the app requires more than admin, and no module requires admin at all — the admin role exists for organization and dataset management, not for extra screens.
Organization rules
All four mutating routes follow the same shape: authenticate, look up the caller’s membership with the admin client, and require role === 'admin' (403 otherwise). RLS on organization_members enforces the same rule as a backstop.
- Create (
POST /api/organizations/create) — any signed-in user; the creator is inserted asadmin. Thecreate-for-uservariant refuses to act for anyone but yourself (403) — there is no platform admin. - Add member (
add-member) — admins only. The role assigned must bememberoradmin; the API rejectsviewerwith a 400 (the UI’s role dropdown offers Viewer anyway — choosing it fails). Adding an existing member is a 400. - Remove member (
remove-member) — admins only, and an admin cannot remove themselves (enforced in the route and in the RLS delete policy, so an org can’t be orphaned). - Change a member’s role — not possible. No endpoint exists; remove and re-add is the only path.
- Rename / delete — admins only; delete is refused with a 409 while the org still contains datasets.
- Invitations — the
accept_invitation()database function (SECURITY DEFINER) is the one sanctioned path that creates a membership row without an admin acting: it copies the role stored on the invitation.
Dataset rules
- Visibility has two values:
privateandorganization(database CHECK constraint — there is nopublic).privatemeans only the owner sees the dataset row;organizationextends reading to every member of the dataset’s org, any role. - Create — any signed-in user (they become
owner_id); the Data Explorer onboarding wizard additionally requires membership in the target org (403 otherwise). - Edit / delete — the dataset owner or an org admin. Enforced three times over: the settings page redirects non-editors away, the update/delete APIs return 403, and the RLS update/delete policies require
owner_id = auth.uid()or org-admin. - Caveat — visibility gates only the dataset row. The RLS policies on a dataset’s children (images, notes, annotations, splat files) check org membership but do not consult
visibility, so org members can read a private dataset’s images through routes that query children directly.
Workspace module access
Each module in the registry (web/lib/workspaces/modules.ts) declares a requiredRole. The current values:
| Required role | Modules |
|---|---|
viewer |
campaigns, insights, investigate, assets, ask, history, organizations, anomalies (Evidence), cad-viewer |
member |
actions, new-dataset |
admin |
(none) |
How it is enforced — and how far that goes:
- In the sidebar: a module whose
requiredRoleyour scoped role does not meet stays visible but disabled, with a tooltip “Requires member role” (canAccessModulein the workspace context →buildSidebarModuleItems). This is presentation, not security. - On the server: pages that need protection call
withWorkspaceData({ requiredRole, organizationId }), which checks yourorganization_membersrows and redirects to/forbiddenon failure. The Actions page uses it — but withrequiredRole: 'viewer', so the registry’smembergate is nav-only (see Known gaps). - The Assets module is hidden by data, not role: it only appears when the scoped org has rows in the asset register.
Work orders and findings
- Work orders are notes (
location_notesrows withmetadata.kind = 'work_order'); statusesopen → scheduled → complete. - Create — any member of the dataset’s organization (any role; the RLS insert policy also requires
created_byto be you). - Edit / close / delete — the author (
created_by), or an admin of the note’s organization, in both the API and RLS (supabase/migrations/20260829180000_org_admin_manages_notes.sql). Amember/viewerstill cannot touch a colleague’s note or work order — only the author, or an org admin moderating on their behalf. - Finding decisions (
equipment-damage-mechanisms/{id}/decision) — any member of a relevant org; the deciding user is stamped into the evidence notes, and the originalidentified_byis never overwritten.
The database backstop (RLS)
Nearly every table has row-level security enabled; the canonical pattern gates access through organization membership, using two SECURITY DEFINER helpers defined in the baseline migration:
is_org_member_secure(org_id)— you have any membership row in the org.is_org_admin_secure(org_id)— you have one withrole = 'admin'.
The policies sort into families:
| Family | Rule | Tables (representative) |
|---|---|---|
| Org-scoped reads | any member of the org (or the dataset’s owner) | datasets, images, annotations, location_notes, data_groups, organizations, organization_members, pds_assets, cad_objects, tag_cross_reference |
| Admin-gated writes | org admin (or resource owner, where noted) | datasets update/delete (owner or admin), organization_members writes, pds_assets / pds_asset_nozzles / cad_objects writes, tag_cross_reference update/delete |
| Viewer-excluded writes | role must be member or admin |
annotations insert, coco_image_mappings insert — the only policies where viewer differs from member |
| Own-row only | auth.uid() must match the row’s user |
profiles, equipment (and its IOW/damage-mechanism children), chat sessions/messages, the AI session-state tables |
| Service-role only | RLS on, no policies — reachable only through API routes | pdf_reports, surveys, message_traces |
| Public reference data | readable by any signed-in user | annotation_categories, damage_mechanisms |
Three of the historical outliers were closed by the 2026-08-01 RLS audit (supabase/migrations/20260801160000_close_public_read_paths.sql): cad_objects is no longer anonymously readable, message_traces gained RLS (service-role only until it has a tenancy key), and tag_cross_reference went from open-to-all-authenticated to org-scoped. The intended platform-wide model — every row private to its owner or scoped to its organization, enforced declaratively and verified by a conformance test — is stated in docs/proposals/20260801_access_control_model.md.
Chat is deliberately user-private, not org-shared: chat_sessions and messages enforce auth.uid() = user_id, and the organization_id column on sessions is a reporting tag, not a security boundary (see 20260801170000_chat_sessions_multitenancy_corrected.sql, which rejected an earlier org-wide policy for exactly that reason).
Two structural facts to keep in mind:
equipmentis user-scoped, not org-scoped — a teammate cannot see your equipment rows through RLS; org-wide asset access goes through the asset register (pds_assets) and API routes instead.supabase/migrations/is documentation-of-record, not the deploy path — the live schema is deployed from the KavApps lineage (seesupabase/README.mdbefore trusting or pushing a migration). The baseline file is a reconstruction of the live schema.
The database side is covered in depth in the Database & Cloud Storage Handbook.
API authentication
- Two credentials, per the Swagger spec: the Supabase session cookie (
CookieAuth) or a Supabase JWT bearer token (SupabaseAuth). There are no API keys and no service-to-service auth scheme. - The rule: every operation requires one of the two, except the handful declared
security: []inswagger.yaml— deliberately public operations like/health,/auth/sso,/systems, the chat streams, and the test/telemetry endpoints. - The standard handler pattern for org-scoped data: authenticate the caller (
requireUserinweb/lib/api-auth.ts, or a hand-rolled session check) → resolve the resource’sorganization_id→ verify the caller has a membership row → 403 otherwise → only then run the privileged (service-role) query. Routes that keep the caller’s own RLS-scoped client can skip the explicit check and let the database filter.
Known gaps and caveats
The rules above are what the code means to enforce. These are the verified places where enforcement currently falls short — useful both for reviewers and as a worklist:
- Module role gates are nav-only. The registry marks Actions and New Dataset as
member, but the only server-side gate on the Actions page requiresviewer— a viewer deep-linking to/w/integrity/actionsgets the page. (Creating work orders is still membership-checked; closing is author-only.) - Roles fail open to
viewer, not to denial. An unknown or missing role normalizes toviewer, and scoping the UI to an org you are not a member of also yieldsviewer— the UI renders read-only rather than refusing. Server checks and RLS still protect the data. withWorkspaceDatawithout anorganizationIdaccepts the required role in any of your orgs, not the one on screen. Pass the org id when the check should be scope-specific.- A few handlers still skip the session check the spec implies — verified at the time of writing:
GET /api/annotationsand/api/anomalies/label-distribution(both run service-role queries),/api/thumbnails/generate-bulk, and/api/set-org-cookie(sets an unvalidated, non-httpOnly org-preference cookie — a UI hint, not an authorization token, but unchecked input). Treat these as the remaining items of the security worklist described in Backend API & Swagger. vieweris barely distinct at the database layer — only the annotation and COCO-import insert policies exclude it; everywhere else RLS treats a viewer like a member (including inserts into datasets and notes). The read-only promise of the role is mostly upheld by the UI and routes, not by the database.- A private dataset’s children are org-readable (see Dataset rules).
- Two tables still sit outside the org model:
gas_readings(all four verbs open to any authenticated user, no org scoping) andintegrity_contract_cache(its migration says so explicitly, pending equipment tenancy). The other former outliers —cad_objects,message_traces,tag_cross_reference— were closed by the 2026-08-01 audit migration.
This page is hand-maintained. When you change who can access what — a module’s requiredRole, a route’s auth check, an RLS policy, or an organization/dataset rule — update the affected section (and close out any gap above that you fix) in the same change.
Last Updated: 2026-08-01