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 3: specialist, customer_support, admin. (Patients are not a role — portal access follows from the existence of a patients row. 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 / admin rows with the same bundles, and the clones keep is_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:

ColumnMeaningConsequence
is_systemIdentity — "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_atInheritance — "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_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.

⚠️ 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}/roles and nothing else: no create-role, no grant, no revoke. Console's /role-templates and /permissions pages 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 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.manage_rolesPOST /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 halfrole_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_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.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.

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

Permissionspecialistcustomer_supportadmin
appointments.view_org
appointments.create
appointments.manage
appointments.manage_files
appointments.record_fields
  • manage is 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_update has permitted manage OR specialist_id = current_app_specialist_id() since 000046, but /status, /cancel and /reschedule all gated on manage — 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 on view_org and let RLS decide. /assign and /link-patient stay on manage: 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.

  • create is split from manage because 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's specialist_assignment_tracking policies (000045) gate on — a forward reference 000046 resolved.

  • manage_files is split from manage for 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 matches forms.manage, which specialists DO hold — not appointments.manage.

  • record_fields is split from manage for the same reason as manage_files, and from manage_files because 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 as manage_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 only view_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_files and any manage_fields spelling differ by one character, which is why the code is record_fields.

  • There is no delete. The status enum expresses every did-not-happen case and appointments has no DELETE policy at all; the record is terminal-stated rather than removed. appointment_files deletion is a soft delete gated by manage_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_org and patients.deletenone of which were ever seeded. The real codes have been patients.view / .manage / .offboard (migration 000006) and .impersonate (000013) all along.

The drift was not harmless: F3's first cut gated the custom_field_values write policies on patients.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 no patient role template — a patient's access to their own rows is RLS ownership, not a grant — so that column is gone too.

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

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

  1. It doesn't match the shipped convention. Every permission on the platform is {resource}.view_org or {resource}.manage — see consents.*, subscriptions.*, patient_subscriptions.*, patient_tiers.manage, locations.manage, catalog.manage. A per-verb split here would have been the only one of its kind.
  2. update_self does not exist because self-edit does not exist. A specialist cannot change their own roster profile at all — specialists.manage is 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 on specialists_update plus a PATCH /me/specialist-profile; both were removed on 2026-08-08. What a person owns and may change is their ACCOUNT — humans.name, timezone, locale — via PATCH /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)

Permissionspecialistcustomer_supportadmin
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 browses
  • published, not is_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.

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

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

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.

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

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

Permissionpatientspecialistcustomer_supportadmin
content.grant

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.manage (permission) is the sole gate on changing a roster profile, a specialist's own included — the roster is clinic-authored. The human_id = current_app_principal_id() branch survives only on the SELECT policy, so a specialist can READ their own row even if their role's view_org grant were revoked.
  • 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. 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:

  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.