|
| 1 | +# Multi-tenant contract |
| 2 | + |
| 3 | +This is the security-critical surface of `adcp.server`. Sellers serving |
| 4 | +more than one tenant (or more than one account within a tenant) must |
| 5 | +satisfy every invariant below. Each invariant names the failure mode |
| 6 | +you get if you violate it — use this as an audit checklist. |
| 7 | + |
| 8 | +The SDK enforces some of these automatically (idempotency scope |
| 9 | +composition); others are the seller's responsibility to populate |
| 10 | +correctly. Both kinds are listed here. |
| 11 | + |
| 12 | +## The three scopes |
| 13 | + |
| 14 | +AdCP models three nested identifiers. Handlers, middleware, audit logs, |
| 15 | +and cache keys all compose from these three. |
| 16 | + |
| 17 | +| Scope | Source | Populated on | |
| 18 | +|-------|--------|-------------| |
| 19 | +| `caller_identity` | Authenticated principal (token, mTLS, signed request) | `ToolContext.caller_identity` | |
| 20 | +| `tenant_id` | Seller's tenancy model — maps an authenticated principal to its tenant | `ToolContext.tenant_id` | |
| 21 | +| `account_id` | Request parameter (`params.account`) resolved via `resolve_account` | `AccountAwareToolContext.account_id` | |
| 22 | + |
| 23 | +`caller_identity` and `tenant_id` are **authentication-scoped** — they |
| 24 | +come from the bearer token / client cert / discovery key, and do not |
| 25 | +change for the lifetime of the request. `account_id` is |
| 26 | +**request-scoped** — the same principal can operate on different |
| 27 | +accounts on successive calls. |
| 28 | + |
| 29 | +## Invariants |
| 30 | + |
| 31 | +### I1. `caller_identity` MUST be stable and globally unique within the tenant |
| 32 | + |
| 33 | +A *stable* identifier means the same string for the same principal |
| 34 | +across all time. An *email address is not stable* — it can be recycled |
| 35 | +after account deletion, renamed, or merged. |
| 36 | + |
| 37 | +**Populate with**: the principal's surrogate id from your IdP (Okta |
| 38 | +`sub` claim, SCIM `externalId`, internal employee id, signing key |
| 39 | +fingerprint). Never an email, display name, or any other mutable |
| 40 | +handle. |
| 41 | + |
| 42 | +**Failure mode**: cross-principal replay of idempotent responses. The |
| 43 | +server-side idempotency store keys cached responses by |
| 44 | +`(scope_key, idempotency_key)` where `scope_key` composes |
| 45 | +`tenant_id + caller_identity`. If principal A's identity is reused |
| 46 | +after they're deleted and assigned to principal B, B will see A's |
| 47 | +cached responses — a confidentiality leak. |
| 48 | + |
| 49 | +**Where populated**: transport layer. |
| 50 | +- A2A: `ADCPAgentExecutor` reads it from `ServerCallContext.user.user_name`. |
| 51 | +- MCP: the seller's FastMCP auth middleware populates a `ContextVar` |
| 52 | + that `context_factory` reads (see `examples/mcp_with_auth_middleware.py`). |
| 53 | + |
| 54 | +### I2. `tenant_id` MUST be populated when principal ids are tenant-scoped |
| 55 | + |
| 56 | +If your principal ids are *only unique within a tenant* — typical for |
| 57 | +Okta group-scoped ids, SCIM per-tenant ids, seller-internal employee |
| 58 | +ids — you **must** populate `tenant_id` so the idempotency store can |
| 59 | +compose `(tenant_id, caller_identity)` into a globally-unique scope |
| 60 | +key. |
| 61 | + |
| 62 | +**Failure mode**: cross-tenant replay. Principal "alice@corp" on tenant |
| 63 | +T1 and principal "alice@corp" on tenant T2 would share cached |
| 64 | +responses. Another confidentiality leak, this time across tenants. |
| 65 | + |
| 66 | +**Single-tenant exception**: if every principal id is globally unique |
| 67 | +across your entire deployment (e.g. every principal id is a UUID, or |
| 68 | +the service only ever serves one tenant), you can leave `tenant_id` |
| 69 | +unset and the scope collapses to `caller_identity` alone. |
| 70 | + |
| 71 | +**Where populated**: seller's `context_factory`. Look up the tenant |
| 72 | +for the authenticated principal and populate the field on the |
| 73 | +`ToolContext` returned by the factory. Subclass `ToolContext` if your |
| 74 | +tenant model needs more than a string id. |
| 75 | + |
| 76 | +### I3. `account_id` is request-scoped, not authentication-scoped |
| 77 | + |
| 78 | +An authenticated principal may operate on several accounts — for |
| 79 | +example, an agency with multiple brand accounts, or a reseller managing |
| 80 | +downstream advertisers. Every operation's `account` parameter carries |
| 81 | +an `AccountReference` that the seller resolves to a concrete account |
| 82 | +at the handler boundary. |
| 83 | + |
| 84 | +**Populate with**: the resolved account's stable id from the seller's |
| 85 | +own system. Same stability rule as `caller_identity` — no emails, no |
| 86 | +display names. |
| 87 | + |
| 88 | +**Failure mode**: authorization bypass. If you treat |
| 89 | +`context.account_id` as if it were as stable as `caller_identity`, a |
| 90 | +single handler invocation can end up scoping a downstream cache or |
| 91 | +authorization check to the wrong account. |
| 92 | + |
| 93 | +**Where populated**: the handler (or a middleware), per-call, via |
| 94 | +`resolve_account_into_context(params, context, resolver)`. The helper |
| 95 | +populates `AccountAwareToolContext.account_id` if the resolver |
| 96 | +succeeds, and returns an `ACCOUNT_NOT_FOUND` / `ACCOUNT_SUSPENDED` / |
| 97 | +etc. error dict if it doesn't. |
| 98 | + |
| 99 | +### I4. Idempotency scope keys MUST include tenant + principal |
| 100 | + |
| 101 | +Enforced by `IdempotencyStore` — listed here so you know what the SDK |
| 102 | +guarantees. |
| 103 | + |
| 104 | +The cache key is `(scope_key, idempotency_key)` where `scope_key` |
| 105 | +derives from both `tenant_id` and `caller_identity` on the request |
| 106 | +context. You cannot bypass this by supplying your own scope key; the |
| 107 | +store composes it internally from the `ToolContext` fields. |
| 108 | + |
| 109 | +**Seller's responsibility**: populate `caller_identity` (I1) and |
| 110 | +`tenant_id` (I2) correctly. The SDK does the composition. |
| 111 | + |
| 112 | +### I5. Caches and audit logs MUST key on the same three scopes |
| 113 | + |
| 114 | +Any seller-side cache (product catalog, resolved accounts, rate limit |
| 115 | +counters) or audit log that stores per-principal or per-account state |
| 116 | +MUST key on `(tenant_id, caller_identity, account_id)` — or a prefix of |
| 117 | +that tuple appropriate for the data. |
| 118 | + |
| 119 | +**Failure mode**: same class as I1/I2 — confidentiality or |
| 120 | +authorization leaks. The SDK idempotency store is just the most |
| 121 | +prominent example of the rule; your own storage has to follow it too. |
| 122 | + |
| 123 | +**Rule of thumb**: if you cache something derived from the request |
| 124 | +context or params, the cache key must incorporate whichever of the |
| 125 | +three scopes varied to produce the cached value. |
| 126 | + |
| 127 | +### I6. `context_factory` MUST NOT hold mutable per-request state |
| 128 | + |
| 129 | +`context_factory` is called once per request; the returned |
| 130 | +`ToolContext` is passed through every handler invocation in that |
| 131 | +request. If your factory mutates a shared instance instead of |
| 132 | +returning a fresh one, concurrent requests will read each other's |
| 133 | +scopes. |
| 134 | + |
| 135 | +**Where this bites**: sellers who cache their `context_factory` result |
| 136 | +module-globally and mutate it. |
| 137 | + |
| 138 | +**Rule**: the factory returns a **new** `ToolContext` (or subclass) |
| 139 | +every call. |
| 140 | + |
| 141 | +### I7. Never stash the context in a module-level variable |
| 142 | + |
| 143 | +Handlers receive `context` as a parameter. Adopting a ContextVar |
| 144 | +pattern for ergonomics (resetting the var in a `finally` block) is |
| 145 | +fine; storing `context` in a module-level dict indexed by anything |
| 146 | +other than the request's transient id is not. |
| 147 | + |
| 148 | +**Failure mode**: cross-request leak. Request A's context is still in |
| 149 | +the module when request B starts — whatever B reads is A's data. |
| 150 | + |
| 151 | +The same advice applies to application-level locks, caches, and |
| 152 | +tracing state: scope them by tenant + principal + account as in I5. |
| 153 | + |
| 154 | +## Wiring it up |
| 155 | + |
| 156 | +### Single-tenant agent (simplest) |
| 157 | + |
| 158 | +Populate `caller_identity` from your auth middleware. Leave `tenant_id` |
| 159 | +unset (scope collapses safely). |
| 160 | + |
| 161 | +```python |
| 162 | +serve(MyAgent(), name="my-agent", context_factory=my_auth_context_factory) |
| 163 | +``` |
| 164 | + |
| 165 | +### Multi-tenant agent (typical) |
| 166 | + |
| 167 | +Subclass `ToolContext` with your tenant model, populate it in the |
| 168 | +factory, parameterise the handler so types flow through. |
| 169 | + |
| 170 | +```python |
| 171 | +from dataclasses import dataclass |
| 172 | +from adcp.server import ADCPHandler, ToolContext |
| 173 | + |
| 174 | +@dataclass |
| 175 | +class TenantContext(ToolContext): |
| 176 | + tenant: Tenant | None = None # your tenant model |
| 177 | + # caller_identity + tenant_id fields inherited from ToolContext |
| 178 | + |
| 179 | +class MyAgent(ADCPHandler[TenantContext]): |
| 180 | + async def get_products(self, params, context=None): |
| 181 | + ... |
| 182 | +``` |
| 183 | + |
| 184 | +The `context_factory` returns `TenantContext(caller_identity=..., tenant_id=..., tenant=...)`. |
| 185 | + |
| 186 | +### Account-scoped operations |
| 187 | + |
| 188 | +Add `account_id` resolution per-call. Use `AccountAwareToolContext` |
| 189 | +(or a subclass of it) and resolve at handler entry. |
| 190 | + |
| 191 | +```python |
| 192 | +from adcp.server import ( |
| 193 | + ADCPHandler, |
| 194 | + AccountAwareToolContext, |
| 195 | + resolve_account_into_context, |
| 196 | +) |
| 197 | + |
| 198 | +class MyAgent(ADCPHandler[AccountAwareToolContext]): |
| 199 | + async def get_products(self, params, context=None): |
| 200 | + err = await resolve_account_into_context(params, context, my_resolver) |
| 201 | + if err: |
| 202 | + return err |
| 203 | + # context.account_id is populated — safe to scope cache/audit by it |
| 204 | + return products_response(self.catalog.for_account(context.account_id)) |
| 205 | +``` |
| 206 | + |
| 207 | +## Audit checklist |
| 208 | + |
| 209 | +Before shipping a multi-tenant agent, verify each of these: |
| 210 | + |
| 211 | +- [ ] `caller_identity` is populated from a stable surrogate id, not an email. |
| 212 | +- [ ] `tenant_id` is populated (unless single-tenant or globally unique ids). |
| 213 | +- [ ] `context_factory` returns a fresh `ToolContext` on every call. |
| 214 | +- [ ] No module-level dict indexes `ToolContext` instances by mutable keys. |
| 215 | +- [ ] Every seller-side cache or audit log keys on |
| 216 | + `(tenant_id, caller_identity, account_id)` as appropriate for the data. |
| 217 | +- [ ] Account-scoped handlers call `resolve_account_into_context` before |
| 218 | + touching account-scoped storage. |
| 219 | +- [ ] Tests cover the cross-tenant and cross-principal leak paths |
| 220 | + (two concurrent requests with different scopes return different results). |
| 221 | + |
| 222 | +## Related reading |
| 223 | + |
| 224 | +- `examples/mcp_with_auth_middleware.py` — concrete ContextVar pattern |
| 225 | + for MCP, including the discovery-method bypass. |
| 226 | +- `docs/handler-authoring.md` — broader handler patterns, including |
| 227 | + the single-file starting point. |
| 228 | +- `src/adcp/server/idempotency/store.py` — how scope keys are composed |
| 229 | + inside the SDK. |
0 commit comments