Building a Production AI Agent in Spring Boot: Tenant Isolation (Part 12) A security researcher reported that an AI meeting recorder with over two million users stored meeting records in a Firestore collection without tenant isolation, allowing him to query 181,874 records and join live meetings uninvited. The company disputes the timeline, claiming two distinct vulnerabilities were fixed, but its CTO acknowledged a communication gap. In response, a developer at BS23 demonstrates how to apply tenant isolation to a production AI agent built with Spring Boot and Spring AI, emphasizing that tenant identity must be resolved at the request edge, not from user messages. 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 https://bobdahacker.com/blog/tldv-hack , 626 points on HN https://news.ycombinator.com/item?id=49242739 . 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 https://tldv.io/blog/our-thoughts-on-the-darkreading-com-article/ . 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. 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: php .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. 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. 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. 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. 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