Tenant Isolation for Agents Over Shared Data

In a multi-tenant agent, the tenant boundary must be applied server-side in the retrieval request and in IAM — never by a filter the model is asked to include.

Most production agents serve more than one customer over shared infrastructure: one Bedrock agent, one knowledge base, one vector index, many tenants. Tenant isolation is the guarantee that a session belonging to tenant A can never surface tenant B's data, no matter what the model is persuaded to do. It is the highest-severity failure in agentic systems, because a single leaked chunk is a reportable breach.

Silo, pool, or bridge

The strongest model is a silo: a separate knowledge base, vector index and data store per tenant, with a separate IAM role. Isolation is structural — a misconfigured filter cannot cross a boundary that does not exist — and each tenant can have its own KMS key, which lets you cryptographically revoke access. The cost is per-tenant resource limits and operational overhead, so silos suit regulated or enterprise tiers.

The pooled model shares one index and separates tenants by a metadata attribute. It is cheap and scales to thousands of tenants, but isolation now depends entirely on every query carrying the right filter. A bridge model mixes the two: pooled by default, siloed for tenants who pay for it.

Enforcing the filter server-side

In a pooled Amazon Bedrock Knowledge Base you attach metadata to each ingested document (in Amazon S3, via a .metadata.json sidecar file alongside the object) and filter at query time using retrievalConfiguration.vectorSearchConfiguration.filter with operators such as equals, in and andAll. The critical rule: that filter must be constructed by your backend from the verified session identity, immediately before the Retrieve or RetrieveAndGenerate call. It must not be a parameter the model supplies, must not come from the client, and must never rely on implicit filtering — the feature where the model infers filters from the wording of the question. Implicit filtering is a relevance improvement, not a security boundary; the model choosing your tenant predicate is exactly the thing you are defending against.

Back the filter with IAM so a code path that forgets it still fails. Assume a role with a session tag carrying the tenant identifier and use attribute-based conditions — dynamodb:LeadingKeys against ${aws:PrincipalTag/tenant_id}, an S3 prefix condition, or per-tenant keys with a KMS grant. Two independent mechanisms have to fail before data crosses.

The parts people forget

  • Memory and session state. Conversation history, summaries and any long-term memory store are tenant data. Namespace them by tenant and user, and never reuse a session identifier across tenants.
  • Caches. A semantic or prompt cache keyed only on the question text will happily serve tenant A's answer to tenant B. Include the tenant in the cache key, or do not cache retrieved content.
  • Sub-agents and shared tools. A sub-agent invoked mid-chain must receive the same verified tenant context, not re-derive it from conversation text.
  • Logs and traces. Prompt and completion logs contain tenant data; restrict and encrypt them like the source.
  • Errors. Return "not found", not "forbidden", so the agent cannot be used to probe whether another tenant's record exists.

Test it as an adversary would: run a session as tenant A and ask directly for tenant B's records, then plant a document in tenant A's corpus instructing the agent to widen its search. If either produces cross-tenant data, the boundary was in the prompt.

Example

Example · python
import boto3

agent_rt = boto3.client("bedrock-agent-runtime")

def answer(question: str, claims: dict) -> dict:
    """claims come from a verified JWT in the API layer - not from the model,
    not from the request body."""
    tenant_id = claims["custom:tenant_id"]

    return agent_rt.retrieve_and_generate(
        input={"text": question},
        retrieveAndGenerateConfiguration={
            "type": "KNOWLEDGE_BASE",
            "knowledgeBaseConfiguration": {
                "knowledgeBaseId": "KB123456",
                "modelArn": "arn:aws:bedrock:eu-west-1::foundation-model/"
                            "anthropic.claude-3-5-sonnet-20241022-v2:0",
                "retrievalConfiguration": {
                    "vectorSearchConfiguration": {
                        "numberOfResults": 8,
                        # Built here, server side, on every single call.
                        "filter": {"andAll": [
                            {"equals": {"key": "tenant_id", "value": tenant_id}},
                            {"in": {"key": "classification",
                                    "value": claims["clearance_levels"]}},
                        ]}
                    }
                },
            },
        },
        # Never reuse a session id across tenants.
        sessionId=f"{tenant_id}:{claims['sub']}:{claims['sid']}",
    )

# Ingestion side: s3://kb-docs/acme/handbook.pdf.metadata.json
# {"metadataAttributes": {"tenant_id": "acme", "classification": "internal"}}

When to use it

  • A SaaS support agent serves thousands of tenants from one knowledge base, applying a tenant_id equals filter built from the caller's JWT on every retrieval.
  • A regulated enterprise customer is moved to a siloed knowledge base with its own KMS key so access can be revoked cryptographically.
  • A penetration test plants a document telling the agent to search all tenants, and the request fails at the IAM condition rather than at the prompt.

Discussion

  • Be the first to comment on this lesson.