RBAC Permission Matrix
Roles
| Role | Code | Scope | Description |
|---|---|---|---|
| Patient | patient | Own data within current org | End user receiving healthcare. Sees only their own appointments, forms, and profile. |
| Specialist | specialist | Own appointments + assigned patients within current org | Healthcare provider. Sees appointments where they are the assigned specialist, plus linked patient data. |
| Customer Support | customer_support | All data (read) within current org, limited write | Support staff. Can view all org data to assist patients/specialists. Cannot manage templates, integrations, or segments. |
| Admin | admin | Full management within current org | Organization manager. Full CRUD on all org resources. Cannot access other organizations. |
| Superadmin | superadmin | Cross-organization, full system access | Platform operator (RestartiX team only). Creates and manages organizations, assigns initial admins. Uses AdminPool (owner role) which bypasses all RLS policies. Not available to clinic staff. |
Enforcement Layers
Access control is enforced at three layers. All must agree for an operation to succeed.
Request
│
▼
┌──────────────────────────────────────┐
│ Layer 0: Connection Pool Routing │
│ ────────────────────────────────── │
│ • Superadmins → AdminPool (owner) │
│ • Everyone else → AppPool (RLS) │
│ • Public endpoints → AppPool │
└──────────┬───────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Layer 1: Middleware (Go) │
│ ───────────────────────────── │
│ • ClerkAuth: verify token │
│ • OrgContext: route to pool │
│ • RequireRole: check role │
│ • RequireOwner: check entity │
│ ownership (where needed) │
└──────────┬──────────────────────┘
│
▼
┌─────────────────────────────────┐
│ Layer 2: PostgreSQL RLS │
│ ───────────────────────────── │
│ • organization_id check │
│ • role-based SELECT/INSERT/ │
│ UPDATE/DELETE policies │
│ • Ownership checks (user_id, │
│ specialist_id sub-queries) │
│ • Cannot be bypassed from app │
│ • Superadmins bypass via pool │
└─────────────────────────────────┘Pool routing handles: superadmins get the owner pool (bypasses RLS), all other users and public endpoints get the restricted pool (RLS enforced).
Middleware handles: route-level role gating, field-level response filtering, business rule validation (e.g., "cannot cancel a done appointment").
RLS handles: row-level data isolation. Even if middleware has a bug, a non-superadmin request on the AppPool cannot access data outside its org scope.
For schema details, see the schema.sql file in each feature directory under ../features/{feature}/schema.sql.
Permission Matrix
Legend:
- C = Create | R = Read | U = Update | D = Delete
- ✅ = Allowed | ❌ = Denied | 🔒 = Own only | 📋 = Published/public only
Organizations
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| List organizations | 🔒 own orgs | 🔒 own orgs | 🔒 own orgs | 🔒 own orgs | ✅ all |
| View organization | 🔒 current org | 🔒 current org | 🔒 current org | 🔒 current org | ✅ any |
| View by slug (public) | ✅ | ✅ | ✅ | ✅ | ✅ |
| Update organization | ❌ | ❌ | ❌ | ✅ current org | ✅ any |
| Create organization | ❌ | ❌ | ❌ | ❌ | ✅ |
| Delete organization | ❌ | ❌ | ❌ | ❌ | ✅ |
| View API keys | ❌ | ❌ | ❌ | ✅ current org | ✅ any |
| Manage custom domains | ❌ | ❌ | ❌ | ✅ current org | ✅ any |
| Connect user to org | ❌ | ❌ | ❌ | ✅ current org | ✅ any |
| Switch organization | 🔒 own orgs | 🔒 own orgs | 🔒 own orgs | 🔒 own orgs | ✅ any |
Enforcement: RequireRole("superadmin") on create/delete org. RequireRole("admin") on update, domains, connect-user, API keys. RLS: id = current_app_org_id() (superadmins bypass RLS via AdminPool).
Schema: See ../features/organizations/schema.sql
Appointments
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| List appointments | 🔒 own | 🔒 own (as specialist) | ✅ org | ✅ org | ✅ all |
| View appointment | 🔒 own | 🔒 own (as specialist) | ✅ org | ✅ org | ✅ any |
| View by UID | 🔒 own | 🔒 own (as specialist) | ✅ org | ✅ org | ✅ any |
| Create (basic) | ❌ | ✅ | ✅ | ✅ | ✅ |
| Create from template | ❌ | ✅ | ✅ | ✅ | ✅ |
| Create from intake | ❌ | ✅ | ✅ | ✅ | ✅ |
| Attach forms | ❌ | 🔒 own | ✅ org | ✅ org | ✅ any |
| Reschedule | ❌ | 🔒 own | ✅ org | ✅ org | ✅ any |
| Cancel | ❌ | 🔒 own | ✅ org | ✅ org | ✅ any |
| Update | ❌ | 🔒 own | ✅ org | ✅ org | ✅ any |
| Delete (soft) | ❌ | ❌ | ❌ | ✅ org | ✅ any |
| Calendar view | 🔒 own | 🔒 own (as specialist) | ✅ org | ✅ org | ✅ all |
Specialist ownership: specialist_id IN (SELECT id FROM specialists WHERE user_id = current_app_user_id())
Patient ownership: patient_person_id IN (SELECT current_user_patient_person_ids()) — covers both the patient themselves and anyone managing them as a caregiver (see patient_person_managers)
Key change from Strapi: Patients can no longer create appointments. In the current Strapi system, all authenticated users can POST to any endpoint. In Go, appointment creation is restricted to staff roles. Patients book through the public scheduling flow or are booked by staff.
Enforcement: RequireRole("specialist", "customer_support", "admin") on create/modify endpoints. RLS handles row filtering.
Schema: See ../features/appointments/schema.sql
Appointment Templates
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| List templates | 📋 published + public | ✅ org | ✅ org | ✅ org | ✅ all |
| View template | 📋 published + public | ✅ org | ✅ org | ✅ org | ✅ any |
| View by slug | 📋 published + public | ✅ org | ✅ org | ✅ org | ✅ any |
| Create template | ❌ | ❌ | ❌ | ✅ | ✅ |
| Update template | ❌ | ❌ | ❌ | ✅ | ✅ |
| Delete template | ❌ | ❌ | ❌ | ✅ | ✅ |
Enforcement: RequireRole("admin") on CUD. RLS: patients see only is_public = TRUE AND published = TRUE AND organization_id = current_app_org_id().
Schema: See ../features/appointments/schema.sql
Patients
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| List patients | 🔒 self only | ✅ org | ✅ org | ✅ org | ✅ all |
| View patient | 🔒 self only | ✅ org | ✅ org | ✅ org | ✅ any |
| Onboard patient | ❌ | ✅ | ✅ | ✅ | ✅ |
| Update patient | 🔒 own profile | ❌ | ✅ org | ✅ org | ✅ any |
| Delete patient (soft) | ❌ | ❌ | ❌ | ✅ org | ✅ any |
| Impersonate patient | ❌ | ❌ | ❌ | ✅ org | ✅ any |
Specialist patient access: Specialists can VIEW all patients in their org (needed to access patient profiles during appointments) but cannot UPDATE patient records directly. Patient profile updates happen through form filling — forms with profile_field_key write to patient_persons (portable), and forms with custom_field_id write to org-scoped custom_field_values.
Enforcement: RequireRole("admin") on impersonate/delete. RLS: patients see only their own record (resolved through patient_person_id using current_user_patient_person_ids()).
Schema: See ../features/patients/schema.sql
Specialists
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| List specialists | ✅ org (read-only) | ✅ org | ✅ org | ✅ org | ✅ all |
| View specialist | ✅ org (read-only) | ✅ org | ✅ org | ✅ org | ✅ any |
| Create specialist | ❌ | ❌ | ❌ | ✅ | ✅ |
| Update specialist | ❌ | 🔒 own profile | ❌ | ✅ org | ✅ any |
| Delete specialist (soft) | ❌ | ❌ | ❌ | ✅ org | ✅ any |
Why patients can read specialists: Patients need to see specialist names, photos, and specialties when viewing appointments and booking.
Specialist self-edit: Specialists can update their own profile (signature, bio, etc.) but not other specialists. RequireRole("specialist", "admin") on PUT with ownership check for specialists.
Enforcement: RequireRole("admin") on create/delete. Specialist PUT: RequireRole("specialist", "admin") + ownership check.
Schema: See ../features/specialists/schema.sql
Specialties
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| List specialties | ✅ org | ✅ org | ✅ org | ✅ org | ✅ all |
| View specialty | ✅ org | ✅ org | ✅ org | ✅ org | ✅ any |
| Create specialty | ❌ | ❌ | ❌ | ✅ | ✅ |
| Update specialty | ❌ | ❌ | ❌ | ✅ | ✅ |
| Delete specialty | ❌ | ❌ | ❌ | ✅ | ✅ |
Enforcement: RequireRole("admin") on CUD. RLS: org-scoped.
Schema: See ../features/specialists/schema.sql
Forms
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| List forms | 🔒 own | ✅ org | ✅ org | ✅ org | ✅ all |
| View form | 🔒 own | ✅ org | ✅ org | ✅ org | ✅ any |
| Create form | ❌ | ✅ | ✅ | ✅ | ✅ |
| Update form (save values) | 🔒 own | ✅ org | ✅ org | ✅ org | ✅ any |
| Sign form | 🔒 own | ✅ org | ❌ | ✅ org | ✅ any |
| Delete form | ❌ | ❌ | ❌ | ✅ (unsigned only) | ✅ (unsigned only) |
Patient form filling: Patients can fill their own forms (surveys, disclaimers). They cannot create forms — forms are auto-generated during appointment creation.
Specialist form filling: Specialists fill report, analysis, advice, and prescription forms. They can also fill any form in their org (e.g., filling on behalf of a patient).
Sign restriction: Customer support cannot sign forms (they are not a party to the medical interaction). Only patients, specialists, and admins can sign.
Business rule: Signed forms are immutable. Any PUT to a signed form returns 409 Conflict.
Enforcement: RequireRole("specialist", "customer_support", "admin") on create. Sign: RequireRole("patient", "specialist", "admin"). Delete: RequireRole("admin") + status != 'signed' check. RLS: patients see only their own forms (filtered by patient_person_id through current_user_patient_person_ids()).
Schema: See ../features/forms/schema.sql
Form Templates
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| List form templates | ✅ org (read-only) | ✅ org | ✅ org | ✅ org | ✅ all |
| View form template | ✅ org (read-only) | ✅ org | ✅ org | ✅ org | ✅ any |
| Create form template | ❌ | ❌ | ❌ | ✅ | ✅ |
| Update form template | ❌ | ❌ | ❌ | ✅ | ✅ |
| Publish form template | ❌ | ❌ | ❌ | ✅ | ✅ |
| View version history | ❌ | ❌ | ❌ | ✅ | ✅ |
| Delete form template | ❌ | ❌ | ❌ | ✅ | ✅ |
Enforcement: RequireRole("admin") on all mutations. RLS: org-scoped.
Schema: See ../features/forms/schema.sql
Reports (appointment_documents type=report)
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| List reports | 🔒 own + published | ✅ org | ✅ org | ✅ org | ✅ all |
| View report | 🔒 own + published | ✅ org | ✅ org | ✅ org | ✅ any |
| Download PDF | 🔒 own + published (audience=patient forced) | ✅ org | ✅ org | ✅ org | ✅ any |
| Create report | ❌ | ✅ | ❌ | ✅ | ✅ |
| Update report | ❌ | 🔒 own | ❌ | ✅ org | ✅ any |
| Publish report | ❌ | 🔒 own | ❌ | ✅ org | ✅ any |
| Delete report | ❌ | ❌ | ❌ | ✅ org | ✅ any |
Patient PDF access: Patients can only download PDFs for published reports. Private fields are automatically excluded (audience=patient is forced regardless of query param).
Customer support: Can VIEW reports for troubleshooting but cannot create, modify, or publish them. Reports are medical documents authored by specialists.
Enforcement: RequireRole("specialist", "admin") on create/update/publish. RequireRole("admin") on delete. RLS: patients see only published reports linked to their own appointments (filtered by patient_person_id through current_user_patient_person_ids()).
Schema: See ../features/documents/schema.sql
Prescriptions (appointment_documents type=prescription)
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| List prescriptions | 🔒 own + published | ✅ org | ✅ org | ✅ org | ✅ all |
| View prescription | 🔒 own + published | ✅ org | ✅ org | ✅ org | ✅ any |
| Download PDF | 🔒 own + published | ✅ org | ✅ org | ✅ org | ✅ any |
| Create prescription | ❌ | ✅ | ❌ | ✅ | ✅ |
| Update prescription | ❌ | 🔒 own | ❌ | ✅ org | ✅ any |
| Publish prescription | ❌ | 🔒 own | ❌ | ✅ org | ✅ any |
| Delete prescription | ❌ | ❌ | ❌ | ✅ org | ✅ any |
Same pattern as reports. Prescriptions have an additional validation: specialist must have a signature uploaded before PDF can be generated.
Enforcement: Same as reports. Additional check: specialist.signature_url IS NOT NULL on PDF generation.
Schema: See ../features/documents/schema.sql
PDF Templates (Visual PDF designer)
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| List templates | ❌ | 📋 published only | 📋 published only | ✅ org | ✅ all |
| View template | ❌ | 📋 published only | 📋 published only | ✅ org | ✅ any |
| Create template | ❌ | ❌ | ❌ | ✅ org | ✅ any |
| Update template | ❌ | ❌ | ❌ | ✅ org | ✅ any |
| Publish template | ❌ | ❌ | ❌ | ✅ org | ✅ any |
| Rollback template | ❌ | ❌ | ❌ | ✅ org | ✅ any |
| Delete template | ❌ | ❌ | ❌ | ✅ org | ✅ any |
| List components | ❌ | ✅ org | ✅ org | ✅ org | ✅ all |
| View component | ❌ | ✅ org | ✅ org | ✅ org | ✅ any |
| Create component | ❌ | ❌ | ❌ | ✅ org | ✅ any |
| Update component | ❌ | ❌ | ❌ | ✅ org | ✅ any |
| Delete component | ❌ | ❌ | ❌ | ✅ org | ✅ any |
| Preview template | ❌ | ✅ org | ✅ org | ✅ org | ✅ any |
| Render PDF | ❌ | 🔒 own appointments | ✅ org | ✅ org | ✅ any |
Admin-only management. PDF templates control the visual layout of generated PDFs (reports, prescriptions, certificates). Specialists/customer support can view published templates and components for preview purposes, and specialists can render PDFs for their own appointments. Template editing is admin-only.
Enforcement: RequireRole("admin") on CUD operations. RLS: org-scoped, admins can manage all templates, specialists can view published templates and render for own appointments.
Schema: See ../features/pdf-templates/schema.md
Custom Fields (entity attribute definitions)
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| List custom fields | ✅ org (read-only) | ✅ org | ✅ org | ✅ org | ✅ all |
| Create custom field | ❌ | ❌ | ❌ | ✅ | ✅ |
| Update custom field | ❌ | ❌ | ❌ | ✅ | ✅ |
| Delete custom field | ❌ | ❌ | ❌ | ✅ (non-system) | ✅ (non-system) |
System fields (where system_key IS NOT NULL) cannot be deleted or have their key modified by anyone, including superadmin.
Enforcement: RequireRole("admin") on CUD. Business rule: system_key fields are protected.
Schema: See ../features/custom-fields/schema.sql
Custom Field Values (patient/specialist profile data)
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| View patient profile | 🔒 own | ✅ org | ✅ org | ✅ org | ✅ any |
| Update patient profile | 🔒 own | ❌ | ✅ org | ✅ org | ✅ any |
| Prefill values (for forms) | 🔒 own | ✅ org | ✅ org | ✅ org | ✅ any |
| View specialist profile | ❌ | 🔒 own | ✅ org | ✅ org | ✅ any |
| Update specialist profile | ❌ | 🔒 own | ❌ | ✅ org | ✅ any |
Auto-sync: When a patient fills a form field that references custom_field_id, the custom field value is automatically upserted. This is the primary way patient profiles are updated — not via direct API calls.
Specialist profile read by patients: Patients cannot view specialist custom field values directly. They see specialist public info (name, title, specialties) from the specialists table, not from custom field values.
Enforcement: Ownership check in handler. RLS: org-scoped with application-level entity ownership filtering.
Schema: See ../features/custom-fields/schema.sql
Segments
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| List segments | ❌ | ❌ | ✅ org (read-only) | ✅ org | ✅ all |
| View segment | ❌ | ❌ | ✅ org (read-only) | ✅ org | ✅ any |
| View segment members | ❌ | ❌ | ✅ org (read-only) | ✅ org | ✅ any |
| Create segment | ❌ | ❌ | ❌ | ✅ | ✅ |
| Update segment (rules) | ❌ | ❌ | ❌ | ✅ | ✅ |
| Re-evaluate segment | ❌ | ❌ | ❌ | ✅ | ✅ |
| Delete segment | ❌ | ❌ | ❌ | ✅ | ✅ |
| View own segments (patient) | 🔒 own | ❌ | ✅ org | ✅ org | ✅ any |
Customer support read access: Support staff can view segments and their members to understand patient grouping, but cannot create or modify segment rules. This is a reporting/support tool, not a management action.
Patient self-view: Patients can see which segments they belong to (via GET /v1/patients/{id}/segments) but cannot see other segment members or segment rules.
Enforcement: RequireRole("admin") on CUD + evaluate. RequireRole("customer_support", "admin") on read. Patient segment view: ownership check.
Schema: See ../features/segments/schema.sql
Exercise Library
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| List/search exercises | 📋 published only | ✅ published (global+org) | ✅ all (global+org) | ✅ all (global+org) | ✅ all |
| View exercise detail | 📋 published only | ✅ published (global+org) | ✅ all (global+org) | ✅ all (global+org) | ✅ all |
| Create org exercise | ❌ | ❌ | ❌ | ✅ own org | ✅ any org |
| Create global exercise | ❌ | ❌ | ❌ | ❌ | ✅ |
| Update org exercise | ❌ | ❌ | ❌ | ✅ own org | ✅ any |
| Update global exercise | ❌ | ❌ | ❌ | ❌ | ✅ |
| Delete org exercise | ❌ | ❌ | ❌ | ✅ own org | ✅ any |
| Delete global exercise | ❌ | ❌ | ❌ | ❌ | ✅ |
| Clone exercise | ❌ | ❌ | ❌ | ✅ | ✅ |
| Upload video | ❌ | ❌ | ❌ | ✅ own org | ✅ any |
| Manage taxonomy | ❌ | ❌ | ❌ | ✅ own org | ✅ global+any org |
Global exercises (org_id IS NULL): Only superadmins can create, modify, or delete global exercises. All other roles can only read (published) global exercises.
Org exercises: Admin manages org-specific exercises. RLS enforces org isolation.
Enforcement: RequireRole("admin") on mutations. RLS dual-scope policy: organization_id IS NULL OR organization_id = current_app_org_id() for SELECT.
Schema: See ../features/exercise-library/schema.sql
Treatment Plans
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| List treatment plans | ❌ (via enrollments) | ✅ org | ✅ org (read) | ✅ org | ✅ all |
| Create treatment plan | ❌ | ✅ | ❌ | ✅ | ✅ |
| Update treatment plan | ❌ | ✅ | ❌ | ✅ | ✅ |
| Delete treatment plan | ❌ | ❌ | ❌ | ✅ | ✅ |
| Publish plan version | ❌ | ✅ | ❌ | ✅ | ✅ |
| Assign plan to patient | ❌ | ✅ | ❌ | ✅ | ✅ |
| View patient enrollment | 🔒 own | ✅ org | ✅ org (read) | ✅ org | ✅ all |
| Approve patient plan | ❌ | ✅ | ❌ | ✅ | ✅ |
| Pause/cancel enrollment | ❌ | ✅ | ❌ | ✅ | ✅ |
| Start session (execute) | 🔒 own | ❌ | ❌ | ❌ | ❌ |
| Complete session | 🔒 own | ❌ | ❌ | ❌ | ❌ |
| Log exercise data | 🔒 own | ❌ | ❌ | ❌ | ❌ |
| View session completions | 🔒 own | ✅ org | ✅ org (read) | ✅ org | ✅ all |
| View exercise logs | 🔒 own | ✅ org | ✅ org (read) | ✅ org | ✅ all |
| View progress/analytics | 🔒 own | ✅ org | ✅ org (read) | ✅ org | ✅ all |
| Clone treatment plan | ❌ | ✅ | ❌ | ✅ | ✅ |
| Promote custom → org | ❌ | ❌ | ❌ | ✅ | ✅ |
| Browse plan library | 🔒 own (library_access) | ✅ | ✅ (read) | ✅ | ✅ |
| Self-assign from library | 🔒 own (library_access) | ❌ | ❌ | ❌ | ✅ |
Patient session execution: Only patients can start, complete, and log exercises for their own sessions. This is enforced via RLS (sub-query through patient_treatment_plans → patients → user_id).
Specialist creates + assigns: Specialists create plans and assign them to patients. The approval workflow (if enabled) requires a specialist or admin to approve before the patient can start.
Access grants from service plans: The telerehab_access and library_access flags on service_plans control treatment plan access. A patient must have an active patient_service_plan with telerehab_access = TRUE for a specialist to assign telerehab treatment plans to them, and library_access = TRUE for the patient to browse and self-assign plans from the library. Middleware verifies these grants before allowing the operation.
Enforcement: RequireRole("specialist", "admin") on plan mutations. RequireRole("specialist", "admin") on clone. RequireRole("admin") on promote (custom → org). RequireRole("patient", "specialist", "admin") on session execution (ownership check in handler). Library browse: RLS handles filtering; patient access gated by library_access grant on active service plan. Self-assign from library: RequireRole("patient") + library_access grant check. RLS handles row-level patient ownership.
Schema: See ../features/treatment-plans/schema.sql
Product Orders
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| Create product order (standalone) | ❌ | ✅ | ❌ | ✅ | ✅ |
| Update order status (confirm/ship/deliver) | ❌ | ✅ | ❌ | ✅ | ✅ |
| Cancel product order | ❌ | ✅ | ❌ | ✅ | ✅ |
| View product orders | 🔒 own | ✅ | ✅ (read) | ✅ | ✅ |
Enforcement: RequireRole("specialist", "admin") on create/update/cancel. RLS: patients see only their own orders (filtered by patient_person_id through current_user_patient_person_ids()). Customer support has read-only access.
Service Plan Products
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| Manage plan product bundles | ❌ | ❌ | ❌ | ✅ | ✅ |
Enforcement: RequireRole("admin") on all mutations. RLS: org-scoped.
Export
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| Export CSV | ❌ | ❌ | ✅ org | ✅ org | ✅ any |
Enforcement: RequireRole("customer_support", "admin"). Rate limited. Max 10,000 records per export.
GDPR
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| Export user data (SAR) | ❌ | ❌ | ❌ | ✅ org | ✅ any |
| Delete user data | ❌ | ❌ | ❌ | ✅ org | ✅ any |
| Anonymize user data | ❌ | ❌ | ❌ | ✅ org | ✅ any |
Admin-only. GDPR operations are serious data actions. Customer support cannot trigger them. In practice, a patient would request data export/deletion from support, and support escalates to an admin.
Enforcement: RequireRole("admin"). Audit log captures every GDPR operation with full context.
Audit & Telemetry Logs
| Action | Patient | Specialist | Customer Support | Admin | Superadmin |
|---|---|---|---|---|---|
| View audit logs | ❌ | ❌ | ❌ | ✅ org | ✅ all |
| View telemetry/metrics | ❌ | ❌ | ❌ | ✅ org | ✅ all |
Enforcement: RequireRole("admin"). RLS: organization_id = current_app_org_id() AND current_app_role() = 'admin'. Audit logs are append-only — no UPDATE or DELETE policies exist.
Field-Level Response Filtering
Beyond row-level access, certain fields are excluded or masked based on the caller's role.
Patient-Restricted Fields
When a patient calls any endpoint, these fields are stripped from responses:
| Entity | Hidden Fields | Reason |
|---|---|---|
| Appointment | specialist.signature_url, specialist.email, specialist.phone | Specialist personal contact info |
| Form | Fields where private = true in fields JSONB | Specialist-only observations, internal scores |
| Patient | consumer_id | Internal system identifiers |
| Organization | integrations, api_keys | Sensitive configuration |
Patient-Restricted Write Fields
When a patient sends PUT/POST, these fields are silently removed from the request body:
| Entity | Protected Fields | Reason |
|---|---|---|
| Patient | patient_person_id, consumer_id, organization_id | Cannot change ownership |
| Form | form_template_id, appointment_id, patient_person_id, organization_id, fields | Cannot change form structure |
Implementation
// ResponseFilter middleware strips fields based on caller role.
// Applied AFTER the handler returns data, BEFORE JSON serialization.
func ResponseFilter(role string) func(any) any {
return func(data any) any {
if role == "patient" {
// Strip private form fields from response
// Strip specialist contact info
// Strip internal identifiers
}
return data
}
}This is implemented as a Go middleware that runs after the handler, not in the database layer. RLS handles row access; field filtering handles column access.
Changes from Current Strapi System
| Area | Strapi (Current) | Go (Target) | Reason |
|---|---|---|---|
| Appointment creation | All authenticated users can create | Staff only (specialist, support, admin) | Patients book via Intakes or are booked by staff |
| Appointment modification | All authenticated users (no role check) | Staff only + ownership check | Patients cannot reschedule/cancel directly |
| Patient impersonation | No role check on route | Admin only | Security: impersonation is a privileged action |
| Form deletion | No role check | Admin only + unsigned check | Prevent accidental deletion of signed medical records |
| Specialist update | All authenticated via field-protection stripping | Specialist (own) + Admin | Explicit role check instead of silent field stripping |
| Meta field value deletion | No role check | Admin only | Prevent data loss |
| Report/prescription creation | No explicit restriction | Specialist + Admin only | Medical documents should only be created by medical staff |
| Segment management | No explicit restriction | Admin only | Segments define patient groups — management action |
| GDPR operations | Not implemented | Admin only | Serious data actions requiring authorization |
| Customer support write access | Same as admin in many places | Read-heavy, limited writes | Support assists, doesn't manage |
RequireRole Middleware Usage
Quick reference for which RequireRole() calls are needed on each route group:
// Organizations
r.Post("/v1/organizations", RequireRole("superadmin")) // create org — superadmin only
r.Patch("/v1/organizations/{id}", RequireRole("admin"))
r.Get("/v1/organizations/{id}/api-keys", RequireRole("admin"))
r.Post("/v1/organizations/{id}/connect-user", RequireRole("admin"))
r.Post("/v1/organizations/{id}/domains", RequireRole("admin"))
r.Delete("/v1/organizations/{id}/domains/{domainId}", RequireRole("admin"))
r.Post("/v1/organizations/{id}/domains/{domainId}/verify", RequireRole("admin"))
// Appointments — creation & modification
r.Post("/v1/appointments", RequireRole("specialist", "customer_support", "admin"))
r.Post("/v1/appointments/from-template", RequireRole("specialist", "customer_support", "admin"))
r.Post("/v1/appointments/{id}/attach-forms", RequireRole("specialist", "customer_support", "admin"))
r.Post("/v1/appointments/{id}/reschedule", RequireRole("specialist", "customer_support", "admin"))
r.Post("/v1/appointments/{id}/cancel", RequireRole("specialist", "customer_support", "admin"))
r.Delete("/v1/appointments/{id}", RequireRole("admin"))
// Appointment templates
r.Post("/v1/appointment-templates", RequireRole("admin"))
r.Put("/v1/appointment-templates/{id}", RequireRole("admin"))
r.Delete("/v1/appointment-templates/{id}", RequireRole("admin"))
// Patients
r.Post("/v1/patients/onboard", RequireRole("specialist", "customer_support", "admin"))
r.Post("/v1/patients/{id}/impersonate", RequireRole("admin"))
r.Delete("/v1/patients/{id}", RequireRole("admin"))
// Specialists
r.Post("/v1/specialists", RequireRole("admin"))
r.Put("/v1/specialists/{id}", RequireRole("specialist", "admin")) // + ownership check
r.Delete("/v1/specialists/{id}", RequireRole("admin"))
// Specialties
r.Post("/v1/specialties", RequireRole("admin"))
r.Put("/v1/specialties/{id}", RequireRole("admin"))
r.Delete("/v1/specialties/{id}", RequireRole("admin"))
// Forms
r.Post("/v1/forms", RequireRole("specialist", "customer_support", "admin"))
r.Post("/v1/forms/{id}/sign", RequireRole("patient", "specialist", "admin"))
r.Delete("/v1/forms/{id}", RequireRole("admin"))
// Form templates (part of forms feature)
r.Post("/v1/form-templates", RequireRole("admin"))
r.Put("/v1/form-templates/{id}", RequireRole("admin"))
r.Post("/v1/form-templates/{id}/publish", RequireRole("admin"))
r.Delete("/v1/form-templates/{id}", RequireRole("admin"))
// Reports & prescriptions
r.Post("/v1/reports", RequireRole("specialist", "admin"))
r.Put("/v1/reports/{id}", RequireRole("specialist", "admin"))
r.Delete("/v1/reports/{id}", RequireRole("admin"))
r.Post("/v1/prescriptions", RequireRole("specialist", "admin"))
r.Put("/v1/prescriptions/{id}", RequireRole("specialist", "admin"))
r.Delete("/v1/prescriptions/{id}", RequireRole("admin"))
// PDF Templates
r.Route("/v1/pdf-templates", RequireRole("admin")) // admin-only management
r.Route("/v1/pdf-template-components", RequireRole("admin")) // admin-only management
// Custom fields
r.Post("/v1/custom-fields", RequireRole("admin"))
r.Put("/v1/custom-fields/{id}", RequireRole("admin"))
r.Delete("/v1/custom-fields/{id}", RequireRole("admin"))
// Segments
r.Get("/v1/segments", RequireRole("customer_support", "admin"))
r.Get("/v1/segments/{id}", RequireRole("customer_support", "admin"))
r.Get("/v1/segments/{id}/members", RequireRole("customer_support", "admin"))
r.Post("/v1/segments", RequireRole("admin"))
r.Put("/v1/segments/{id}", RequireRole("admin"))
r.Post("/v1/segments/{id}/evaluate", RequireRole("admin"))
r.Delete("/v1/segments/{id}", RequireRole("admin"))
// Export
r.Post("/v1/export/csv", RequireRole("customer_support", "admin"))
// GDPR
r.Route("/v1/gdpr", RequireRole("admin")) // all methods
// Exercise Library
r.Post("/v1/exercises", RequireRole("admin"))
r.Put("/v1/exercises/{id}", RequireRole("admin"))
r.Delete("/v1/exercises/{id}", RequireRole("admin"))
r.Put("/v1/exercises/{id}/status", RequireRole("admin"))
r.Post("/v1/exercises/{id}/clone", RequireRole("admin"))
r.Post("/v1/exercises/{id}/video", RequireRole("admin"))
r.Post("/v1/exercise-categories", RequireRole("admin"))
r.Put("/v1/exercise-categories/{id}", RequireRole("admin"))
r.Delete("/v1/exercise-categories/{id}", RequireRole("admin"))
// Same pattern for exercise-body-regions and exercise-equipment
// Treatment Plans
r.Post("/v1/treatment-plans", RequireRole("specialist", "admin"))
r.Put("/v1/treatment-plans/{id}", RequireRole("specialist", "admin"))
r.Delete("/v1/treatment-plans/{id}", RequireRole("admin"))
r.Post("/v1/treatment-plans/{id}/publish", RequireRole("specialist", "admin"))
r.Post("/v1/patient-treatment-plans", RequireRole("specialist", "admin"))
r.Post("/v1/patient-treatment-plans/{id}/approve", RequireRole("specialist", "admin"))
r.Put("/v1/patient-treatment-plans/{id}/status", RequireRole("specialist", "admin"))
r.Post("/v1/treatment-plans/{id}/clone", RequireRole("specialist", "admin"))
r.Post("/v1/treatment-plans/{id}/promote", RequireRole("admin"))
// Library browse: no RequireRole (RLS handles filtering), but middleware checks library_access grant
r.Post("/v1/treatment-plan-library/self-assign", RequireRole("patient")) // + library_access grant check
// Session execution: RequireRole("patient", "specialist", "admin") + ownership check
r.Post("/v1/patient-treatment-plans/{id}/sessions/{num}/start", RequireRole("patient", "specialist", "admin"))
r.Post("/v1/patient-session-completions/{id}/complete", RequireRole("patient", "specialist", "admin"))
// Product Orders
r.Post("/v1/product-orders", RequireRole("specialist", "admin"))
r.Put("/v1/product-orders/{id}/status", RequireRole("specialist", "admin"))
r.Post("/v1/product-orders/{id}/cancel", RequireRole("specialist", "admin"))
// Service Plan Products
r.Route("/v1/service-plan-products", RequireRole("admin")) // all methods
// No RequireRole needed (RLS handles filtering):
// GET /v1/appointments, GET /v1/patients, GET /v1/specialists,
// GET /v1/specialties, GET /v1/forms, GET /v1/form-templates,
// GET /v1/custom-fields, GET /v1/calendar,
// GET /v1/reports, GET /v1/prescriptions,
// GET /v1/appointment-templates, PUT /v1/forms/{id} (save values),
// PUT /v1/patients/{id} (self-edit allowed)Ownership Checks Beyond RLS
Some endpoints need application-level ownership validation in addition to RLS:
| Endpoint | Check | Implementation |
|---|---|---|
PUT /v1/specialists/{id} | Specialist can only edit own profile | Handler: if role == "specialist" && specialist.UserID != currentUserID { return 403 } |
PUT /v1/patients/{id} | Patient can only edit own record | RLS handles via patient_person_id IN (current_user_patient_person_ids()) — no extra check needed |
PUT /v1/reports/{id} | Specialist can only edit own reports | Handler: if role == "specialist" && report.AppointmentSpecialistUserID != currentUserID { return 403 } |
PUT /v1/prescriptions/{id} | Same as reports | Same ownership check |
POST /v1/forms/{id}/sign | Patient can only sign own forms | RLS handles via patient_person_id IN (current_user_patient_person_ids()) |
PUT /v1/forms/{id} | Patient can only save own forms | RLS handles via patient_person_id IN (current_user_patient_person_ids()) |
Rate-Limited Endpoints
| Endpoint | Limit | Key | Reason |
|---|---|---|---|
POST /webhooks/clerk | 20/min | IP | Webhook abuse prevention |
GET /v1/organizations/{id}/api-keys | 5/5min | user_id + path | Sensitive data access |
POST /v1/organizations/{id}/connect-user | 5/5min | user_id + path | Prevents mass user connection |
POST /v1/patients/{id}/impersonate | 3/5min | user_id + path | Privileged action |
POST /v1/export/csv | 5/5min | user_id + path | Resource-intensive operation |
POST /v1/gdpr/* | 3/5min | user_id + path | Serious data operations |
GET /v1/reports/{id}/pdf | 10/min | user_id + path | PDF generation is resource-intensive |
GET /v1/prescriptions/{id}/pdf | 10/min | user_id + path | PDF generation is resource-intensive |