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 3:specialist,customer_support,admin. (Patients are not a role — portal access follows from the existence of apatientsrow. See decisions.md → Why patients are not memberships.) - Per-org system roles — cloned from the templates by a trigger when the org row is inserted. Every clinic gets its own
specialist/customer_support/adminrows with the same bundles, and the clones keepis_system = TRUE. - Custom per-org roles — created by the clinic in Organization → Roles, gated on
organizations.manage_roles.
Two columns on roles look similar and must not be conflated:
| Column | Meaning | Consequence |
|---|---|---|
is_system | Identity — "this is the built-in admin / specialist / customer_support row." | Undeletable. Ownership transfer and break-glass notification both resolve a role through this flag, so deleting a clone would break a transfer and silently drop the notification. |
customized_at | Inheritance — "the clinic has taken this role over." NULL until the clinic first changes its grants. | While NULL, feature migrations auto-grant new permissions to it. Once set, they don't: the new permission appears in the editor unchecked and badged new instead. |
That split is what makes a deliberate removal survive. Without customized_at, the next feature migration's WHERE is_system = TRUE would silently restore a permission an admin had removed on purpose, and nobody would find out until an audit.
The clinic edits its own rows only. Nothing in the editor can write a platform template (organization_id IS NULL) — the RLS policies scope every statement to current_app_org_id(), and the app database role holds no UPDATE grant on organization_id, is_system or code, so a role cannot be moved between tenants or promote itself into the built-in set.
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.
⚠️ NOT BUILT — grants are migration-only today (verified 2026-08-06). Layer 4 below is designed, not shipped. The API exposes
GET /v1/organizations/{id}/rolesand nothing else: no create-role, no grant, no revoke. Console's/role-templatesand/permissionspages are read-only catalogs, and the Clinic app has no roles surface at all. So the only way to change who holds what is a migration, and every clinic runs the three system templates as seeded.Closing it needs
POST /roles+PUT /roles/{id}/permissions, a Clinic roles page, and Console template editing — 1D admin-surface work. Deferred deliberately while the system roles are the only roles; revisit before a clinic needs "billing clerk".
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.manage_roles | POST /v1/organizations/{id}/roles, PATCH .../roles/{roleId}, DELETE .../roles/{roleId}, PUT .../roles/{roleId}/permissions, GET /v1/organizations/{id}/permissions. Also RLS: the INSERT/UPDATE/DELETE policies on roles and role_permissions. The route gate is the weaker half — role_permissions_insert additionally refuses any grant the caller does not hold themselves, which no route gate could express because the bound depends on the row being written. |
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.manage_roles | ❌ | ❌ | ✅ |
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 (F5, migration 000046)
CORRECTED 2026-08-06 to what actually shipped. This table previously listed eight codes — view_own, update_own/update_org, cancel_own/cancel_org, delete — that exist in no migration and never have. F5.1 seeded the three below as their first definition anywhere, and F5.2 added the fourth. The old rows were pre-implementation prose; anything reasoning from them is reasoning from a false premise.
There is no patient column because there is no patient role template. Patients reach their own appointments through an RLS branch keyed on the portable patient_profile_id, which is what keeps a booking taken before onboarding visible to the person who made it.
| Permission | specialist | customer_support | admin |
|---|---|---|---|
appointments.view_org | ✅ | ✅ | ✅ |
appointments.create | ✅ | ✅ | ✅ |
appointments.manage | ❌ | ✅ | ✅ |
appointments.manage_files | ✅ | ✅ | ✅ |
appointments.record_fields | ✅ | ✅ | ✅ |
manageis front-desk authority over ANYONE's booking — reschedule, cancel with attribution, assign, drive status transitions. Withheld from specialists deliberately. A specialist runs their OWN session (start, finish, cancel, reschedule) through the RLS ownership branch instead; ownership is orthogonal to the grant.⚠️ This only became true on 2026-08-06.
appointments_updatehas permittedmanageORspecialist_id = current_app_specialist_id()since000046, but/status,/canceland/rescheduleall gated onmanage— which specialists do not hold — so the ownership branch was unreachable and a specialist could not start or finish the consultation in front of them. Those three routes now gate onview_organd let RLS decide./assignand/link-patientstay onmanage: choosing which clinician sees a patient, and resolving the callback queue, are front-desk acts rather than part of conducting a session.A consequence worth knowing: denial for a specialist on someone else's appointment is now 404, not 403 — it happens at RLS, which answers "no such row" so a status code cannot be used to enumerate real ids.
createis split frommanagebecause booking and amending are different authorities: a specialist may take a follow-up booking at the end of a session without thereby being able to cancel a colleague's. It is also the code F4'sspecialist_assignment_trackingpolicies (000045) gate on — a forward reference000046resolved.manage_filesis split frommanagefor the mirror-image reason. Attaching documents to a consultation is the conducting clinician's work, so gating it on front-desk authority would have locked out the primary user. Its grant shape matchesforms.manage, which specialists DO hold — notappointments.manage.record_fieldsis split frommanagefor the same reason asmanage_files, and frommanage_filesbecause they mean different things. It gates writing the clinic's own appointment-entity custom fields (F3.1's value store) onto a consultation — the values a generated document prints. Same audience asmanage_files, since the clinician conducting the session is who records what it found; kept as its own code because "attach a scan" and "record a measurement" are separately grantable, and a clinic that wants one without the other could not otherwise say so. Reading needs onlyview_org(RLS also admits the assigned clinician). There is no patient branch at all — a patient sees their own appointment, documents and attachments, but these are the clinic's internal observations about the visit, and what the clinic asks the patient directly is a form.Note the near-collision:
manage_filesand anymanage_fieldsspelling differ by one character, which is why the code isrecord_fields.There is no
delete. The status enum expresses every did-not-happen case andappointmentshas no DELETE policy at all; the record is terminal-stated rather than removed.appointment_filesdeletion is a soft delete gated bymanage_files, and its S3 object survives it.
Ownership (specialist=own-appointments, patient=own-appointments) is enforced by RLS through specialist_id = current_app_specialist_id() / patient_profile_id = ANY(current_human_patient_profile_ids()) — orthogonal to the permission check.
Patients (Phase 3)
Corrected 2026-08-04. This table previously listed
patients.view_self,patients.view_org,patients.onboard,patients.update_self,patients.update_organdpatients.delete— none of which were ever seeded. The real codes have beenpatients.view/.manage/.offboard(migration000006) and.impersonate(000013) all along.The drift was not harmless: F3's first cut gated the
custom_field_valueswrite policies onpatients.update_org, copied from this table. Since no such permission exists, the canonical value store was writable by nobody but an org owner, and every write-back would have failed silently on the first attempt. There is nopatientrole template — a patient's access to their own rows is RLS ownership, not a grant — so that column is gone too.
| Permission | specialist | customer_support | admin |
|---|---|---|---|
patients.view | ✅ | ✅ | ✅ |
patients.manage | ❌ | ✅ | ✅ |
patients.offboard | ❌ | ❌ | ✅ |
patients.impersonate | ❌ | ❌ | ✅ |
patients.change_email | ❌ | ✅ | ✅ |
patients.change_email grants the ability to OFFER a change, never to make one (000002). The confirmation link goes to the NEW address, and only whoever holds that inbox can complete it — so a front desk can hold this without it becoming a way to re-point a patient's login at an address the holder controls. It is deliberately NOT folded into patients.manage: these requests arrive by phone and routing each through an admin would make the feature slow enough that staff work around it, but the ability to start re-pointing a login should be legible in a role's permission list so a clinic that does not want reception holding it can remove exactly this. See Changing an Account Email.
patients.manage is wider than its name. Besides creating, updating and archiving patient records, it gates who sees and can force-close every patient-impersonation session at the clinic (000013) and who sees patient invites (000012). Treat a request to grant it as a request for all four.
That came up concretely in F3. Write-back propagates a form answer into custom_field_values, which this permission gates — so a specialist, who holds forms.manage but not patients.manage, recorded a measurement mid-consult and watched the canonical value silently stay stale. Granting them patients.manage was the obvious fix and the wrong one: a physiotherapist filling in a weight has no business acquiring impersonation-session oversight. 000043 instead gives custom_field_values a second, narrow door — the clinic's per-binding writes_back opt-in AND the ability to reach the form the answer came from (owning it as the patient, or forms.manage as staff). The capability is granted, nothing around it is.
Specialists & specialties (F1 — shipped in migration 000040)
| Permission | specialist | customer_support | admin |
|---|---|---|---|
specialties.manage | ❌ | ❌ | ✅ |
specialists.view_org | ✅ | ✅ | ✅ |
specialists.manage | ❌ | ❌ | ✅ |
These are the codes that actually shipped. An earlier draft of this section sketched specialists.view / .update_self / .create / .update_org / .delete. That shape was not built, for two reasons:
- It doesn't match the shipped convention. Every permission on the platform is
{resource}.view_orgor{resource}.manage— seeconsents.*,subscriptions.*,patient_subscriptions.*,patient_tiers.manage,locations.manage,catalog.manage. A per-verb split here would have been the only one of its kind. update_selfdoes not exist because self-edit does not exist. A specialist cannot change their own roster profile at all —specialists.manageis the only way in. A roster entry is how the clinic presents a provider to patients on a public booking page, under a name and title it stands behind, so the person named by it is not its author. F1 originally shipped an ownership branch onspecialists_updateplus aPATCH /me/specialist-profile; both were removed on 2026-08-08. What a person owns and may change is their ACCOUNT —humans.name, timezone, locale — viaPATCH /v1/me.
There is no patient column because there is no patient role template — only specialist, customer_support and admin are seeded (000002). Patients read the roster and specialty list through dedicated RLS policies gated on current_human_is_patient_at(organization_id), which the portal booking picker depends on; without them F4/F5 booking returns zero rows and presents as "no specialists available" rather than an authorization error.
Reading specialties needs no permission at all — it is RLS-scoped to org members plus the clinic's patients, and carries no field that specialties.manage alone should gate. Field-level restriction on the specialist self-edit path (a specialist may not flip their own scheduling_active) lives in the service layer, which knows which fields changed.
Offerings (F2.1 — shipped in migration 000041)
| Permission | specialist | customer_support | admin |
|---|---|---|---|
offerings.view_org | ✅ | ✅ | ✅ |
offerings.manage | ❌ | ❌ | ✅ |
Same {resource}.view_org / {resource}.manage convention as F1, and the same absence of a patient column for the same reason — there is no patient role template.
offerings.manage carries no commercial authority. It publishes and staffs a clinical service; it does not price, sell, or grant access to anything. F2.2 packages and F2.3 products are deferred, so there is no purchase path to gate. Do not conflate this with the shipped access_offers permissions (F14 commerce), which govern a different concept that shares a word.
The patient read is bounded by publication state, which is the one place this differs from specialists. The RLS SELECT policy admits a patient of the org only when published AND is_public:
published+is_public→ the bookable catalog a patient browsespublished, notis_public→ staff-only; the clinic books it on the patient's behalf- not
published→ a draft; nobody books it
offerings.view_org is deliberately not publish-bounded — staff configure drafts, so they see every state. Getting the patient branch wrong leaks a half-configured service onto a public booking page, which is worse than a missing row: a patient books something the clinic has not finished setting up. Pinned by TestOfferings_PatientSeesOnlyPublishedPublic.
Forms, templates & custom fields (F3 — shipped in migrations 000042 + 000043)
Corrected 2026-08-04, when F3 shipped. The seven codes this section used to list (
forms.view_own,forms.create,forms.fill_own,forms.fill_org,forms.sign,forms.delete_unsigned,form_templates.view) were never seeded and are not what shipped. Four codes exist.
| Permission | specialist | customer_support | admin |
|---|---|---|---|
custom_fields.manage | ❌ | ❌ | ✅ |
form_templates.manage | ❌ | ❌ | ✅ |
forms.view_org | ✅ | ✅ | ✅ |
forms.manage | ✅ | ✅ | ✅ |
No patient column, and no forms.view_own / forms.fill_own. There is no patient role template, and a patient reading and filling their own form is ownership expressed in RLS — patient_profile_id = ANY(current_human_patient_profile_ids()) — not a grant. Encoding it as a permission would put the same rule in two places, the identical call F1 made for specialist self-edit. The portal's /v1/me/forms routes therefore mount with no permission gate at all: RequirePermission fails closed for a patient session, so a gate there would lock out exactly the people the surface is for.
Reading custom-field definitions needs no permission — they are configuration every form renderer resolves, so RLS scopes them to org members plus the clinic's patients, who see non-private definitions only. Same call specialties made.
No separate forms.sign or forms.delete_unsigned. Signing is part of managing a form, and a signed form is immutable for every holder of forms.manage alike — enforced at the handler, the service, and by a database trigger, not by withholding a permission. Archival is the one change permitted after signing, because retention and GDPR erasure still have to work on a signed record.
Attaching templates to offerings rides offerings.manage, not a fifth code: the junction is part of configuring the offering, and whoever configures a service decides which forms it carries.
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).
Sessions (Layer 10 — Telerehabilitation)
The session model is source-agnostic: sessions is the org-curated template; session_runs is the patient-authored clinical record (one row per playthrough). Staff manage templates; patients write their own runs via the current_human_patient_profile_ids() RLS gate — there is no patient role, so no permission gates the patient write path. Staff get read-only visibility into the run history via session_runs.view.
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
sessions.read | ❌ | ✅ | ✅ | ✅ |
sessions.manage | ❌ | ✅ | ❌ | ✅ |
session_runs.view | ❌ | ✅ | ✅ | ✅ |
Patient writes (POST /v1/session-runs, POST /v1/session-runs/{run_id}/pain, POST /v1/session-runs/{run_id}/complete) are RLS-gated on session_runs.patient_id ↔ current_human_patient_profile_ids(), mirroring how consents and patient_subscriptions model patient self-access.
Patient catalog (F9.x — patient self-serve merchandising)
The clinic-curated merchandising layer (catalog_sections + catalog_entries, migration 000032) that decides which programs/sessions appear in the patient self-serve catalog and how they're presented. Published rows are patient-visible via RLS without any permission; these permissions gate the staff curation surface (including draft/archived rows). Platform-default rows (organization_id IS NULL) are managed via the admin pool (Console), not these permissions.
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
catalog.read | ❌ | ✅ | ✅ | ✅ |
catalog.manage | ❌ | ✅ | ❌ | ✅ |
content.grant (catalog Phase 3.1, migration 000034) gates create/list/revoke of per-patient content grants (patient_content_grants — comp/promo/legacy ownership that unlocks catalog content independent of tier). Granted to admin + customer_support (a commercial action, mirroring patient_subscriptions.manage); not specialist. Patients read their own grants via RLS (no permission). Never gates prescriptions.
| Permission | patient | specialist | customer_support | admin |
|---|---|---|---|---|
content.grant | ❌ | ❌ | ✅ | ✅ |
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.manage(permission) is the sole gate on changing a roster profile, a specialist's own included — the roster is clinic-authored. Thehuman_id = current_app_principal_id()branch survives only on the SELECT policy, so a specialist can READ their own row even if their role'sview_orggrant were revoked.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. The gaps/ tree that used to hold the rollout plan was retired on 2026-08-02. This section's "planned" framing is stale: internal/core/ratelimit (Store / Policy / Middleware / IPKey / PrincipalKey) is shipped and mounted throughout services/api/internal/core/server/routes.go, including a principal-keyed limiter on patient impersonation and break-glass. Which of the remaining sensitive permissions still lack a policy has not been re-audited.
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.