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.

Every code carries a scope saying who can hold it at a clinic:

scopeHeld byExamples
organization (default)Any clinic role it is granted to; the owner alwaysNearly every code
ownerOnly the owner, through the owner flag — never a roleorganizations.transfer_ownership
platformOnly superadminsexercises.manage, catalog.tags.manage_platform, content.platform_write
patientNo staff role; reached through the patient_self RLS gatesprotocols.enroll

The owner holds every organization and owner code, whatever their role grants — SubjectLoader.LoadPermissions builds that set, so /v1/me current_permissions, Subject.HasPermission and the Clinic app all see the same thing, and current_app_has_permission() applies the same rule in RLS. It stops at the clinic: a platform code writes rows every clinic reads (the exercise catalog, platform taxonomy, usually through the AdminPool), so an owner bypass on it would let one clinic edit another's catalog. For the same reason role_permissions_insert refuses any non-organization code, and the role editor never lists one. A new code that must not reach clinic staff is seeded with its scope in the same INSERT.

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.

Permissions answer what may this person do; the Clinic menu answers where does this person work. A role often needs a capability for work done elsewhere — customer support reads the program library to prescribe from it, without wanting Programs in its menu — so the two are kept apart.

Each role carries an optional roles.nav_layout: named groups of Clinic menu-entry keys, in order, edited on the role's Menu tab (PUT /v1/organizations/{id}/roles/{roleId}/navigation, organizations.manage_roles, audited). NULL means the automatic menu, built from permissions, where a group left with a single entry folds into the one above. The layout chooses among the entries the role can open and cannot add one — the sidebar re-checks access for every entry — and nothing server-side reads it. Hiding an entry therefore closes nothing: its URL still answers whatever the permissions allow. To take access away, remove the permission. The entries and their access rules live in one catalog, apps/clinic/lib/nav-catalog.tsx, read by both the sidebar and the editor; Organization cannot be hidden from a role that holds organizations.manage_roles, so a layout can never strand the role editor.

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

Patient payments (F14.6 — migration 000061) ​

Payments patients make through the clinic's own connected Stripe account.

Permissionspecialistcustomer_supportadmin
payments.view❌✅✅
payments.refund❌❌✅
payments.assign❌✅✅
payments.invoice❌❌✅
payments.export❌❌✅

payments.view is read-only. It gates the patient record's Payments panel and the checkout rows. It goes to customer_support for the same reason patient_subscriptions.view_org does. payments.refund gives back what is left of a payment from that panel; it is admin-only because money leaves the clinic. A refund made in the clinic's Stripe dashboard arrives through the webhook and is recorded the same way. Connecting Stripe reuses organizations.manage_integrations, and binding a Stripe price to an offer reuses products.manage. Patients read their own payments and checkouts through row ownership (patients.patient_profile_id IN current_human_patient_profile_ids()). payments.assign (F14.7, 000062) is the front desk's handling of a purchase made without an account that nobody has activated: resend its activation link, correct the address it goes to, or attach it to a patient. Correcting the address decides who can activate what was paid for, so it is its own permission rather than part of payments.view. payments.invoice (F14.8, 000063) issues a payment's fiscal invoice through the clinic's invoicing provider (FGO), one payment or a date range, sends a failed one again, and emails the patient their invoice link again. Granted by 000063 only to an admin role that was never customised; a clinic whose admin role was edited adds it on its Roles page. Admin-only: issuing a fiscal document has weight, and it is a different act from a refund. Seeing a payment's invoice and downloading its PDF need only payments.view; connecting FGO reuses organizations.manage_integrations. payments.export (000063, folded 2026-10-25) downloads the payment list as a file — the selected rows, or a period — for the clinic's accountant. Admin-only by default, and its own permission because a period of patient payments leaving the platform is more than seeing the list; each export writes one EXPORT audit row. A clinic grants it to its accountant's role on the Roles page.

The Clinic's Sales section is payments.view / payments.assign, not products.manage (2026-10-22). Sales holds the waiting purchases, shop orders and the fulfillments ledger: front-desk work. payments.view reads all three, plus the offer names and campaign headlines they reference (RLS read arms on access_offers, access_offer_orders, access_offer_campaigns, access_offer_fulfillments). payments.assign re-checks an unmatched shop order and emails or copies its activation link, the same hand-over as for a waiting purchase. products.manage keeps every write: authoring offers and campaigns (the Clinic's Offers section) and a shop's SKU bindings (on that shop's integration page). Before this split, handling an order required the permission that sets prices.

"De rezolvat" splits the same way (commerce step 2, 000065, no new permission). payments.view reads the list (and the sync's state); payments.assign links a Stripe customer to a patient, closes an item with a reason, and starts a Sync, all front-desk hand-over; mapping a Stripe price to a product is products.manage, because it says what the clinic sells. A listing's RLS read gained a payments.view arm so the list can name the product a price was mapped to.

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.delete — none 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.send_sign_in_link❌✅✅
patients.show_sign_in_link❌✅✅

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.send_sign_in_link emails a patient a one-tap sign-in link (000006). It exists for the patient who phones because the emailed sign-in code expires before they manage to copy it: the front desk presses "Email a sign-in link" on the patient page and the patient gets the same single-use, one-day link the programme email carries (sign_in_links, 000056). It is no way into the account: the link goes only to the address already on it, never one the sender types, and the response never carries it, so the sender learns where it went and not how to use it. Kept out of patients.manage for the reason patients.change_email is, so a clinic can take exactly this away from reception. Patients with no account are invited instead.

patients.show_sign_in_link shows the sender that link instead (000006), to copy into a text message or scan as a QR code at the desk — for the patient who cannot find the email at all. Kept apart from patients.send_sign_in_link because this one IS a way into the account for whoever holds it: it lives one hour, not a day, stays single-use, and each link shown is audited (sign_in_link CREATE, delivery: shown) against the staff member who asked for it. Activating any sign-in link is audited against the account's own patient, so it appears on the patient's Activity tab.

patients.offboard is the only way to remove a patient (000054). It runs the same off-boarding cascade as the patient leaving from the Portal — soft-deleted patient row, cancelled subscriptions, ended self-enrolments (never deleted), withdrawn org consents — through offboard_patient(). A patient holding org_terms has it withdrawn on their behalf; a walk-in who never accepted terms gets the same effects applied directly. Before 000054, DELETE /patients/{id} sat on patients.manage and only set deleted_at, leaving subscriptions live and consents granted. Restorable when the clinic removed them (2026-11-02): restore_patient() un-hides the record under patients.manage (admin + customer support — putting a record back is record management), and GET …/patients/removed lists removed patients (name, register number, when, removal_source) through list_removed_patients(), the one narrow view onto profiles staff otherwise cannot read. Consents stay withdrawn and nothing cancelled or ended comes back. Refused when the patient left on their own (removal_source = 'patient' — only they rejoin), when their data was erased, or when the same person is already registered again. The same permission runs the duplicate check the Add patient dialog makes before creating (POST …/patients/duplicates, find_patient_duplicates()): this clinic's active and removed patients with a similar name (trigram, word order and small typos ignored) or the same email or phone, names and register numbers only. Refused while anything is open (2026-11-02): a live prescription, a coming appointment, a live or billing subscription, purchased access, or a family plan others use — patient_open_items() lists them, GET …/patients/{patientId}/removal-check serves the list to the Remove dialog, and both removal and the patient's own leave (POST /v1/me/leave-clinic, leave_clinic()) answer 409 patient_has_open_items until each is settled by hand — each refusal recorded as REFUSED on the patient with attempt and open_items. Settling them automatically on leave (provider-side subscription cancel, refunds, slot release) is designed, not built; until then the Portal tells the patient to contact the clinic.

Sending a prescription can invite its patient, without patients.manage (2026-10-06). The hand-over (protocols.prescribe) accepts the email of a patient who has no account and sends ONE email with the programme and an account-activation link. It is a narrow door in the same spirit as 000043's write-back: it can invite only that prescription's patient to claim their own record, it resends a live invitation to the SAME address, and it refuses one to a DIFFERENT address — redirecting somebody's pending invitation stays with patients.manage on the record. Specialists prescribe and must not need the wider permission to reach their own patient.

Applying an access offer is patient_subscriptions.manage (2026-10-06; moved off the send 2026-11-02). The Subscription tab's Apply offer lists the clinic's ACTIVE offers through GET /access-offers/active and applies one through POST /access-offers/patient-fulfillments, both under that permission rather than products.manage: applying an offer is the front desk's subscription work, authoring offers is not. Unlike the invite above, it is NOT opened to protocols.prescribe: it is a billing decision (paid or free access).

Giving an offer to an email address is patient_subscriptions.manage (2026-11-01). POST /access-offers/{offerId}/gift makes a waiting 0-lei order and emails the activation link; it is the same billing decision as applying an offer to a listed patient (free access), for somebody who is not a patient yet. The public free claim (POST /v1/public/campaigns/{campaignId}/claim) needs no permission: it is a visitor's, limited per IP and per address.

patients.manage is wider than its name. Besides creating and updating 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.

A patient's plan is prescribing, not library authoring (000058, 2026-10-07). Content authority follows the row's tier. Library rows (programs / sessions with patient_id IS NULL, and their phases, assets, exercises, audio) are written under content.write (sessions also sessions.manage), as before. A patient-specific copy — the plan built for one patient, composed or adopted, and every child row under it — is written under protocols.prescribe plus access to that patient (content.read + patients.view). So a clinic can let someone build and edit patients' plans without letting them author the org's guided programs or exercises. The RLS helpers current_app_can_author_program / current_app_can_author_session decide child tables by their parent's tier; the route gate middleware.RequireAuthorityByOwnership applies the same split on /v1/programs/{id}/… and /v1/sessions/{sessionId}/… and must stay in step with them. Before this, a role holding protocols.prescribe without content.write passed the compose route and failed in the database with a 500.

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❌✅❌✅

A library program's public page (000067) adds no permission either: catalog.read reads its settings and catalog.manage publishes, renames and removes it (/v1/site-pages/programs/{programId}). So a specialist, who curates the catalog, can publish a program's page, while a webinar's page stays with webinars.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❌❌✅✅

Webinars (F17 — migration 000060) ​

Provider webinars a clinic has linked through a webinars integration. Published rows are patient-visible via RLS without any permission; webinars.view adds drafts and archived rows for staff, and webinars.manage links webinars and sets their access (free / paid / included in plans) — a commercial decision, so admin only. Adding a paid month to a patient is a content grant and stays under content.grant. Connecting the provider itself is organizations.manage_integrations. A webinar's public page (000067) adds no permission: webinars.view reads its settings and webinars.manage publishes, renames and removes it. Spec: features/integrations/webinars.md.

Permissionpatientspecialistcustomer_supportadmin
webinars.view❌✅✅✅
webinars.manage❌❌❌✅

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.