On-Behalf-Of (OBO)¶
On-behalf-of (OBO) is a Medplum security mechanism that lets an
authenticated service act as a specific ProjectMembership. PyMedplum
sends the membership reference in the X-Medplum-On-Behalf-Of header
on every outbound request where OBO is active.
When OBO is in effect, Medplum applies the target membership’s
accessPolicy and access entries to the request — that membership’s
parameterized access rules are what determine what the call can see
or write. See
ProjectMembership Access for how to keep
those access entries in sync with your application’s source of
truth.
There are three ways to set OBO on a PyMedplum client. This page walks through each, the precedence rules between them, and how OBO interacts with async tasks and worker pools.
Three ways to pass OBO¶
1. Per-call kwarg (wins over everything)¶
Pass on_behalf_of= directly to any request method:
from pymedplum import MedplumClient
client = MedplumClient(
base_url="https://api.medplum.com/",
client_id="YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
)
patient = client.read_resource(
"Patient",
"123",
on_behalf_of="ProjectMembership/abc",
)
Use this for security-critical calls where the acting identity must be obvious at the call site — reviewers should not have to check ambient state to know who the call was made as.
The empty string on_behalf_of="" clears the ambient OBO for
that one call (bypasses both the context manager and the client
default, sends no X-Medplum-On-Behalf-Of header).
2. Context manager (ambient for a block)¶
with client.on_behalf_of("ProjectMembership/abc"):
patient = client.read_resource("Patient", "123")
conditions = client.search_resources("Condition", {"patient": "123"})
Async variant:
from pymedplum import AsyncMedplumClient
async def handler():
async with AsyncMedplumClient(
base_url="https://api.medplum.com/",
client_id="YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
) as client:
async with client.on_behalf_of("ProjectMembership/abc"):
patient = await client.read_resource("Patient", "123")
Use this when you have a logical scope (one HTTP handler, one background task) that makes several calls as the same acting user.
3. Client default (baseline for the client’s lifetime)¶
client = MedplumClient(
base_url="https://api.medplum.com/",
client_id="YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
default_on_behalf_of="ProjectMembership/abc",
)
patient = client.read_resource("Patient", "123")
Use this when the client exists specifically to act as one particular membership (for example, a per-user client in a worker pool where each task gets its own fresh instance).
Precedence rules¶
When more than one source is set, the SDK resolves OBO in this order:
- Per-call kwarg (explicit
on_behalf_of=...) — wins if passed. - Context manager (ambient ContextVar set by
client.on_behalf_of(...)). - Client default (
default_on_behalf_of=constructor kwarg). - None — no
X-Medplum-On-Behalf-Ofheader sent.
The empty string is treated as a clear at the per-call layer:
client.read_resource("Patient", "123", on_behalf_of="") sends
no header even if a context manager is active or a client default
is set.
Per-client isolation¶
Each MedplumClient / AsyncMedplumClient instance owns its own
ContextVar. Setting OBO on client A never affects client B, even
in the same process and even when clients are rapidly created and
destroyed (a fresh ContextVar name — with a per-instance UUID
suffix — prevents the Python ContextVar machinery from recycling
state across instances).
client_a = MedplumClient(base_url="https://api.medplum.com/")
client_b = MedplumClient(base_url="https://api.medplum.com/")
with client_a.on_behalf_of("ProjectMembership/actor-a"):
# client_b still has no OBO set — isolation is per-instance.
client_b.read_resource("Patient", "123")
Async task isolation¶
Two concurrent asyncio tasks can each set different OBO values
on the same client, and each sees only its own — standard
ContextVar semantics under asyncio.
import asyncio
from pymedplum import AsyncMedplumClient
async def act_as(client: AsyncMedplumClient, membership: str) -> dict:
async with client.on_behalf_of(membership):
return await client.read_resource("Patient", "123")
async def main() -> None:
async with AsyncMedplumClient(
base_url="https://api.medplum.com/",
) as client:
a, b = await asyncio.gather(
act_as(client, "ProjectMembership/actor-a"),
act_as(client, "ProjectMembership/actor-b"),
)
Each coroutine carries its own ContextVar state across await
points; the two gathered calls do not see each other’s OBO.
Thread propagation caveat¶
ThreadPoolExecutor.submit does not copy ContextVar state
into the worker thread. This code is broken:
from concurrent.futures import ThreadPoolExecutor
# BROKEN — the submitted function does not see the outer OBO.
with client.on_behalf_of("ProjectMembership/abc"):
with ThreadPoolExecutor() as pool:
pool.submit(client.read_resource, "Patient", "123")
Safe patterns:
import asyncio
import contextvars
from concurrent.futures import ThreadPoolExecutor
# Option A: asyncio.to_thread — copies context automatically.
async def ok_async():
async with client.on_behalf_of("ProjectMembership/abc"):
await asyncio.to_thread(client.read_resource, "Patient", "123")
# Option B: contextvars.copy_context().run — manual propagation.
def ok_threaded():
with client.on_behalf_of("ProjectMembership/abc"):
ctx = contextvars.copy_context()
with ThreadPoolExecutor() as pool:
pool.submit(ctx.run, client.read_resource, "Patient", "123")
# Option C: pass OBO explicitly — does not rely on propagation.
def ok_explicit():
with ThreadPoolExecutor() as pool:
pool.submit(
client.read_resource,
"Patient",
"123",
on_behalf_of="ProjectMembership/abc",
)
Option C is usually the easiest to reason about in code review — the acting identity is visible at the submission site.
Worker / Celery / TaskIQ pattern¶
In a task-worker setup where each task acts on behalf of a
different user, the recommended pattern is a fresh client per
task, constructed with default_on_behalf_of= bound to that
task’s acting membership:
from pymedplum import AsyncMedplumClient
async def handle_patient_task(membership_id: str, patient_id: str):
async with AsyncMedplumClient(
base_url="https://api.medplum.com/",
client_id="YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
default_on_behalf_of=membership_id,
) as client:
return await client.read_resource("Patient", patient_id)
This is safer than reusing a long-lived shared client across tasks for two reasons:
- State isolation. A fresh client cannot carry OBO state from a previous task. If a task forgets to exit a context manager on a shared client, that OBO could bleed into the next task running on the same worker thread.
- Explicit identity. The acting membership is visible at the client-construction site, which is the first thing a reviewer or debugger looks at.
If you must share a long-lived client, always use the per-call
kwarg or context manager — never rely on default_on_behalf_of
to be the right value for the current task.
When to use which¶
| Situation | Recommended mechanism |
|---|---|
| Security-critical one-off call; acting identity must be obvious at the call site | Per-call kwarg |
| Handler that makes 5–10 calls as the same user | Context manager |
| Client that exists specifically to act as user X (e.g. per-task worker client) | Client default |
Threaded code without ContextVar propagation |
Per-call kwarg (safest) |
| Long-lived shared client across many users | Per-call kwarg or context manager; never rely on default |
Relation to audit logging¶
Every RequestEvent dispatched to on_request_complete records
on_behalf_of per wire attempt under event.attempts[i], so
your audit log always reflects what was sent on the wire — not a
snapshot taken at hook time. See
Audit Logging for the full hook contract.