Skip to main content

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)?
SituationApproach
Clinics, practices, locations (MSO)Organization
Service lines, departmentsHealthcareService
Dedicated team per patientCareTeam 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.access entry 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)?
SituationApproach
One tenant per patientSingle compartment on the Patient
Patient shared across tenantsMultiple compartments, one per tenant
Practitioners shared (global directory)Leave unassigned; policy reads without _compartment filter
Practitioners isolated per tenantTag 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 scopeModeling approach
One tenantSingle ProjectMembership.access entry parameterized with that tenant
Multiple tenants, combined viewOne access entry per tenant, same policy
Org-wide / cross-tenant oversightUnparameterized 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.)
SituationApproach
Full CRUD on a resourceStandard resource entry, no interaction restriction
Read-onlyreadonly: true
Partial interactionsExplicit 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):

SituationApproach
Readable but not writablereadonlyFields
Not visiblehiddenFields (masks output only — for decluttering, do it in the UI)

Write constraints — enforce state transitions or required fields at the policy layer:

SituationApproach
Prevent field change in terminal statewriteConstraint on %before
Require field at transitionwriteConstraint on %after + field presence
Update-onlyPrefix with %before.exists() implies ...
Create-onlyPrefix with %before.exists().not() implies ...

Keep FHIRPath simple; complex logic belongs in a Bot.

Criteria-based filtering — filter by resource attribute (geography, code, status):

SituationApproach
State licensePatient?address-state=%licensed_state, parameterized
Multi-stateOne access entry per state
Specialty or status filtercriteria with code/status filter
Criteria + tenantSingle 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?
SituationApproach
Standard adminProjectMembership.admin
Admin with narrowed clinical accessAdmin flag + dedicated AccessPolicy
Admin blocked from clinical contentAdmin flag + hiddenFields on clinical resources
Project creation, overwriting protected fieldsSuper 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?
SituationApproach
Invite-onlyStandard ProjectMembership with patient policy
Open self-registrationSet Project.defaultPatientAccessPolicy; enable open registration
Patient sees own dataParameterized policy with %patient on clinical resources
Single caregiver, single patientOne access entry with %patient
One caregiver, multiple patientsMultiple access entries, different %patient
Multiple caregivers per patientEach caregiver has their own ProjectMembership
Revoke caregiverRemove 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?
SituationApproach
Your own frontendNot SMART — standard ClientApplication + AccessPolicy
Third-party, patient-contextSMART 2.0.0, launch/patient
Third-party, provider-contextSMART 2.0.0, launch
Standalone patient apppatient/*.rs or narrower
Offline accessAdd 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?
SituationApproach
VPN-onlyAllow VPN ranges + wildcard block
On-prem onlyAllow clinic network + wildcard block
MixedDifferent 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?
SituationApproach
Patient enrollmentBot calls $set-accounts on the triggering event; use propagate: true to tag related resources
Transfer, records followUpdate compartment with propagate: true; old tenant loses access
Transfer, records stay / sharedAdd 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)?
SituationApproach
Shared standard terminologyLeave 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 BotsLeave unassigned; Bots run as ClientApplication
Reference data shared across multiple projectsSuper 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

SituationApproach
UI changes based on roleAccessPolicy.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.