# Building a Production AI Agent in Spring Boot: Tenant Isolation (Part 12)

> Source: <https://dev.to/jamilxt/building-a-production-ai-agent-in-spring-boot-tenant-isolation-part-12-22a2>
> Published: 2026-08-13 03:16:20+00:00

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<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.

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:

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.

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.

`tenantId:conversationId`

for in-memory, `(tenant_id, ...)`

columns in persistent stores.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.
