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 owner membership role and no platform-wide super-admin. “Ownership” exists only as a separate axis on resources — datasets.owner_id names the user who created a dataset. (Two spots in the code still test for an 'owner' role — web/app/api/datasets/route.ts and 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 admin in one org and viewer in another has both, each applying within its org.
  • The “workspace role” is the org role, verbatim. The authenticated layout loads your organization_members rows and hands them to the workspace shell as WorkspaceRole (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. normalizeWorkspaceRole maps any unknown or missing value to viewer.
  • 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.tsx hardcodes 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 as admin. The create-for-user variant refuses to act for anyone but yourself (403) — there is no platform admin.
  • Add member (add-member) — admins only. The role assigned must be member or admin; the API rejects viewer with 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: private and organization (database CHECK constraint — there is no public). private means only the owner sees the dataset row; organization extends 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 requiredRole your scoped role does not meet stays visible but disabled, with a tooltip “Requires member role” (canAccessModule in the workspace context → buildSidebarModuleItems). This is presentation, not security.
  • On the server: pages that need protection call withWorkspaceData({ requiredRole, organizationId }), which checks your organization_members rows and redirects to /forbidden on failure. The Actions page uses it — but with requiredRole: 'viewer', so the registry’s member gate 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_notes rows with metadata.kind = 'work_order'); statuses open → scheduled → complete.
  • Create — any member of the dataset’s organization (any role; the RLS insert policy also requires created_by to 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). A member/viewer still 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 original identified_by is 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 with role = '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:

  • equipment is 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 (see supabase/README.md before 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: [] in swagger.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 (requireUser in web/lib/api-auth.ts, or a hand-rolled session check) → resolve the resource’s organization_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:

  1. 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 requires viewer — a viewer deep-linking to /w/integrity/actions gets the page. (Creating work orders is still membership-checked; closing is author-only.)
  2. Roles fail open to viewer, not to denial. An unknown or missing role normalizes to viewer, and scoping the UI to an org you are not a member of also yields viewer — the UI renders read-only rather than refusing. Server checks and RLS still protect the data.
  3. withWorkspaceData without an organizationId accepts the required role in any of your orgs, not the one on screen. Pass the org id when the check should be scope-specific.
  4. A few handlers still skip the session check the spec implies — verified at the time of writing: GET /api/annotations and /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.
  5. viewer is 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.
  6. A private dataset’s children are org-readable (see Dataset rules).
  7. Two tables still sit outside the org model: gas_readings (all four verbs open to any authenticated user, no org scoping) and integrity_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