Multi-Tenancy¶
First-class tenant isolation across the whole stack. When a caller carries a
tenant_id, it becomes part of every isolation key — semantic cache,
memory scoping, conversation ownership, rate-limit buckets, audit entries,
and tool access — so two tenants with the same user_id can never see
each other's data. Isolation is a structural invariant, not a naming
convention buried in metadata.
Why this matters
Cross-tenant data leakage is the worst incident class for any multi-customer platform. Conventions ("we always prefix keys with the org") fail silently the first time one call site forgets. Promptise bakes the tenant into the key derivation itself, in one place per surface — there is no code path that stores or reads tenant data without it.
Server side: ClientContext.tenant_id¶
AuthMiddleware extracts the tenant from a configurable JWT claim
(default tenant_id) and attaches it to ctx.client.tenant_id:
from promptise.mcp.server import AuthMiddleware, JWTAuth, MCPServer
server = MCPServer(name="api")
server.add_middleware(
AuthMiddleware(
JWTAuth(secret="...", audience="api://my-server"),
tenant_claim="tenant_id", # or "org", "org_id", ... — your IdP's claim
)
)
@server.tool(auth=True)
async def whoami(ctx: RequestContext) -> dict:
return {"client": ctx.client.client_id, "tenant": ctx.client.tenant_id}
Only string claim values are accepted; anything else leaves
tenant_id unset and tenant guards fail closed.
For APIKeyAuth, the tenant comes from the key's config dict:
APIKeyAuth(keys={
"sk-acme-1": {"client_id": "acme-agent", "roles": ["analyst"], "tenant_id": "acme"},
"sk-globex-1": {"client_id": "globex-agent", "roles": ["analyst"], "tenant_id": "globex"},
})
Enforcing tenancy: guards and require_tenant¶
Two guards mirror the role/scope guards:
| Guard | Grants access when |
|---|---|
RequireTenant() |
The client has any tenant identity |
HasTenant("acme", "globex") |
The client belongs to one of the listed tenants |
from promptise.mcp.server import HasTenant, RequireTenant
@server.tool(auth=True, guards=[RequireTenant()])
async def list_records() -> list: ...
@server.tool(auth=True, guards=[HasTenant("acme")])
async def acme_only_tool() -> str: ...
To make tenancy a server-wide invariant, build the server with
require_tenant=True — every tool (from decorators, routers, mounts, or
OpenAPI import) is forced to authenticate and carries a RequireTenant
guard. A client whose token lacks the tenant claim is denied on every call:
What the tenant automatically isolates (server side)¶
| Surface | Behavior with a tenant present |
|---|---|
| Rate limiting | Bucket keys are tenant-qualified in both RateLimitMiddleware and declared per-tool limits — one tenant's traffic can never exhaust another's quota, even for identical client_id strings |
| Audit log | AuditMiddleware records tenant_id in each entry's identity descriptors — tenant-scoped forensics without joining external data |
| Tool access | RequireTenant / HasTenant guards, or the server-wide require_tenant invariant |
SessionState needs no tenant prefix: it is keyed by the live transport
session, which is connection-scoped and therefore cannot be shared across
tenants.
Agent side: CallerContext.tenant_id¶
The same invariant applies inside the agent. CallerContext gains
tenant_id, and one derivation — CallerContext.isolation_key
("{tenant_id}::{user_id}", or the plain user_id without a tenant) —
feeds every per-user isolation surface:
from promptise import CallerContext
acme_alice = CallerContext(user_id="alice", tenant_id="acme")
globex_alice = CallerContext(user_id="alice", tenant_id="globex")
# Same user_id, different tenants — fully isolated:
await agent.chat("...", session_id=sid, caller=acme_alice)
| Surface | Behavior |
|---|---|
| Semantic cache | Scope keys embed the tenant — cross-tenant cache hits are structurally impossible. purge_user("alice", tenant_id="acme") purges exactly that tenant's scope |
| Memory | Providers receive the isolation key as user_id — no provider changes needed, isolation guaranteed at the scoping layer |
| Conversations | Session ownership keys on the isolation key — a same-user_id caller from another tenant gets SessionAccessDenied |
| Cross-agent delegation | The full CallerContext (including tenant) is inherited by peers via caller-context continuity |
Memory providers see composite ids
With a tenant present, providers store owner ids like
"acme::alice". If you query a provider directly (outside the agent),
use the same composite form.
The isolation-key separator is reserved
CallerContext construction rejects (with a ValueError) a tenant_id
containing any colon and a user_id containing the :: sequence.
That makes the tenant::user join unambiguous and injective, and keeps
the tenanted keyspace (always containing ::) provably disjoint from the
untenanted one (a raw user_id, which can never contain ::) — an
untenanted user_id="acme::alice" cannot forge tenant acme's user
alice, it simply fails to construct. Single colons in user_id (SSO
ids like google:12345, auth0|abc) remain fine; tenant ids are plain
identifiers (acme, an org UUID) and so are colon-free.
End-to-end: tenant flows from token to storage¶
# 1. Your app authenticates the user and knows their org
caller = CallerContext(user_id="alice", tenant_id="acme")
# 2. Agent-side isolation is automatic
reply = await agent.chat("What did we discuss?", session_id=sid, caller=caller)
# 3. Server-side: the agent's JWT carries the tenant claim,
# AuthMiddleware extracts it, guards + rate limits + audit key on it
See Also¶
- Authentication & Security — auth providers, guards,
ClientContext - Multi-User Identity guide — end-to-end
CallerContextflow - Approval Gates — server-side human-in-the-loop