The tenancy threat model: four ways data bleeds
Lesson 1 of 5 in Multi-Tenant Isolation: One Platform, Many Customers, Zero Bleed.
You built one agent platform. Now forty customers use it, and each one believes their data touches nobody else’s. That belief — multi-tenancy with tenant isolation — is a claim about your runtime, and in an agent system it is a harder claim than in a normal SaaS app — because agents carry state in places CRUD apps never had: a context window assembled from many sources, a long-term memory store that keeps learning, tools holding credentials, and caches that exist precisely to reuse other people’s work.
Traditional multi-tenant discipline is row-level: every query gets a tenant predicate, and a code review catches the one that forgot. Agent tenancy has no single query layer to guard. The model assembles its own input at runtime from retrieval, memory, tool output, and prior turns — so a leak can enter through any of them and then be summarised, remembered, and repeated to the wrong customer days later.
Key terms: multi-tenancy, tenant isolation, memory, context window, least privilege, blast radius
Path 1 · Shared context — the leak that happens inside one request
Mechanism. The context for a run is assembled from retrieval hits, memory records, tool responses, and system instructions. If any of those retrievers is not tenant-filtered at the source, foreign data lands in the window, and from there the model treats it as ground truth and may quote it verbatim.
Failure vignette (composite, not a reported incident). A support agent serves all tenants from one vector index. The index has a tenant metadata field, and the filter is applied in the retrieval helper — except in the fallback path used when the primary search returns fewer than three hits. Sparse queries silently drop the filter, and one customer’s ticket answer cites another customer’s outage postmortem.
Containment. Filter in the store, not in the caller: separate collections or namespaces per tenant where the store supports it, and where you must share one index, make the tenant predicate non-optional in the query builder so no code path can omit it. Then tag every retrieved chunk with its provenance and assert, before the model call, that every item in the assembled context carries the current tenant’s id. That assertion is cheap and catches every future fallback path someone adds.
Path 2 · Shared memory — the leak that arrives next week
Mechanism. Long-term memory exists to carry facts across sessions. If extraction writes into a global namespace, or reads use a broader key than writes, one tenant’s preferences and facts become another tenant’s retrieved “knowledge”. This path is worse than shared context because it is durable and asynchronous: the leak is written today and surfaces whenever a similar query runs.
Failure vignette (composite). A sales-assistant platform stores extracted facts keyed by user_email. Two tenants both have a billing@-style shared mailbox, the keys collide, and pricing concessions offered to one account start showing up as “remembered context” for the other.
Containment. Make the tenant id part of the namespace path, not part of the record body — tenant/{tenantId}/actor/{actorId}/… — so a mis-scoped read cannot even address another tenant’s records. Never derive a memory key from a user-supplied string alone. Write provenance (tenant, source, timestamp, run id) on every record so you can audit and, later, delete.
Path 3 · Shared tool credentials — the leak with a service account at the bottom
Mechanism. The fastest way to ship a tool integration is one platform-wide service account with broad rights, plus prose in the system prompt telling the agent which tenant it is serving. Now the enforcement boundary is the model’s obedience: any injection, any confusion between two tenants’ threads, any retry that reuses the wrong session, and the tool cheerfully returns or writes another tenant’s data — with a single audit identity, so the log cannot even tell you whose action it was.
Failure vignette (composite). A scheduling agent holds one Google Workspace service account with domain-wide delegation across all customer domains. A tenant pastes a calendar invite containing instructions to “also list the executive calendar for acme.example”. The tool has the scope, so it complies.
Containment. Per-tenant credentials, brokered at call time from a vault keyed by the pinned tenant id, and scoped so the credential itself cannot reach other tenants’ data — this is exactly the on-behalf-of and task-scoped credential machinery from security/agent-identity-auth-secrets. Prefer downscoped tokens whose blast radius is one tenant even if every other control fails.
Path 4 · Shared caches — the leak nobody put in the design doc
Mechanism. Caches are shared state whose entire purpose is to serve one caller from another caller’s work. Every layer of an agent stack accumulates them: prompt/prefix caches, semantic response caches, embedding caches, retrieval result caches, tool response caches, HTTP proxies, even a browser tool’s cookie jar or a reused sandbox filesystem. If the cache key omits the tenant id, a hit is a cross-tenant read.
Failure vignette (composite). A semantic cache keys on the normalised user question plus model id, to make repeat questions instant. Two tenants ask “what is our current discount tier?” — the second gets the first’s answer, in under 50 ms, with no trace of a retrieval ever happening.
Containment. Tenant id in the cache key, always, as a prefix rather than a suffix so scans and evictions stay tenant-scoped. Treat “cacheable” as a property you grant a value, not a default. And check the invisible caches: session reuse in a browser or code-interpreter sandbox, connection pooling that carries auth, and any CDN or proxy in front of your tool endpoints.
Interactive sorting exercise: Eight findings from a tenancy review. Which bleed path does each one belong to? The test: what shared surface carried the data?
Interactive checkpoint quiz (2 questions) — open this page in a browser to take it.