Building practical AI
Deepak Kumar Tiwari

Deepak Kumar Tiwari

Technology & AI Leader · Building practical systems

Agent failure labs / Guide 71

AI Agent Data Access Control Before Retrieval

Build AI agent data access control that rejects cross-tenant retrieval before private text reaches a model, with a runnable failure lab and rollout checks.

Illustrative engineering guide. Examples are not client deployments, production-ready integrations, or evidence of measured results. Validate the design with your domain and security owners.

Reference workflow: Authenticated principal; Scoped candidate lookup; Current permission check; Authorized text projection; Restricted answer and audit.
Reference workflow. Steps identify responsibilities, not necessarily separate agents. Branches and recovery paths are explained below. Open full-size diagram.

A hypothetical account manager asks an assistant to summarize renewal risks for customer Cedar. Search finds a document called Cedar Renewal, but it belongs to another tenant with an identically named customer. The assistant produces a persuasive summary containing that tenant’s confidential discount. The failure happened before the answer: private text crossed into model context without an authorization decision.

This lab isolates that boundary. It is not a general employee provisioning workflow or an experiment conducted against an actual LLM. A deterministic simulation shows how trusting a requested document identifier can disclose the wrong record, then reruns the identical request through a server-owned permission check. No network, database, or real private document is involved.

Separate Relevance From Permission

Relevance answers whether a document might help. Authorization answers whether this principal may read this document now. A high similarity score, a familiar customer name, and a valid session establish neither document ownership nor permission. OWASP’s authorization guidance recommends least privilege, denial by default, and permission validation on every request. Those checks belong outside the model.

Start with authenticated subject and tenant identifiers obtained from the server session. Accept candidate IDs from search, but resolve their access attributes from a trusted catalog. Check tenant, operation, relationship, and current permission version before projecting text. Return only authorized fields. Search snippets, titles, cached summaries, embeddings, and source download links need the same boundary; protecting the final answer alone leaves earlier disclosures intact.

The narrower question here is runtime access, not who grants access. The employee access request guide covers the provisioning lifecycle. Its approved grant becomes one input to this check, not a permanent exception. A transferred employee or a revoked contractor must lose retrieval access even if an old conversation still mentions the document.

Reproduce the Disclosure

The unsafe function below accepts the model’s selected identifier as sufficient authority. Both functions receive the same principal and request. The fixed function treats identity records and catalog contents as trusted fixture inputs, while the requested tenant remains untrusted. Assertions prove the disclosure, the denial, and a permitted control case.

from copy import deepcopy

catalog = {
    "cedar-a": {"tenant": "a", "readers": {"sam"},
                "text": "Tenant A renewal note", "epoch": 7},
    "cedar-b": {"tenant": "b", "readers": {"sam"},
                "text": "Tenant B confidential discount", "epoch": 7},
}
principal = {"subject": "sam", "tenant": "a", "epoch": 7}
request = {"document": "cedar-b", "tenant": "b"}

def naive(user, proposed, records):
    return records[proposed["document"]]["text"]

def guarded(user, proposed, records):
    row = records.get(proposed.get("document"))
    if not row or not user:
        return {"state": "denied"}
    if (row["tenant"] != user["tenant"]
            or user["subject"] not in row["readers"]
            or row["epoch"] != user["epoch"]):
        return {"state": "denied"}
    return {"state": "allowed", "text": row["text"]}

assert "confidential" in naive(principal, request, catalog)
assert guarded(principal, request, catalog) == {"state": "denied"}
own = {"document": "cedar-a", "tenant": "b"}
assert guarded(principal, own, catalog)["text"] == "Tenant A renewal note"
revoked = deepcopy(catalog)
revoked["cedar-a"]["readers"] = set()
assert guarded(principal, own, revoked) == {"state": "denied"}
assert guarded(dict(principal, epoch=6), own, catalog)["state"] == "denied"
assert guarded(principal, {"document": "missing"}, catalog)["state"] == "denied"
print("cross-tenant disclosure reproduced; guarded cases passed")

The matching subject name in both tenants is intentional. An implementation checking only membership would still fail. The misleading tenant inside the permitted request also matters: authorization derives tenant scope from the authenticated principal, not a field that the model can edit. Unknown and forbidden documents produce the same public denial shape to avoid an obvious existence oracle.

Why a Prompt Cannot Repair This

An instruction saying “never reveal another customer’s information” requires the model to infer ownership from text it should never have received. It also conflicts with malicious instructions embedded in that text. OWASP’s prompt injection guidance describes indirect instructions in retrieved documents and recommends scoped tool access. The companion document injection lab exercises that second boundary after retrieval permission has been established.

Keep a read-only retrieval adapter rather than adding an authorization sub-agent. A specialist becomes useful only when it interprets heterogeneous access evidence for an administrator; it must not invent grants. MCP can expose the adapter, but MCP is an integration protocol, not authorization. The service still authenticates the caller and checks each object.

Production Preconditions and Recovery

The sample omits the authentication source, database transactions, provider semantics, approvals, and state persistence. Its epoch comparison illustrates freshness, not a deployable revocation protocol. Production must define how identity and resource policy versions advance together, how stale caches are invalidated, and what happens while the policy store is unavailable. Default to no retrieval rather than silently reusing an expired allow decision.

PostgreSQL row security can restrict visible rows, but superusers, bypass roles, and normally table owners have exceptions. Test with the actual application role, not an administrative console. Application checks and database policy should agree; their agreement is still conditional on correct tenant labels and membership data. A misclassified document can pass every deterministic check.

Read requests do not require approval merely because a model initiated them. Sensitive exports may require separate approval bound to the selected records and destination. Keep selection, permission, freshness, approval, export side effects, and receipt reconciliation separate. If an export times out, its outcome is unknown; inspect the export service using the original operation identity before retrying. Never interpret missing acknowledgment as proof that nothing left the system.

Acceptance Tests

  1. Submit a valid document ID from another tenant and verify that neither text nor title reaches model context or ordinary logs.
  2. Revoke membership between candidate search and text projection; the projection must deny access using current policy.
  3. Reuse a cached answer under another principal and verify that cache scope prevents disclosure across the boundary.
  4. Make the policy store unavailable and confirm a bounded unavailable response, without a fallback to unrestricted search.
  5. Request an unknown ID and a forbidden ID; compare public responses for unintended existence disclosures.
  6. Use the production database role against protected rows and verify that owner or bypass privileges are absent.

Measure Before Expanding Scope

Use the unsafe adapter as an offline baseline only. Measure unauthorized text projections divided by all deliberately unauthorized retrieval attempts, and authorized retrieval completion divided by all valid requests. Report stale-policy denials separately so a secure but unusable configuration cannot hide behind a zero-leakage figure. The proposed guardrail is zero unauthorized projections in the release fixture, not a claim of universal security.

Begin with one tenant-scoped collection and shadow decisions against a reviewed permission matrix. Inspect disagreements before enabling answers. Log decision codes and opaque record references, not document bodies or authentication tokens. The first implementation step is a test proving that denied text never enters the prompt builder. AI systems engineering can then extend the boundary across retrieval, caching, exports, and operational review without changing that invariant.

Sources