Access Control Decision Guide
Companion to the Authorization and Access Control docs.
Section 1: Use Case & Participants
1.1 Who are the users?
- Patients only
- Providers / staff / partners (clients or outside vendors) only
- Both
1.2 What is your organizational structure?
- Single organization
- Multiple locations of one organization
- Multiple independent organizations (MSO-style)
- Departments or service lines within one organization
- Combination
Why: biggest driver of downstream design. If single org with no internal isolation, skip Section 2.
Section 2: Multi-tenancy
Skip if single-organization with no internal isolation.
Isolation uses compartments (meta.compartment) matched by parameterized access policies.
Design principle (from Medplum docs): Push complexity into enrollment Bots, not policies. Each patient is tagged with their tenant(s); each practitioner's ProjectMembership.access lists every tenant they work in. See the MSO demo enrollment.ts for reference.
2a Tenant Modeling
Questions:
- Internal term for tenants? (clinics, programs, sites, accounts)
- Grouping is organizational, functional, or per-patient?
- Is there an organizational hierarchy (e.g., region → clinic)?
| Situation | Approach |
|---|---|
| Clinics, practices, locations (MSO) | Organization |
| Service lines, departments | HealthcareService |
| Dedicated team per patient | CareTeam per patient |
Hierarchy pattern (if tenants nest, e.g., region → clinic):
- Patients are tagged only at the lowest level they belong to.
- Practitioners enrolled at an intermediary level get one
ProjectMembership.accessentry per lowest-level tenant under it (iteratively expanded). - Adding a new lowest-level tenant requires a workflow to update every practitioner membership at or above that intermediary.
2b Patient and Practitioner Sharing
Questions:
- Can a patient be enrolled in more than one tenant at a time?
- Are practitioners isolated to their tenant, or visible across all tenants (global directory)?
| Situation | Approach |
|---|---|
| One tenant per patient | Single compartment on the Patient |
| Patient shared across tenants | Multiple compartments, one per tenant |
| Practitioners shared (global directory) | Leave unassigned; policy reads without _compartment filter |
| Practitioners isolated per tenant | Tag with tenant compartment; policy filters by _compartment |
Section 3: Roles & Permissions
For each role: name it, scope it (which tenants, if multi-tenant), say what resources it touches, and narrow as needed.
3a Role Inventory & Scope
Questions:
- What are the role names? (physician, RN, MA, front desk, billing, care coordinator, medical director)
- If multi-tenant: for each role, which tenants does it cover? (one, several, all)
- Any oversight roles that need visibility across all tenants without per-tenant enrollment?
| Role scope | Modeling approach |
|---|---|
| One tenant | Single ProjectMembership.access entry parameterized with that tenant |
| Multiple tenants, combined view | One access entry per tenant, same policy |
| Org-wide / cross-tenant oversight | Unparameterized policy, no _compartment filter |
| Tenants contractually can't share data (e.g., locum across competitor clinics) | Separate logins per tenant — see callout below |
⚠ One-way door: stacking is additive only. Policies compose by union — you can't narrow by composition. For genuinely narrower API access in one context (not just a filtered UI view), use separate logins, not stacked policies.
When separate logins are warranted: contractual or regulatory obligations requiring the API to never return both scopes' data in the same session. Example: a provider works at two independent clinics under separate BAAs — stacking would let one token read both, violating the BAA. Two memberships (each parameterized to one clinic) bound each session to one tenant.
Test: if this token leaked, should the attacker reach both scopes? No → separate logins. Yes or don't care → stacked; any "switcher" is purely a UI concern.
3b Permission Mechanics
Questions:
- For each role: which resource types does it read / write / search / delete?
- Any partial FHIR interactions? (create-only, read-without-search, etc.)
| Situation | Approach |
|---|---|
| Full CRUD on a resource | Standard resource entry, no interaction restriction |
| Read-only | readonly: true |
| Partial interactions | Explicit interaction array |
3c Narrowing What a Role Can Do
Cover only the knobs that apply.
Field-level — partial field access (e.g., billing sees Patient minus diagnoses):
| Situation | Approach |
|---|---|
| Readable but not writable | readonlyFields |
| Not visible | hiddenFields (masks output only — for decluttering, do it in the UI) |
Write constraints — enforce state transitions or required fields at the policy layer:
| Situation | Approach |
|---|---|
| Prevent field change in terminal state | writeConstraint on %before |
| Require field at transition | writeConstraint on %after + field presence |
| Update-only | Prefix with %before.exists() implies ... |
| Create-only | Prefix with %before.exists().not() implies ... |
Keep FHIRPath simple; complex logic belongs in a Bot.
Criteria-based filtering — filter by resource attribute (geography, code, status):
| Situation | Approach |
|---|---|
| State license | Patient?address-state=%licensed_state, parameterized |
| Multi-state | One access entry per state |
| Specialty or status filter | criteria with code/status filter |
| Criteria + tenant | Single criteria string combining both |
criteria supports only :not / :missing — no chained searches. Denormalize or tag via an enrollment Bot.
Section 4: Admin Structure
Questions:
- Who invites/removes users and manages memberships?
- Who manages Bots, ClientApplications, AccessPolicies?
- Any super admin needs? (project creation via API, overwriting protected fields)
- Should admins be blocked from clinical data?
| Situation | Approach |
|---|---|
| Standard admin | ProjectMembership.admin |
| Admin with narrowed clinical access | Admin flag + dedicated AccessPolicy |
| Admin blocked from clinical content | Admin flag + hiddenFields on clinical resources |
| Project creation, overwriting protected fields | Super admin — server operators only |
| Separate "user manager" vs. "tech admin" | Two AccessPolicies stacked on admin flag (see 3b) |
⚠ Note: Admin flag covers admin resources only (Project, ProjectMembership, User) — admins still need a policy for clinical data.
Appendix: Additional Decisions
Narrower decisions that apply only in specific situations. Cover each only if relevant.
A. Open Registration & Caregivers
If the product has a patient portal.
Questions:
- Self-registration or staff-invite?
- Are caregivers / proxies needed? (parents, adult children, guardians)
- One caregiver with multiple patients? One patient with multiple caregivers?
- How is the caregiver relationship established and revoked?
| Situation | Approach |
|---|---|
| Invite-only | Standard ProjectMembership with patient policy |
| Open self-registration | Set Project.defaultPatientAccessPolicy; enable open registration |
| Patient sees own data | Parameterized policy with %patient on clinical resources |
| Single caregiver, single patient | One access entry with %patient |
| One caregiver, multiple patients | Multiple access entries, different %patient |
| Multiple caregivers per patient | Each caregiver has their own ProjectMembership |
| Revoke caregiver | Remove the corresponding access entry |
| Time-bound caregiver access (e.g., expires at age 18) | Application-layer enforcement — not native |
B. SMART on FHIR
If third-party apps (not your own frontend) request access.
Questions:
- Patient-context launch, provider-context launch, or standalone?
- Which scopes?
- Offline access needed?
| Situation | Approach |
|---|---|
| Your own frontend | Not SMART — standard ClientApplication + AccessPolicy |
| Third-party, patient-context | SMART 2.0.0, launch/patient |
| Third-party, provider-context | SMART 2.0.0, launch |
| Standalone patient app | patient/*.rs or narrower |
| Offline access | Add offline_access |
⚠ Note: Effective access = SMART scopes ∩ AccessPolicy.
C. IP Access Rules
If access should be restricted by IP range.
Questions:
- Restrict by IP range? (VPN, on-prem devices)
- Any roles that bypass?
| Situation | Approach |
|---|---|
| VPN-only | Allow VPN ranges + wildcard block |
| On-prem only | Allow clinic network + wildcard block |
| Mixed | Different AccessPolicies per role |
⚠ Note: IPv4 only. Rules evaluate sequentially — always terminate with a wildcard block.
D. Enrollment & Reassignment Workflows
If Section 2 multi-tenancy applies.
Questions:
- When does a patient get enrolled in a tenant? (registration, first appointment, referral, insurance, geography)
- Who is authorized to move a patient between tenants?
- On transfer: do records follow the patient, stay with the original tenant, or become shared?
| Situation | Approach |
|---|---|
| Patient enrollment | Bot calls $set-accounts on the triggering event; use propagate: true to tag related resources |
| Transfer, records follow | Update compartment with propagate: true; old tenant loses access |
| Transfer, records stay / shared | Add new compartment (don't replace); both tenants retain access |
Reference implementation: MSO demo enrollment.ts — canonical example patterns.
E. Shared vs. Tenant-Specific Resources
If Section 2 multi-tenancy applies.
Questions:
- Terminology (CodeSystems, ValueSets) shared or tenant-specific?
- Questionnaires shared or per-tenant?
- PlanDefinitions / ActivityDefinitions global or per-tenant?
- Any resources shared across projects (not just tenants)?
| Situation | Approach |
|---|---|
| Shared standard terminology | Leave unassigned; unfiltered read in policy |
| Tenant-specific Questionnaires | $set-accounts tags with tenant; filter by _compartment |
| Mixed (some global, some per-tenant) | Two resource entries in policy: one filtered, one unfiltered |
| Global Bots | Leave unassigned; Bots run as ClientApplication |
| Reference data shared across multiple projects | Super admin creates a linked project (read-only view) |
⚠ Note: Forgetting unfiltered read on standard terminology causes empty dropdowns — a common multi-tenant misconfiguration.
F. Role-Aware UI
If your frontend needs to show / hide UI based on the user’s role
| Situation | Approach |
|---|---|
| UI changes based on role | AccessPolicy.basedOn + /auth/me |
Using AccessPolicy.basedOn keeps the UI in sync with what the API enforces. The alternative (hardcoded role field, custom endpoint) tends to drift.