Plan: Organisation-scoped access control + external access¶
TL;DR¶
Enforce organisation boundaries so staff see only their org's patients and messages. Messages auto-include all shared orgs between creator and patient, and snowball when cross-org participants join. Add external_hcp and patient_advocate user types who gain per-patient access via invite-only registration (JWT link from patient or admin). External users see the full patient record and all messages for their granted patients. All endpoints enforce RBAC + CBAC.
Design decisions¶
- Multi-org: Staff and patients can belong to multiple orgs
- Strict internal boundary: Staff see only patients in their org(s)
- Message org set: Many-to-many with orgs. Auto-includes ALL shared orgs of creator + patient at creation. Snowballs when cross-org participants join. All staff in any linked org can view/contribute
- No org prompt: All shared orgs are auto-added — no user selection needed
- Admin/superadmin: System administration only, no extra clinical access. Superadmins see all patients only on admin pages
- External HCP:
system_permissions = "external_hcp". Per-patient read-only clinical + messaging. No org membership. Writing clinical notes requires subscription (future) - Patient advocate:
system_permissions = "patient_advocate". Family, friends, solicitors, carers. Per-patient access, simple on/off for now (granular levels later) - Invite-only: No public registration page. External users join ONLY via invite from patient or admin. JWT in invite URL encodes patient_id, user_type, expiry
- Invite flow: Click JWT link → if new user: registration form (single page, type from JWT) → auto-grant access. If existing user: login → auto-grant access
- Access grants: Patient or admin sends invite. Only admin can revoke
- External user visibility: External users see the FULL patient record and ALL messages for granted patients (not just ones they participate in)
- ExternalPatientAccess is many-to-many: One external user can have access to multiple patients (separate row per patient). One patient can have multiple external users
- No org badge on messages: Participant list is what matters
- British spelling: "organisation" in all new code/comments/UI. Existing model names unchanged
- RBAC + CBAC on all endpoints: Every endpoint checks system_permissions and competencies
- Existing messages: Dev data, can be deleted
Current state¶
- Organisation models exist (
Organization, association tables) but access not enforced - No User-to-FHIR link (User has no fhir_patient_id)
- Messaging has no org awareness
- system_permissions: patient | staff | admin | superadmin (no external_hcp or patient_advocate)
- No CBAC checks on messaging or patient endpoints
- Inconsistent spelling: class
Organization, tablesorganizations, association tablesorganisation_*
Phase 1: Data model foundations¶
1.1 Add fhir_patient_id to User model¶
- Add
fhir_patient_id: Mapped[str | None](nullable, unique) to User - Links a patient user to their FHIR patient record (separate entities, linked on opt-in)
- File:
backend/app/models.py - Alembic migration
1.2 Add external_hcp and patient_advocate to system_permissions¶
- Update
SystemPermissionLiteral to includeexternal_hcpandpatient_advocate - Hierarchy: both sit alongside
patient(lowest clinical access) external_hcp: clinician outside Quill orgs, per-patient read-only clinical + messagingpatient_advocate: family/friends/solicitors/carers, per-patient access (simple on/off for now)- Update
String(20)column length if needed - Files:
backend/app/system_permissions/permissions.py,backend/app/models.py
1.3 Create ExternalPatientAccess model (many-to-many: external users ↔ patients)¶
- New model
ExternalPatientAccess: id(PK)user_id(FK to users.id) — the external HCP or patient advocatepatient_id(String, FHIR patient ID) — the patient they have access togranted_by_user_id(FK to users.id) — the patient user or admin who granted accessgranted_at(datetime)revoked_at(datetime, nullable) — set by admin to revokeaccess_level(String, default "full") — for future granularity (messaging_only, read_clinical, full)- Unique constraint on (user_id, patient_id) — one access record per user-patient pair
- One external user can have multiple rows (access to multiple patients)
- One patient can have multiple rows (multiple external users)
- File:
backend/app/models.py - Alembic migration
1.4 Create message_organisation many-to-many¶
- New association table
message_organisation: conversation_id(FK to conversations.id)organisation_id(FK to organizations.id)- Composite PK
- Add relationship on Conversation model
- File:
backend/app/models.py - Alembic migration (combined with above)
1.5 Create organisation helpers module¶
- New file:
backend/app/organisations.py - Functions:
get_user_org_ids(db, user_id) -> list[int]get_patient_org_ids(db, patient_id) -> list[int]get_shared_org_ids(db, user_id, patient_id) -> list[int]check_user_patient_access(db, user, patient_id) -> bool— checks org membership OR ExternalPatientAccessget_org_patient_ids(db, org_ids) -> set[str]get_org_staff_ids(db, org_ids) -> set[int]get_accessible_patient_ids(db, user) -> set[str]— returns all patient IDs a user can access (via orgs or external grants)- Tests for all helpers
Phase 2: Organisation-scoped patient and staff lists¶
2.1 Org-filtered patient list¶
- Update
GET /patients: filter to user's org(s) for staff/admin on clinical pages - Admin pages: full list via
?scope=adminparam (requires admin/superadmin) - External HCP/advocate: return only patients they have ExternalPatientAccess for
- CBAC: require appropriate competency to view patient data
- File:
backend/app/main.py
2.2 Org-filtered staff list for participant selection¶
- Add query param to
GET /users:?patient_id={id}returns only staff who share an org with that patient - Also include external users who have ExternalPatientAccess for that patient
- Used by NewMessageModal when selecting participants
- File:
backend/app/main.py
2.3 Frontend: NewMessageModal updates¶
- After patient selection, re-fetch staff filtered by patient's org(s) + external users with access
- File:
frontend/src/components/messaging/NewMessageModal.tsx
2.4 Tests¶
- Staff sees only their org's patients
- External HCP/advocate sees only granted patients
- Admin override on admin pages
- Filtered staff list by patient org
- CBAC enforcement
Phase 3: Organisation-scoped messages¶
3.1 Message creation with auto org context¶
- Update
create_conversation(): - Compute shared orgs between creator and patient
- Auto-add ALL shared orgs to message_organisation table
- For external users: no org needed (they have per-patient access)
- Validate creator has access to patient (via org or external grant)
- Files:
backend/app/schemas/messaging.py,backend/app/messaging.py
3.2 Cross-org participant snowball¶
- When a participant from a different org is added (
add_participant): - Get participant's org(s)
- Add those org(s) to message_organisation
- All staff in those orgs can now view the message thread
- When a staff member self-joins (
join_conversation): - Verify they're in one of the message's orgs
- File:
backend/app/messaging.py
3.3 Message listing and access filters¶
list_conversations(): filter where user is participant OR user's org(s) overlap with message's orgslist_patient_conversations(): same org filterget_conversation_detail(): verify user has access via participation or org membership- External users: see ALL messages for their granted patients (not just ones they participate in)
- File:
backend/app/messaging.py
3.4 RBAC + CBAC on all messaging endpoints¶
- All messaging endpoints check system_permissions (reject patient_advocate if no messaging competency, etc.)
- Add CBAC competency checks where appropriate (e.g. viewing clinical messages)
- File:
backend/app/main.py
3.5 Tests¶
- Auto org set on message creation
- Cross-org snowball on participant add
- List filtering by org
- External user sees all messages for granted patient
- RBAC/CBAC enforcement on all messaging endpoints
Phase 4: Frontend org context in messaging¶
4.1 Shared orgs endpoint (for display, not selection)¶
- New endpoint:
GET /patients/{patient_id}/shared-organisations - Returns orgs shared between current user and patient
- For external users: returns empty (they use per-patient access)
- File:
backend/app/main.py
4.2 NewMessageModal updates¶
- Remove org selector (no longer needed — all shared orgs auto-added)
- If user has 0 shared orgs AND is not external: show error "You do not share an organisation with this patient"
- External users: skip org check entirely (access via ExternalPatientAccess)
- Files:
frontend/src/components/messaging/NewMessageModal.tsx,frontend/src/lib/messaging.ts
4.3 Tests¶
- Auto-org story + test
- External user no-org story + test
- Zero-shared-org error story + test
Phase 5: Invite-only external registration and access¶
5.1 Backend: invite generation¶
POST /patients/{patient_id}/invite-external- Called by patient user or admin
- Requires CSRF
- Accepts:
email(recipient),user_type("external_hcp" | "patient_advocate") - Generates JWT containing: patient_id, user_type, email, expiry
- Sends email with invite link containing JWT
- Also returns: invite URL (for QR code generation on frontend)
- Does NOT create ExternalPatientAccess yet (created on accept)
- File:
backend/app/main.py
5.2 Backend: invite acceptance / registration¶
POST /accept-invite— single endpoint, single page- Accepts JWT from URL
- Decodes JWT to get patient_id, user_type, email
- If user already exists (by email): grant access immediately (create ExternalPatientAccess), return login redirect
- If new user: accept registration fields (name, password) + set up 2FA
- Create user with
system_permissions= user_type from JWT - Create ExternalPatientAccess record
- Return login redirect
- Create user with
- File:
backend/app/main.py
5.3 Backend: admin revocation¶
DELETE /patients/{patient_id}/external-access/{user_id}- Admin-only: sets
revoked_aton ExternalPatientAccess - CSRF required
- File:
backend/app/main.py
5.4 Frontend: patient invite UI¶
- On patient's profile/settings or dedicated page
- Single form with:
- Email address input
- Dropdown: "External healthcare professional" or "Family member / advocate"
- Submit sends invite
- Show QR code / copy link after invite generated
- Show list of external users with access to this patient (with type label)
- Files: new component in
frontend/src/components/
5.5 Frontend: invite acceptance page¶
- Single page at
/accept-invite?token={jwt} - Decodes JWT to show: patient name (from FHIR), access type
- If new user: registration form (name, email pre-filled from JWT, password, 2FA setup)
- If existing user: "Click to accept access" button (then redirect to login)
- Files:
frontend/public_pages/or new route
5.6 Tests¶
- Invite generation (email + QR/link)
- New user registration via invite
- Existing user auto-grant via invite
- Expired JWT rejected
- Admin revocation
- External user can view full patient record and all messages
- External user cannot view other patients
- RBAC enforcement
Phase 6: Organisation management cleanup¶
6.1 Remove staff/patients from orgs (currently missing)¶
DELETE /organizations/{org_id}/staff/{user_id}DELETE /organizations/{org_id}/patients/{patient_id}- Admin/superadmin only, CSRF required
- File:
backend/app/main.py
6.2 Frontend admin pages¶
- Add remove buttons to OrganisationAdminPage staff/patient lists
- Files:
frontend/src/pages/admin/organisations/OrganisationAdminPage.tsx
6.3 Admin endpoint: link patient user to FHIR record¶
PATCH /users/{user_id}/link-patient(admin only)- Validates FHIR patient exists and not already linked
- File:
backend/app/main.py
6.4 Tests¶
Relevant files¶
backend/app/models.py— User, Conversation, Organization, new ExternalPatientAccess, new message_organisationbackend/app/main.py— all endpoint changesbackend/app/messaging.py— org-scoped messaging functionsbackend/app/schemas/messaging.py— schema updatesbackend/app/system_permissions/permissions.py— add external_hcp, patient_advocatebackend/app/organisations.py— NEW: org access helpersbackend/app/security.py— JWT generation for invite tokensfrontend/src/lib/messaging.ts— API typesfrontend/src/components/messaging/NewMessageModal.tsx— filtered staff, no org selectorfrontend/src/pages/Messages.tsx,PatientMessages.tsx— list pagesbackend/tests/test_messaging.py— tests
Verification¶
- Two orgs with different staff/patients: staff in A sees only A's patients
- Message creation auto-adds all shared orgs
- Adding participant from org B snowballs org B access to message thread
- Superadmin on admin pages sees all patients; clinical pages only their org's
- Patient sends invite to external HCP: HCP registers via JWT link, gains access
- Patient sends invite to advocate: advocate registers via JWT link, gains access
- External user sees full patient record and all messages for granted patient
- External user cannot see other patients
- Admin can revoke external access
- All endpoints enforce RBAC + CBAC
just ubandjust ufpass
Noted for future (not this work)¶
- External referrals to non-Quill specialities (e.g. tertiary neurosurgery referral)
- Subscription paywall for external HCP clinical note writing
- Push notification org scoping
- Audit logging for org membership changes
- Granular access levels for patient advocates (messaging_only, read_clinical, full)