An AI meeting recorder with over two million users stored every meeting on the platform in one Firestore collection, and that collection had no tenant isolation. The researcher who found it queried 181,874 meeting records belonging to 84,312 unique users across 35,003 email domains. Roughly a thousand of those records were live calls at any given moment, each carrying a conference ID that anyone could use to join. He walked into a meeting of the Malaysian Ministry of Education with 157 participants, uninvited, because the database told him where it was. He reported the problem on January 28. Six months later, he says, the CTO had never responded and the collection was still open (writeup, 626 points on HN).
The company tells a different story: two distinct vulnerabilities, the first found by its own penetration testing vendor and closed months ago, the second a new vector fixed within 24 hours of discovery, and Firebase being removed from the stack entirely (rebuttal). Its CTO also admitted the part that is not in dispute: "I recognize that I should have kept the researcher updated after his initial outreach earlier this year, and I take full responsibility for that communication gap."
Someone is wrong, and that is exactly why Part 11 promised this part. Tenant isolation is the kind of bug you cannot afford to guess about, because the guess goes one of two ways: the researcher is right and two million users' meeting metadata sat exposed for six months, or the company is right and the public record still reads as a six-month silence. Either way, the fix is the same. You put a tenant boundary around every piece of data, and you prove it with a test that one tenant cannot see another.
I am a Senior Software Engineer II at BS23 in Dhaka, and I have been building production AI agents with Spring Boot and Spring AI for over a year. The e-commerce assistant from Parts 1 through 11 is the same agent: same nine tools, same supervisor, same memory, and now the Part 11 guard is growing a tenant boundary, with the tl;dv checklist applied to the agent itself.
Where the agent has no tenants
The demo app that started this series resolves identity with an HTTP session, and the session id becomes the conversation id. Every tool reads that id from the tool context:
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.toolContext(Map.of("conversationId", conversationId))
That works for a single-user demo, and it hides a structural fact: there is no tenant dimension anywhere. The chat memory bean is one shared MessageWindowChatMemory. The cart service keys carts by conversation id. getOrderStatus fetches an order by its numeric id, full stop. The vector store index has no tenant field, and semantic search has no filter. If two customers used this app, their conversations would share one memory pool, their tool results would be separated only by the Part 11 conversation-scoping rule, and every search would run against the whole catalog.
tl;dv's meetings collection was the same shape. Every other collection returned 403 to foreign users. The researcher's writeup says it in one line: "You already do it correctly for every other collection. You just forgot meetings." The agent version of that sentence is: you do it right for checkout, and the tool that fetches by id is your meetings collection. The job of tenant isolation is to make the forgotten collection impossible.
Step 1: Resolve the tenant at the edge, not in the prompt
The first rule: the model never tells you who the user is. A tenant id that arrives inside a user message is not an identity, it is an assertion, and assertions are what attacks are made of. The tenant is resolved once, at the request edge, from the authenticated session or the token the API client presented at login. Then it flows through the same seam the conversation id already uses, so streaming and tool calls both see it:
String tenantId = resolveTenant(session); // set at login, never parsed from the prompt
chatClient.prompt()
.user(message)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, tenantId + ":" + conversationId))
.toolContext(Map.of("tenantId", tenantId, "conversationId", conversationId))
.call()
.content();
Two details matter here. First, the conversation key becomes tenantId:conversationId, because conversation ids are only unique inside a tenant, and two tenants may both have a session whose id happens to be the same. Second, the tenant rides in the tool context, not a thread-local. The SSE streaming path in the demo crosses threads (the ChatStreamService streams on Reactor threads), and a ThreadLocal will silently be empty on the other side. The tool context is carried explicitly by Spring AI into every ToolCallback, which is exactly why the conversation id was already there. The tenant is the same kind of value: identity data, not model data.
Step 2: Namespace the memory per tenant
The demo registers one shared MessageWindowChatMemory bean with a 30-message window, and the advisor looks up history by conversation id. Two tenants sharing one memory bean means tenant A's history is one key away from tenant B's, and if the key guess succeeds, the advisor happily prepends a stranger's conversation to the prompt. The composite key from Step 1 already fixes the collision: memory is now partitioned by tenantId:conversationId, and no tenant can address another tenant's window.
That is the minimal fix, and it is worth being honest about what it is not. MessageWindowChatMemory is an in-memory store that lives inside the application. It is fine for the demo and for single-instance development. A production agent with real tenants wants persistent memory, and the moment you move to a real store, the composite key becomes a row: (tenant_id, conversation_id, message_id), with the tenant id in the primary key and a tenant filter on every read. The pattern is the same at every layer. Identity first, storage second. If the key has the tenant in it, the leak needs two bugs instead of one.
Step 3: Scope every tool result to the caller
The Part 11 guard already checks that an order lookup belongs to the conversation that asked for it. Part 12 widens the same check: an order belongs to a tenant, and a tool that returns order data has to verify the caller's tenant before it returns anything. The policy rule grows one line:
if (toolName.equals("getOrderStatus")) {
return verifyOrderBelongsToTenantAndConversation(toolInput, context);
}
The verification is not a filter on the result, it is an ownership check on the lookup. OrderService.getById in the demo fetches by id alone, which is the exact shape of tl;dv's bug: the data was protected everywhere except the one lookup that took a raw id. The tool layer is where this has to be enforced, because the model will happily pass along an id a user mentions, and a user can mention any id they have heard of. The tool is the last place that can refuse.
The same rule applies to every tool that touches tenant data, not just orders: cart reads, order status, and anything the agent's memory or RAG returns. The checklist from the researcher's writeup is useful here precisely because it is a checklist: name every collection, name every tool, and for each one, state what a foreign tenant would see if the check were missing. If you cannot say it for a tool, that tool is your meetings collection.
Step 4: Give the vector store a tenant dimension
The vector store is the easiest place to leak and the easiest place to forget, because retrieval failures are soft. A cross-tenant order lookup throws or returns nothing, and a test catches it. A cross-tenant semantic search returns plausible results from another tenant's catalog, and nobody notices, because the answer still looks right.
The demo builds product documents with metadata and indexes them on startup:
Map<String, Object> metadata = new HashMap<>();
metadata.put("productId", String.valueOf(product.getId()));
metadata.put("category", product.getCategory());
The fix is to add the tenant to the metadata when the document is created, and filter on it when a search runs:
Map<String, Object> metadata = new HashMap<>();
metadata.put("productId", String.valueOf(product.getId()));
metadata.put("tenantId", product.getTenantId());
vectorStore.similaritySearch(SearchRequest.builder()
.query(query)
.topK(topK)
.filterExpression("tenantId == '" + tenantId + "'")
.build());
One design question matters here: separate index per tenant, or one index with a filter? For this catalog, one index with a tenant filter is the right call, because the catalog is shared merchandise and the tenant only needs to see its own slice. For an agent whose tenants genuinely own disjoint data, a separate index (or a separate collection in a hosted vector store) is the stronger boundary, at the cost of more moving parts. Either way, the test is the same: tenant A's search can never return a document whose tenant is B, and the assertion belongs in the eval harness from Part 8, not just in a unit test.
Step 5: The cross-tenant test, written before the feature
Part 11 said the cage is a test suite you have to maintain. Tenant isolation is the same sentence with a sharper point: the test has to exist before the feature, because the whole value of the feature is the negative case, and negative cases are the ones teams skip. The Part 6 harness already covers the policy; the cross-tenant suite sits next to it with two sessions, two tenants, and deliberately colliding ids:
- Memory does not leak. Tenant A and tenant B both start a conversation with the same conversation id. A asks about product X. B asks the same question with the same id. B's reply must not contain anything A said, and the advisor must not concatenate the windows.
- Orders do not cross. A places an order. B asks for that order's status by id, in a fresh conversation. The tool must refuse, not return A's order.
- Search stays inside the tenant. A's catalog contains a product that B's does not. B searches for it by name and by meaning. Both queries return nothing from A's slice.
The third test is the one that catches the vector store, and it is the easiest to forget, because it passes until the day it fails in production. That is the tl;dv pattern: the writeup shows the platform doing tenant rules correctly for users, chats, transcripts, clips, and notes, and missing the meetings collection. The agent equivalent is the tool you did not put in the test matrix.
The honest cost section
Tenant isolation is a tax on every layer, and the tax is the reason it gets skipped. Every query needs a tenant predicate or a tenant-scoped store. Every document needs metadata. Every tool signature that touches data needs the tenant in scope. The demo's single shared memory bean becomes a partitioned key. The search service grows a filter expression. The test suite doubles, because every existing test now has a cross-tenant sibling.
That tax is also the defense. tl;dv's failure was not a sophisticated exploit, it was one collection without a rule, and it survived for six months because a platform that protects nine collections can still be one forgotten query away from a breach. The agent equivalent is cheaper to build now than to explain later, and the explanation is always the same sentence: we just forgot.
The Checklist
- Resolve the tenant at the edge. From the session or token, once per request, never parsed out of the prompt.
- Carry it in the tool context. Same seam as the conversation id, because streaming crosses threads and thread-locals do not.
-
Namespace the memory key.
tenantId:conversationIdfor in-memory,(tenant_id, ...)columns in persistent stores. - Check ownership in the tool, not the result. Every id-based lookup verifies the caller's tenant before returning data.
- Filter the vector store. Tenant id in document metadata, tenant filter on every similarity search.
- Write the cross-tenant test first. Same conversation id, same order id, same search term, two tenants, zero leakage.
- Checklist every tool. Name each collection, state what a foreign tenant would see, and close the ones you cannot answer.
What Comes Next
Part 13 is audit trails, and the hook is the tl;dv compliance page. The writeup describes a security page decorated with SOC2, GDPR, and EU AI Act badges, and a line promising a security team that responds within 24 hours, sitting above a six-month silence. Compliance badges are not security, and for an agent, the audit log is where the two diverge: every tool call with its tenant, its arguments, and its outcome, stored in a way that cannot be quietly edited. Part 13 builds that log, because the next tl;dv will not be a Firestore collection, it will be an agent that answered with the wrong tenant's data and left no record of which tenant asked.
Where is your meetings collection? Name the one tool, store, or search that has no tenant check, and tell me how you found it. I read every response.
I write about Java, Spring Boot, and AI agents every week. Subscribe, it's free.
Bookmark this one. The day your agent serves a second tenant, this checklist is the difference between a bug and a breach.
Top comments (0)