ProjectMembership Access¶
Three client methods for managing the parameterized AccessPolicy slice
of ProjectMembership.access — bulk replace, atomic add, atomic
remove — backed by If-Match optimistic concurrency and a 412 retry
loop. The end of this page has an
About ProjectMembership appendix for
readers new to the resource model.
If you’re skimming: jump to What these methods do.
What these methods do¶
Three methods, all on MedplumClient and AsyncMedplumClient with
identical signatures:
merge_project_membership_access— replace the whole managed slice with a desired list. Use when you have the full intended state.add_project_membership_access_entry— atomically append one entry.remove_project_membership_access_entry— atomically remove one structurally-equal entry.
All three share the same contract:
- One atomic write. Read the membership, compute the new
accesslist, write it back withIf-Matchfrommeta.versionId. No partial states; no risk of clobbering a concurrent writer’s changes silently. - 412 retry. If a concurrent writer bumped the version between
our read and our write, Medplum returns 412
(
PreconditionFailedError). The helper re-reads, re-applies the mutation against the new state, and retries up tomax_retriestimes (default1, so two attempts total) before letting the exception propagate. - Skip the PUT when nothing changed. If the desired
accesslist is byte-equal to what’s already on the server, no write is sent andresult.updatedisFalse. Calling these helpers from a retry loop or a recovery script is therefore cheap.
Calling merge¶
The realistic shape is “user belongs to N tenants” — pass one entry
per tenant. All entries reuse the same parameterized AccessPolicy;
only the parameter differs. Here Alice belongs to three practices:
from pymedplum import (
MedplumClient,
make_project_membership_access,
)
client = MedplumClient(...)
result = client.merge_project_membership_access(
"alice-membership-id",
managed_access=[
make_project_membership_access(
"AccessPolicy/practice-policy",
{"organization": "Organization/practice-a"},
),
make_project_membership_access(
"AccessPolicy/practice-policy",
{"organization": "Organization/practice-b"},
),
make_project_membership_access(
"AccessPolicy/practice-policy",
{"organization": "Organization/practice-c"},
),
],
managed_policy_ids={"practice-policy"},
)
print(result.updated, result.version_id, result.managed_count) # ... 3
To reassign Alice (drop practice-c, add practice-d), call merge again with the new desired list. The helper diffs against the remote list internally; the caller does not have to compute the diff. This is one PUT regardless of how many entries change. An MSO staff member with 1000 active practice assignments writes 1000 entries on the days the assignment changes and sends zero PUTs on the days it doesn’t (byte-equal short-circuit).
The async client mirrors the sync API exactly:
from pymedplum import AsyncMedplumClient, make_project_membership_access
async with AsyncMedplumClient(...) as client:
result = await client.merge_project_membership_access(
"alice-membership-id",
managed_access=[
make_project_membership_access(
"AccessPolicy/practice-policy",
{"organization": "Organization/practice-a"},
),
make_project_membership_access(
"AccessPolicy/practice-policy",
{"organization": "Organization/practice-b"},
),
],
managed_policy_ids={"practice-policy"},
)
Adding or removing a single tenant¶
When you don’t have the full desired list cached — a UI button click, say — use the atomic single-entry methods. They do the read, take the managed slice, mutate it, and write back inside the same 412 retry loop, so concurrent writes by other callers are preserved.
Add a tenant:
client.add_project_membership_access_entry(
membership_id,
make_project_membership_access(
"AccessPolicy/practice-policy",
{"organization": "Organization/practice-d"},
),
managed_policy_ids={"practice-policy"},
)
Idempotent: if a structurally-equal entry already exists in the managed slice, no PUT is sent.
Remove a tenant: build the entry the same way the original was built (the builder is canonical) and pass it in:
client.remove_project_membership_access_entry(
membership_id,
make_project_membership_access(
"AccessPolicy/practice-policy",
{"organization": "Organization/practice-c"},
),
managed_policy_ids={"practice-policy"},
)
Idempotent: if no matching entry exists, no PUT is sent.
Both methods send 1 GET + 1 PUT (or 1 GET + 0 PUTs if the operation is a no-op). They are safe under concurrent writers: if another caller adds or removes a different entry between the GET and the PUT, Medplum returns 412, the helper re-reads, re-applies the mutation against the new state, and retries. The other writer’s change is preserved.
Building an access entry¶
from pymedplum import make_project_membership_access
entry = make_project_membership_access(
"AccessPolicy/abc",
{"organization": "Organization/org-a"},
)
# {
# "policy": {"reference": "AccessPolicy/abc"},
# "parameter": [{
# "name": "organization",
# "valueReference": {"reference": "Organization/org-a"},
# }]
# }
Policy and parameter values accept several shapes:
| Input | Result |
|---|---|
"abc" (bare ID) |
{"reference": "AccessPolicy/abc"} |
"AccessPolicy/abc" |
{"reference": "AccessPolicy/abc"} |
Reference(reference="AccessPolicy/abc") |
{"reference": "AccessPolicy/abc"} |
{"reference": "AccessPolicy/abc"} |
{"reference": "AccessPolicy/abc"} |
Parameter values:
"Organization/org-a"(or anyResourceType/id) emitsvalueReference."active"(no slash) emitsvalueString.Reference(...)and{"reference": ...}emitvalueReference.
Other parameter shapes (CareTeam, HealthcareService, etc.) work identically:
make_project_membership_access(
"AccessPolicy/care-team-policy",
{"careTeam": "CareTeam/team-1"},
)
make_project_membership_access(
"AccessPolicy/practice-policy",
{"healthcareService": "HealthcareService/svc-9"},
)
If you already have a generated ProjectMembershipAccess model, pass
it through merge_project_membership_access directly — the helper
normalizes both shapes through to_fhir_json:
from pymedplum.fhir import (
ProjectMembershipAccess,
ProjectMembershipAccessParameter,
Reference,
)
entry = ProjectMembershipAccess(
policy=Reference(reference="AccessPolicy/abc"),
parameter=[
ProjectMembershipAccessParameter(
name="organization",
value_reference=Reference(reference="Organization/org-a"),
),
],
)
Inspecting existing entries¶
from pymedplum import (
get_project_membership_access_parameter,
get_project_membership_access_policy_id,
)
membership = client.read_resource("ProjectMembership", "abc")
for entry in membership.get("access", []):
policy_id = get_project_membership_access_policy_id(entry)
org = get_project_membership_access_parameter(entry, "organization")
get_project_membership_access_policy_id returns None for malformed
entries (missing policy, non-AccessPolicy reference, etc.) instead
of raising.
managed_policy_ids — what it is and why it’s there¶
managed_policy_ids is a set of AccessPolicy IDs that your
application owns. Most apps in practice are the only writer of
ProjectMembership.access for the users they manage, so this is a
singleton like {practice_policy_id} and never grows.
When you call merge:
- Existing entries pointing at a policy in this set get replaced
by
managed_access. - Entries pointing at any other policy are preserved untouched.
If your app is the only writer (the common case), the “other policy”
branch is dead code — there are no other entries, so the partition
has nothing to preserve. You can verify this with git grep or a
one-time read of representative memberships before going live.
The branch exists for two reasons that are cheap to defend against and expensive to debug if they happen:
- Manual admin edits. If somebody opens Medplum’s admin UI and adds an entry by hand to debug a permissions issue, a later sync from your application won’t silently delete it.
- Policy rotation. If you ever recreate the AccessPolicy
resource (different ID), pass
{old_id, new_id}during the rollout window. Entries pointing at the old ID get cleaned up the first time the new policy is the only one inmanaged_access.
If neither concern applies, you can effectively ignore
managed_policy_ids after wiring it once — pass the same singleton
on every call.
Empty sets are rejected, because “manage no policies” plus “write this list of entries” can’t be reconciled — the helper would have no way to clean up entries it later wrote.
Removing all managed access (lockout)¶
Pass managed_access=[] to revoke every entry your app previously
wrote, while leaving anything else alone:
client.merge_project_membership_access(
"abc",
managed_access=[],
managed_policy_ids={"practice-policy"},
)
The helper rejects entries in managed_access whose policies are
outside managed_policy_ids for exactly this reason: if it ever
created an entry with an unmanaged policy, a later
managed_access=[] couldn’t clean it up.
Idempotency¶
force=False (the default) compares the merged list to the remote
access via canonical JSON. If they match, no PUT is sent and
result.updated is False. At-least-once event handlers and
recovery scripts can call merge repeatedly without churning
versionId.
# Safe to re-run on the same desired_entries: only writes when
# something actually changed.
client.merge_project_membership_access(
membership_id,
managed_access=desired_entries,
managed_policy_ids={"practice-policy"},
)
force=True writes regardless. Useful when you specifically need a
fresh versionId for downstream auditing.
412 retry contract¶
The helper auto-attaches If-Match: W/"<versionId>" from the read
membership. A 412 (PreconditionFailedError) means a concurrent
writer landed between our read and our write — the helper re-reads,
rebuilds the merged list against the new state, and retries up to
max_retries times (default 1 — so two attempts total). After
that, the exception propagates:
from pymedplum import PreconditionFailedError
try:
client.merge_project_membership_access(
membership_id,
managed_access=desired_entries,
managed_policy_ids={"practice-policy"},
max_retries=3,
)
except PreconditionFailedError:
# Sustained contention. Back off and try later, or accept
# eventual consistency and move on.
...
If the read response lacks meta.versionId, the helper raises
ValueError before writing. It will not PUT without optimistic
concurrency.
Patient-scoped memberships¶
Everything above used Practitioner profiles and Organization tenants because that’s the most concrete MSO example. The same primitives apply when the parameterized AccessPolicy is patient-scoped instead of organization-scoped — only the policy and the parameter name change.
Two patient-scoped patterns matter in practice. Medplum documents both in Access Policies → Patient Access / Caregiver Access.
Special-case behavior to know about. Medplum’s access-policy
resolver auto-injects two parameters when it builds the effective
policy at request time
(see
server/src/fhir/accesspolicy.ts):
profile— always set to the membership’sprofilereference.patient— set to the membership’sprofilereference unless the access entry already provides apatientparameter.
The patient default is the source of two patterns below.
1. Patient self-access. A Patient User logs in to a portal
scoped to their own record via a templated policy whose compartment
is %patient. Because Medplum auto-defaults patient to the
profile reference (and the profile is Patient/<id>), the
application does not need to write any access entry — the
default does the right thing. Medplum’s
open patient registration
flow leans on this. If you do manage it explicitly anyway:
make_project_membership_access(
"AccessPolicy/patient-access-policy-template",
{"patient": "Patient/<patient-id>"},
)
In short: don’t reach for these helpers for self-access patient memberships unless you’re consciously overriding Medplum’s default.
2. Caregiver access. A parent, guardian, or proxy gets one
access entry per Patient they’re authorized to see, all referencing
the same templated patient-access policy. The
Medplum invite docs name
RelatedPerson as the natural profile for a caregiver who isn’t
themselves a patient.
For this case the application must specify patient explicitly on
each entry — Medplum’s auto-default would otherwise resolve
%patient to the RelatedPerson reference, which is the wrong
compartment.
The wire shape and our helpers are identical to the
practice-assignment example — substitute Patient for Organization
and the patient-access policy for the practice policy:
client.merge_project_membership_access(
caregiver_membership_id,
managed_access=[
make_project_membership_access(
"AccessPolicy/patient-access-policy-template",
{"patient": "Patient/child-a"},
),
make_project_membership_access(
"AccessPolicy/patient-access-policy-template",
{"patient": "Patient/child-b"},
),
],
managed_policy_ids={"patient-access-policy-template"},
)
add_project_membership_access_entry and
remove_project_membership_access_entry work the same way for
adding or revoking access to one Patient at a time, with the same
atomic concurrency guarantees.
One caveat for both patterns: the parameter name must match
the AccessPolicy template’s variable. A policy using %patient
requires {"patient": "Patient/..."}; a policy using %care_team
requires {"careTeam": "CareTeam/..."}. Mismatches don’t fail
client-side — Medplum accepts the entry but the variable just
doesn’t get bound — so verify against the AccessPolicy you
reference.
What these helpers do NOT do¶
- They do not modify
ProjectMembership.accessPolicy. That legacy single-policy field has been superseded byaccess; if you have callers still using it, migrate them — these helpers won’t. - They do not change
ProjectMembership.adminorProjectMembership.active. Admin elevation and membership revocation are separate operations. - They do not assign account compartments. Use
set_accountsfor that. The two are complementary:set_accountsputs a Patient under a tenant;merge_project_membership_accessgives a user access to that tenant.
About ProjectMembership¶
The rest of this page is orientation for readers new to the ProjectMembership resource model. Skip if you already know it.
A Medplum server hosts multiple Projects, each isolated from the others — a Project is the unit of multi-tenancy at the server level. Each Project has its own set of resources, its own AccessPolicies, and its own users.
A ProjectMembership is the resource that grants a principal
access to a Project. The principal (the membership’s user field)
is one of:
User— a human login identityBot— an automation script that runs inside the projectClientApplication— a machine-to-machine integration identity
Each membership also pins the principal to a profile within the project — the FHIR resource that represents how they participate. Allowed profile types are:
Practitioner— clinicians and staffPatient— patients with portal loginsRelatedPerson— family members, caregivers, or other delegated partiesBotandClientApplication— for non-human principals, whereuserandprofiletypically reference the same resource
The membership also carries the principal’s permission shape inside the project:
accessPolicy— a single AccessPolicy reference (legacy field, superseded byaccess)access— the list of parameterized AccessPolicy bindings this page is aboutadmin— project-admin flagactive— whether the membership is currently usable
A single User can belong to multiple Projects via separate
ProjectMembership resources.
ProjectMembership.access is a list on that row. Each entry binds the
principal to a parameterized AccessPolicy with specific parameter
values. For multi-tenancy within a Project (e.g., one Project per
customer, many Organizations representing practices inside it), each
entry represents one tenant the user can see. See Medplum’s
Multi-Tenant Access Policy guide
for the data model — what entries look like, how %organization /
%care_team / %healthcare_service parameters get substituted at
runtime, and how the resulting compartments restrict reads.
Why access matters in practice¶
Whenever a request is authenticated as a particular ProjectMembership,
Medplum applies that membership’s accessPolicy plus each entry in
access (with parameter substitution) to the call. That happens in
two situations:
- A user logging in directly — through Medplum’s web app, a custom UI you’ve built against Medplum’s auth, or any OAuth/OIDC flow that resolves to a ProjectMembership. Their search results, reads, writes, and GraphQL queries are all filtered by their membership’s access rules.
- Your code acting on their behalf — via PyMedplum’s OBO context
manager (
client.on_behalf_of(membership_id)) or a client-widedefault_on_behalf_of. PyMedplum sendsX-Medplum-On-Behalf-Ofwith the membership ID, and Medplum applies that membership’s rules instead of the calling client’s. The calling identity’s own permissions act as a ceiling, but within the ceiling the effective policy is the OBO target’s.
Either way, ProjectMembership.access is what determines what the
principal can see and do. Getting this list right is the point. See
On-Behalf-Of for the OBO mechanics themselves.
Where ProjectMembership IDs come from¶
You’ll typically have an ID handy because you got it from
invite_user:
membership = client.invite_user(
project_id="...",
resource_type="Practitioner",
first_name="Alice", last_name="Smith",
email="alice@example.com",
)
membership_id = membership["id"] # this is what merge takes
Or by searching for an existing membership when you don’t have it cached:
result = client.search_one(
"ProjectMembership",
{"user": f"User/{user_id}", "project": f"Project/{project_id}"},
)
membership_id = result["id"]