Skip to content

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 NULL and is_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/organizations runs. Every clinic gets its own patient/specialist/customer_support/admin rows 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_memberships rows. 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.

LayerLives inManaged byInterface
1. Permission catalogpermissionsMigrations onlyCode (Console exposes a read-only catalog viewer)
2. System role templatesroles where is_system = TRUE AND organization_id IS NULLPlatform operatorConsole template editor (affects new orgs only)
3. Per-org cloned system rolesroles where is_system = TRUE AND organization_id = <org>Clinic adminClinic Roles section (edit cloned permissions)
4. Per-org custom rolesroles where is_system = FALSE AND organization_id = <org>Clinic adminClinic 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 checks

Layer 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.

MiddlewareWhen 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:

  1. Insert the permission code(s) into permissions.
  2. Grant the permission to the appropriate system role templates (rows where organization_id IS NULL).
  3. 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:

sql
-- 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.

CodeGated Endpoints
organizations.updatePATCH /v1/organizations/{id}
organizations.manage_domainsGET /v1/organizations/{id}/domains, POST /v1/organizations/{id}/domains, DELETE .../domains/{domainId}, POST .../domains/{domainId}/verify
organizations.manage_membersPOST /v1/organizations/{id}/members, GET /v1/organizations/{id}/members, GET /v1/organizations/{id}/roles, DELETE /v1/organizations/{id}/members/{principalId}
organizations.view_directoryRLS-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_deletedRLS-only: hides deleted_at IS NOT NULL rows for callers without it.
audit_log.view_orgRLS-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 patient column (post-1.26): patients are no longer a role and don't hold role-permission grants. The patient column 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 a patient system role. The actual feature migrations should drop the patient column 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)

Permissionspecialistcustomer_supportadmin
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.

Permissionspecialistcustomer_supportadmin
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).

Permissionspecialistcustomer_supportadmin
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.

Permissionspecialistcustomer_supportadmin
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)

Permissionpatientspecialistcustomer_supportadmin
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)

Permissionpatientspecialistcustomer_supportadmin
patients.view_self
patients.view_org
patients.onboard
patients.update_self
patients.update_org
patients.delete
patients.impersonate

Specialists (Phase 3)

Permissionpatientspecialistcustomer_supportadmin
specialists.view
specialists.update_self
specialists.create
specialists.update_org
specialists.delete

Forms (Phase 5)

Permissionpatientspecialistcustomer_supportadmin
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)

Permissionpatientspecialistcustomer_supportadmin
form_templates.view
form_templates.manage

Documents (Reports & Prescriptions) (Phase 6)

Permissionpatientspecialistcustomer_supportadmin
documents.view_own_published
documents.view_org
documents.create
documents.update_own
documents.update_org
documents.publish
documents.delete

PDF Templates (Phase 6)

Permissionpatientspecialistcustomer_supportadmin
pdf_templates.view_published
pdf_templates.manage
pdf_templates.render

Exercise Library (Phase 7 — regulatory boundary)

Permissionpatientspecialistcustomer_supportadmin
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)

Permissionpatientspecialistcustomer_supportadmin
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)

Permissionpatientspecialistcustomer_supportadmin
segments.view_org
segments.manage
segments.view_own_membership

Services & Pricing (Phase 4 — billing surface)

Permissionpatientspecialistcustomer_supportadmin
services.view_org
services.manage

Export

Permissionpatientspecialistcustomer_supportadmin
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.

Permissionpatientspecialistcustomer_supportadmin
consents.view_org
consents.manage

GDPR

Permissionpatientspecialistcustomer_supportadmin
gdpr.export_data
gdpr.erase_data
gdpr.restrict_processing

Audit & Telemetry

Permissionpatientspecialistcustomer_supportadmin
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.

MechanismEnforces
PermissionIs 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 checkCross-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 check document.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:

  1. 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.
  2. Hardcoded role checks (role == "admin") made authorization non-queryable, non-customizable, and non-auditable — all three are requirements for GDPR / EU MDR readiness.
  3. 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.