RBAC & Permissions
The platform uses permission-based authorization with seeded system roles. Permissions are the atomic unit of authorization; roles are named bundles of permissions scoped to an organization.
This document is authoritative. Implementation changes that diverge from the model described here should be treated as bugs, not as new ground truth.
SQL is illustrative
Migration templates and SQL fragments below are examples meant to convey shape and intent — they're not authoritative reproductions of the production schema. The real migrations live in services/api/migrations/core/.
Schema scope: tenant-scoped vs platform-scoped
The RBAC tables roles, permissions, and role_permissions are tenant-scoped — every grant is consumed via organization_memberships.role_id, never any other scope. They deliberately don't carry an organization_ prefix because there's no parallel "platform roles" or "platform permissions" table to disambiguate from.
The platform-scoped equivalent is platform_memberships, which uses a hardcoded role column (only valid value: superadmin) rather than its own role table — superadmin is the only platform role, so a full RBAC system would be over-engineered. The structural asymmetry is informative: tenants get rich per-org RBAC; the platform team gets a flat boolean.
Patients are excluded from this system entirely — their authorization is row-ownership-based (current_human_patient_profile_ids() and similar RLS helpers), not role-based. AI agents and service accounts (when they light up) plug into the same tenant RBAC tables via organization_memberships.
Concepts
Permissions
A permission is a fine-grained capability identified by a resource.action code (e.g. organizations.update, appointments.create). Permissions are stored in the permissions table and seeded by migrations as features ship — every new feature's migration adds the permission codes it gates on.
Roles
A role is a named bundle of permissions, scoped to an organization. Roles live in the roles table. Two kinds exist:
- System role templates — rows with
organization_id IS NULLandis_system = TRUE. There are 4 of these today:patient,specialist,customer_support,admin. They represent canonical tenant roles every clinic needs. - Per-org system roles — cloned from the templates automatically when
POST /v1/organizationsruns. Every clinic gets its ownpatient/specialist/customer_support/adminrows with the same permission bundles as the templates. - Custom per-org roles (not yet surfaced in UI) — org-specific roles with a chosen subset of permissions. The schema supports them; a future phase adds UI.
Superadmin
Superadmin is a platform-level identity, not a tenant role. Granted via the platform_memberships table (one row per (principal, role) grant, with granted_by_principal_id + granted_at for audit). A CHECK constraint on platform_memberships (via the principal_is_human(uuid) helper) restricts grants to principals whose principal_type = 'human' — service accounts and agents cannot be superadmin. The Subject.IsSuperadmin flag in Go is a derived convenience populated from that table on each request. Superadmins:
- Are routed to the AdminPool (PostgreSQL owner role) which bypasses RLS entirely.
- Bypass every permission check in middleware (
principalCtx.HasPermission(x)returns true). - Do not need — and cannot be assigned —
organization_membershipsrows. They operate above tenants. - Are created only by direct database mutation or by a future superadmin-bootstrap tool. There is no API to self-promote.
Memberships
organization_memberships has (principal_id, organization_id, role_id). A principal has one role per org they belong to, and a principal can hold different roles in different orgs (admin at Clinic A, customer_support at Clinic B). Role is queried per-request from the membership row for the org the request is scoped to. A trigger (enforce_single_membership_for_non_humans) restricts non-human principals (agents, service accounts) to at most one membership and requires it to match principals.organization_id.
The Four-Layer Authorization Model
Authorization composes from four layers, each owned by a different actor and managed via a different interface. The boundary between layers 2 and 3 is the platform-vs-tenant trust boundary: changes to layers 1–2 affect every tenant; changes to layers 3–4 affect only one org.
| Layer | Lives in | Managed by | Interface |
|---|---|---|---|
| 1. Permission catalog | permissions | Migrations only | Code (Console exposes a read-only catalog viewer) |
| 2. System role templates | roles where is_system = TRUE AND organization_id IS NULL | Platform operator | Console template editor (affects new orgs only) |
| 3. Per-org cloned system roles | roles where is_system = TRUE AND organization_id = <org> | Clinic admin | Clinic Roles section (edit cloned permissions) |
| 4. Per-org custom roles | roles where is_system = FALSE AND organization_id = <org> | Clinic admin | Clinic Roles section (create / edit / delete) |
Layer 1 — Permission catalog. The grammar of authorization: every capability the platform supports, named by a resource.action code. Adding a permission requires a migration — there is no UI to mint one. Permissions are deploy-time artifacts so feature code can reference them as constants (auth.PermOrganizationsUpdate) and so drift tests catch Go ↔ TypeScript ↔ database skew. The Console exposes a read-only catalog viewer; mutations go through migrations.
Layer 2 — System role templates. The four defaults (patient / specialist / customer_support / admin) every clinic gets out of the box. Edited via the Console template editor by the platform operator. Templates' permission grants are stored in role_permissions keyed by the template role.id; new orgs' clones copy these grants at creation time.
Asymmetric UI-time propagation. When the Console template editor changes a permission grant, the rule is: grants propagate to every existing Layer-3 clone (matches migration-time behavior); revocations do not (clinics may have legitimately revoked already, and silent capability removal is the failure mode). Renames and deletes require a migration, not the UI. Propagation audit rows carry
action_context = 'template_propagate'. See decisions.md → Why asymmetric propagation for system role template edits.
Layer 3 — Per-org cloned system roles. Each org's editable copy of the four templates, materialized at POST /v1/organizations time by the role-cloning trigger in migration 000003. Clinic admins edit the permission set on these rows (e.g. revoke a capability their specialist role does not need); they cannot rename or delete the four system codes — is_system = TRUE AND organization_id IS NOT NULL is recognizable as templates' descendants.
Feature migrations that add a permission to a layer-2 template also add the same grant to every existing layer-3 clone in the same statement (WHERE r.is_system = TRUE matches both — see the recipe in § Adding a permission). This is migration-time propagation: always automatic. The UI-time path follows the same direction (grants flow down), with revocations the explicit exception above.
Layer 4 — Per-org custom roles. Org-defined roles like "billing clerk" or "intake nurse" — is_system = FALSE. Clinic admins create them, choose a permission subset from the layer-1 catalog, and assign them to memberships. App access is structural (membership row → Clinic app, patients row → Patient Portal), so a custom role doesn't need any app-access grant — it's staff-side by virtue of being attached via organization_memberships. The admin then grants whichever capability permissions (e.g. organizations.view_directory, patients.view, appointments.create) the role should hold.
Enforcement Layers
Authorization is enforced at three independent layers. All three must agree; any one layer refusing denies the request.
Request
│
▼
Layer 0 — Connection Pool Routing
• Superadmins → AdminPool (owner role, bypasses RLS)
• Everyone else → AppPool (restricted role, RLS enforced)
• Public endpoints → AppPool (RLS enforced; public-access policies allow unauthenticated reads)
│
▼
Layer 1 — Middleware (Go)
• Authenticate verify JWT, load principal/human + memberships
• OrganizationContext resolve current org, load role + permissions, set RLS session variables
• RequirePermission gate route by permission code (canonical)
• RequireSuperadmin gate route to platform superadmins
│
▼
Layer 2 — PostgreSQL RLS
• Org-scoping policies filter rows by current_app_org_id()
• Permission-aware policies call current_app_has_permission(resource, action)
• Superadmins bypass RLS via the AdminPool, not via policy checksLayer 1 is the primary authorization layer. Layer 2 is defense-in-depth and guarantees that even if a handler forgets a gate, a non-superadmin request on the AppPool cannot write outside its org or read what it shouldn't.
Middleware reference
All tenant-scoped routes go through Authenticate → OrganizationContext. Gate each route by the strongest applicable check.
| Middleware | When to use |
|---|---|
RequireSuperadmin() | Endpoints that operate across tenants (e.g., POST /v1/organizations). |
RequirePermission("resource.action") | Default gate. All tenant-scoped endpoints that modify data. |
If you find yourself writing if role == "admin" anywhere in handler code, stop and introduce a permission instead. Roles are human-facing labels; permissions are the authorization primitive.
Adding a permission (feature-migration recipe)
Each feature migration that introduces a new gated capability should:
- Insert the permission code(s) into
permissions. - Grant the permission to the appropriate system role templates (rows where
organization_id IS NULL). - Grant the permission to all already-cloned per-org system roles (rows where
is_system = TRUE AND organization_id IS NOT NULL) so existing orgs get the capability too.
Template:
-- 1. Insert the permission(s)
INSERT INTO permissions (code, resource, action, description) VALUES
('appointments.create', 'appointments', 'create', 'Create appointments within the org');
-- 2 + 3. Grant to every system role (template + per-org clones) that should have it
INSERT INTO role_permissions (role_id, permission_code)
SELECT r.id, 'appointments.create'
FROM roles r
WHERE r.code IN ('specialist', 'customer_support', 'admin')
AND r.is_system = TRUE
ON CONFLICT DO NOTHING;ON CONFLICT DO NOTHING makes the migration re-runnable safely.
Seeded permission catalog (today)
As of today, the permissions table contains only the codes actually gated by shipped endpoints. More will be added by each Phase 3+ feature migration.
| Code | Gated Endpoints |
|---|---|
organizations.update | PATCH /v1/organizations/{id} |
organizations.manage_domains | GET /v1/organizations/{id}/domains, POST /v1/organizations/{id}/domains, DELETE .../domains/{domainId}, POST .../domains/{domainId}/verify |
organizations.manage_members | POST /v1/organizations/{id}/members, GET /v1/organizations/{id}/members, GET /v1/organizations/{id}/roles, DELETE /v1/organizations/{id}/members/{principalId} |
organizations.view_directory | RLS-only: gates SELECT on the staff-side directory tables — humans (co-member arm), principals (membership arm), agents, service_accounts, organization_settings, roles, role_permissions, permissions. Replaces the bare current_app_role() <> '' discriminator so a clinic-admin-created custom no-permission role doesn't over-grant directory visibility. |
data.view_deleted | RLS-only: hides deleted_at IS NOT NULL rows for callers without it. |
audit_log.view_org | RLS-only today: gates SELECT on audit_log. Will gate GET /v1/audit-logs* once the read API ships. |
locations.manage (Foundation 1B.14) | POST /v1/organizations/{id}/locations, PATCH .../locations/{locationId}, DELETE .../locations/{locationId}. List + Get are RLS-scoped to org members (no permission gate; the response carries no field that's locations.manage-only). Granted to admin only at seed; not specialist, not customer_support. |
The seeded admin template holds the four organizations.* permissions plus data.view_deleted and audit_log.view_org. The specialist and customer_support templates hold only organizations.view_directory so they can see the staff directory at their org; they accumulate capability permissions as features ship.
App access is structural, not permission-based: a human is staff at org X iff they have a row in organization_memberships for X (Clinic app entry), and a patient at org X iff they have a row in patients for X (Patient Portal entry). There is no patient system role and no app.access_* permissions — see decisions.md → Why patients are not memberships, and patient tiers are not roles.
Canonical Permission Matrix (planned, not yet seeded)
The matrix below describes the product intent for each feature's permissions. Each feature migration will (a) insert the listed permission codes and (b) grant them to the indicated system roles.
Legend — ✅ granted to this system role by default; ❌ not granted. Superadmin always implicitly has everything.
Note on the
patientcolumn (post-1.26): patients are no longer a role and don't hold role-permission grants. Thepatientcolumn in the planned tables below is a product-intent shorthand for "what a patient session at the org should be able to do." In implementation, patient-side access is RLS-driven via row ownership predicates (patient_profile_id IN current_human_patient_profile_ids(), etc.) — not via permission codes attached to apatientsystem role. The actual feature migrations should drop thepatientcolumn from the seeded grants and translate the intent into row-ownership policies. See decisions.md → Why patients are not memberships.
App Access (structural, not permission-based)
A human reaches the Clinic app iff they hold a row in organization_memberships for the current org; they reach the Patient Portal iff they hold a row in patients for the current org. There is no patient system role and no app.access_* permissions — the row is the access. The legend below omits the patient column because patients are not memberships.
Organizations (seeded + Layer 1.19 additions)
| Permission | specialist | customer_support | admin |
|---|---|---|---|
organizations.update | ❌ | ❌ | ✅ |
organizations.manage_domains | ❌ | ❌ | ✅ |
organizations.manage_members | ❌ | ❌ | ✅ |
organizations.view_directory | ✅ | ✅ | ✅ |
organizations.update_settings (Layer 1.19) | ❌ | ❌ | ✅ |
organizations.manage_billing (Layer 1.19) | ❌ | ❌ | ✅ |
update_settings gates organization_settings (operational/compliance knobs — default sign-up role, marketing prefs, retention overrides). manage_billing gates organization_billing (regulated financial-data class — billing email, address, tax ID, current-tier pointer). Both seeded by the Layer 1.19 migration that creates the companion tables.
Tiers & Subscriptions (Layer 1.20)
Platform tiers / subscriptions / sales overrides — clinic-side commercial state. Catalog (tiers, features, limit_definitions) is read-only for non-superadmins; mutations live in superadmin-only Console flows.
| Permission | specialist | customer_support | admin |
|---|---|---|---|
subscriptions.view_org | ❌ | ❌ | ✅ |
subscriptions.manage | ❌ | ❌ | ✅ |
subscriptions.view_org lets the clinic admin see their org's current plan, active add-ons, override grants, and renewal dates. subscriptions.manage is the self-service subscription path (cancel, attach add-on, buy usage pack) — superadmin-driven plan changes and override grants stay on the AdminPool side regardless of this permission.
Patient Tiers & Subscriptions (Layer 1.21 + 2.5)
Per-clinic patient tier catalog (Layer 1.21) and per-patient tier subscriptions (Layer 2.5, after patients lands).
| Permission | specialist | customer_support | admin |
|---|---|---|---|
patient_tiers.manage (Layer 1.21) | ❌ | ❌ | ✅ |
patient_subscriptions.view_org (Layer 2.5) | ❌ | ✅ | ✅ |
patient_subscriptions.manage (Layer 2.5) | ❌ | ✅ | ✅ |
patient_tiers.manage covers both the tier catalog and the tier inclusions (Layer 3.2 once service_plans exists) — they're parts of the same admin surface. patient_subscriptions.view_org and manage go to customer_support too, since flipping a patient between tiers is the day-to-day work of clinic ops staff in the clinic-owned-billing model. Patients see and manage their own subscription via row-level ownership (patients.patient_profile_id IN current_human_patient_profile_ids()), not via these org-level permissions.
Locations (Foundation 1B.14)
Per-clinic physical sites — branches, satellite offices, the room a specialist runs telerehab from. Locations are a logistics layer on top of org-scoped tenancy (P40) — they partition where appointments physically happen and where specialists physically are at a given moment, NOT permissions, consents, or patient identity.
| Permission | specialist | customer_support | admin |
|---|---|---|---|
locations.manage (Foundation 1B.14) | ❌ | ❌ | ✅ |
Listing + reading locations is gated by RLS membership only — every staff member needs to see every location at their org (specialists check where they're rostered, receptionists book against any of them). locations.manage gates only mutations (create / update / close / delete). Customer support is deliberately excluded from manage — location lifecycle (opening a new branch, closing one) is an admin concern, not a service-desk concern. No per-location RBAC scoping in v1 (no current_app_location_ids() helper); per-location restrictions are a future ADR if a customer requires it.
Appointments (Phase 4)
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
appointments.view_own | ✅ | ✅ | ❌ | ❌ |
appointments.view_org | ❌ | ❌ | ✅ | ✅ |
appointments.create | ❌ | ✅ | ✅ | ✅ |
appointments.update_own | ❌ | ✅ | ❌ | ❌ |
appointments.update_org | ❌ | ❌ | ✅ | ✅ |
appointments.cancel_own | ❌ | ✅ | ❌ | ❌ |
appointments.cancel_org | ❌ | ❌ | ✅ | ✅ |
appointments.delete | ❌ | ❌ | ❌ | ✅ |
Ownership (specialist=own-appointments, patient=own-appointments) is enforced by RLS through specialist.human_id = current_app_principal_id() / patient_profile_id IN (current_human_patient_profile_ids()) — orthogonal to the permission check.
Patients (Phase 3)
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
patients.view_self | ✅ | ❌ | ❌ | ❌ |
patients.view_org | ❌ | ✅ | ✅ | ✅ |
patients.onboard | ❌ | ✅ | ✅ | ✅ |
patients.update_self | ✅ | ❌ | ❌ | ❌ |
patients.update_org | ❌ | ❌ | ✅ | ✅ |
patients.delete | ❌ | ❌ | ❌ | ✅ |
patients.impersonate | ❌ | ❌ | ❌ | ✅ |
Specialists (Phase 3)
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
specialists.view | ✅ | ✅ | ✅ | ✅ |
specialists.update_self | ❌ | ✅ | ❌ | ❌ |
specialists.create | ❌ | ❌ | ❌ | ✅ |
specialists.update_org | ❌ | ❌ | ❌ | ✅ |
specialists.delete | ❌ | ❌ | ❌ | ✅ |
Forms (Phase 5)
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
forms.view_own | ✅ | ❌ | ❌ | ❌ |
forms.view_org | ❌ | ✅ | ✅ | ✅ |
forms.create | ❌ | ✅ | ✅ | ✅ |
forms.fill_own | ✅ | ❌ | ❌ | ❌ |
forms.fill_org | ❌ | ✅ | ✅ | ✅ |
forms.sign | ✅ | ✅ | ❌ | ✅ |
forms.delete_unsigned | ❌ | ❌ | ❌ | ✅ |
Signed forms are immutable regardless of permissions — the constraint lives in the business logic, not in RBAC.
Form Templates (Phase 5)
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
form_templates.view | ✅ | ✅ | ✅ | ✅ |
form_templates.manage | ❌ | ❌ | ❌ | ✅ |
Documents (Reports & Prescriptions) (Phase 6)
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
documents.view_own_published | ✅ | ❌ | ❌ | ❌ |
documents.view_org | ❌ | ✅ | ✅ | ✅ |
documents.create | ❌ | ✅ | ❌ | ✅ |
documents.update_own | ❌ | ✅ | ❌ | ❌ |
documents.update_org | ❌ | ❌ | ❌ | ✅ |
documents.publish | ❌ | ✅ | ❌ | ✅ |
documents.delete | ❌ | ❌ | ❌ | ✅ |
PDF Templates (Phase 6)
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
pdf_templates.view_published | ❌ | ✅ | ✅ | ✅ |
pdf_templates.manage | ❌ | ❌ | ❌ | ✅ |
pdf_templates.render | ❌ | ✅ | ✅ | ✅ |
Exercise Library (Phase 7 — regulatory boundary)
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
exercises.view_published | ✅ | ✅ | ✅ | ✅ |
exercises.manage_org | ❌ | ❌ | ❌ | ✅ |
Global exercises (organization_id IS NULL) are managed only by superadmin — there is no template permission for this; the check is the platform-level superadmin grant in platform_memberships (exposed as Subject.IsSuperadmin).
Treatment Plans (Phase 7 — regulatory boundary)
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
treatment_plans.view_own | ✅ | ❌ | ❌ | ❌ |
treatment_plans.view_org | ❌ | ✅ | ✅ | ✅ |
treatment_plans.manage | ❌ | ✅ | ❌ | ✅ |
treatment_plans.delete | ❌ | ❌ | ❌ | ✅ |
treatment_plans.assign | ❌ | ✅ | ❌ | ✅ |
treatment_plans.approve | ❌ | ✅ | ❌ | ✅ |
treatment_plans.execute_own_session | ✅ | ❌ | ❌ | ❌ |
treatment_plans.promote_to_org | ❌ | ❌ | ❌ | ✅ |
Segments (Phase 8)
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
segments.view_org | ❌ | ❌ | ✅ | ✅ |
segments.manage | ❌ | ❌ | ❌ | ✅ |
segments.view_own_membership | ✅ | ❌ | ❌ | ❌ |
Services & Pricing (Phase 4 — billing surface)
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
services.view_org | ❌ | ✅ | ✅ | ✅ |
services.manage | ❌ | ❌ | ❌ | ✅ |
Export
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
export.csv | ❌ | ❌ | ✅ | ✅ |
Consents (Foundation 1B.9)
Consent rows are subject-owned: a patient always reads their own consents across all clinics (RLS via current_human_patient_profile_ids()). Org staff get visibility into consents at their clinic via consents.view_org. Staff-action grants/withdrawals (CS rep flips marketing on a patient's behalf when they call in) require consents.manage. See P17 and Foundation 1B.9.
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
consents.view_org | ❌ | ✅ | ✅ | ✅ |
consents.manage | ❌ | ❌ | ✅ | ✅ |
GDPR
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
gdpr.export_data | ❌ | ❌ | ❌ | ✅ |
gdpr.erase_data | ❌ | ❌ | ❌ | ✅ |
gdpr.restrict_processing | ❌ | ❌ | ❌ | ✅ |
Audit & Telemetry
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
audit_log.view_org (seeded; gates RLS today) | ❌ | ❌ | ❌ | ✅ |
telemetry.view_org | ❌ | ❌ | ❌ | ✅ |
Field-Level Filtering
Row-level authorization answers "can this caller see this row?". Some endpoints also need to strip fields from responses or ignore fields in request bodies based on caller role. That is response filtering, not RBAC. It is implemented at the handler/serialization layer; it is orthogonal to permissions and is documented alongside each affected feature.
Canonical filters:
- Patient callers: strip specialist contact info, strip private form fields, strip internal identifiers from entity responses.
- Patient callers: silently drop
organization_id,patient_profile_id, and other ownership fields from PUT/POST bodies.
Field filtering belongs in its own middleware (ResponseFilter) added when the first patient-facing read endpoint lands.
Ownership vs Permissions
Permissions answer "is this caller allowed to perform this action type?". Ownership answers "on which rows?". The two are independent.
| Mechanism | Enforces |
|---|---|
| Permission | Is the verb allowed at all? |
| RLS (org-scope) | Row belongs to the current org |
| RLS (ownership sub-query) | Row belongs to the caller / caller manages the patient |
| Handler check | Cross-entity ownership RLS can't express cleanly |
Examples:
specialists.update_self(permission) +specialist.human_id = current_app_principal_id()(RLS) together ensure a specialist can only edit their own profile.documents.update_own(permission) + application-level checkdocument.appointment.specialist.human_id == caller.PrincipalID(handler) together prevent a specialist from editing someone else's report.
Rate-Limited Endpoints (planned)
Authorization does not replace rate limiting. Endpoints that gate on sensitive permissions (e.g. gdpr.*, export.csv, patients.impersonate) also need principal-keyed rate limits. See apps/docs/gaps/09-rate-limiting.md for the rollout.
Changes from the Old Global-Role Model
The previous implementation stored a single global users.role enum (back when the identity table was users, before the rename to humans). This was discarded because:
- The same human could not be admin at Clinic A and customer_support at Clinic B — roles must be per-org in a multi-tenant platform.
- Hardcoded role checks (
role == "admin") made authorization non-queryable, non-customizable, and non-auditable — all three are requirements for GDPR / EU MDR readiness. - White-label clinics will eventually want custom role bundles; a permission system supports that without a schema change.
The permission codes and default bundles in this document are the product decision about who can do what out of the box. Custom per-org roles, when introduced, layer on top of (or replace subsets of) these defaults without changing the code gating them.