An employee asks the company assistant for a pricing summary. The retrieval service checks their access, retrieves the right documents and produces a useful answer. The application caches it.
Later, the employee moves to another account. Their access to the original pricing documents is revoked. They ask the same question. The cache returns the old answer without calling retrieval.
The document store denied access correctly. The assistant still disclosed the information.
This is a design failure scenario, not a claim about a named breach. It exposes a boundary that production AI teams can miss when they treat caching as a performance detail. An answer derived from restricted documents is restricted data too. Moving it into Redis, a conversation store or a semantic cache does not remove that obligation.
I would block a production launch that tests document authorization but never tests an answer-cache hit after revocation.
The retrieval filter did its job
Microsoft’s Azure AI Search security-trimming example associates documents with security identifiers and applies a query filter. Its distinction matters: the example’s identity strings participate in a filter; they are not themselves authentication or authorization. A trusted application must establish which identities belong in the request.
That addresses the retrieval boundary. It does not establish that your separate answer cache re-evaluates the same policy. When the application returns a stored answer directly, no search request runs.
Database controls have a similar scope. PostgreSQL’s row-security documentation describes policies that restrict rows for database operations. It also documents exceptions, including table owners normally bypassing row security and roles with BYPASSRLS. Even correctly configured row security cannot reach backwards into text your application already copied into another store.
The engineering question is therefore specific: what checks the permission to disclose this answer, to this caller, now?
Put a release gate after both paths
A common cache key contains the question, model version and prompt version. A more careful one includes the tenant. That prevents some cross-tenant reuse, but it says nothing about two employees with different access inside the same tenant.
Adding the user identifier narrows reuse further. It still does not handle that user’s permissions changing.
For a conservative starting design, bind a cached answer to the tenant, principal, an authorization epoch and a dependency manifest. The epoch is a monotonic policy version maintained by your authorization system, not a timestamp invented by the model. The manifest identifies the restricted documents used to generate the answer. Track document revisions separately when freshness matters.
Before returning either a cache hit or a newly generated answer:
- Resolve the caller from authenticated server-side context. Never accept the tenant or group list merely because the client supplied it.
- Obtain current policy state through the system’s defined consistency mechanism.
- Reject an answer bound to another tenant or principal.
- Reject a stale policy epoch and regenerate under current permissions.
- Check every dependency against the allowed set. Missing provenance is a reason to deny reuse, not an invitation to ask the model whether the answer seems sensitive.
This design sacrifices cache hits. That is an acceptable starting point. A shared cache keyed by a proven access-equivalence class may be worthwhile later, but then the correctness of that class becomes part of your security argument.
The dependency manifest must cover the actual generation inputs, not only the citations the model chose to display. If a restricted passage influenced a summary, deleting its citation does not remove its influence.
Run the failure before debating the architecture
The downloadable Python fixture uses only the standard library and makes no network calls or persistent writes:
python3 rag-cache-revocation.py
It contains a deliberately unsafe dictionary cache and a conservative answer-release function. The unsafe path demonstrates that revoking document access leaves the previously cached text available. The guarded path checks caller binding, policy epoch and source permissions before returning text.
The suite covers an authorized hit, the naive-cache disclosure, a revoked hit, another tenant, revocation between retrieval and release, a policy outage and missing provenance. The unsafe-cache test passes when it reproduces the disclosure. That is evidence of the failure mode, not evidence that the naive design is safe.
This is an executable teaching model. It is not a Redis integration test, a distributed authorization service or proof that your own application is protected. Run the same transitions through the real endpoint, with the real cache populated, before treating the control as deployed.
A useful acceptance test starts with a successful answer, confirms a cache hit, revokes access through the authoritative policy system, and repeats the request. Record when the revocation was acknowledged and when each answer was released. Then repeat with the policy service unavailable. A silent fallback to an old answer defeats the control you are trying to test.
Define what “revoked” means during generation
A check before retrieval leaves a window while the model generates. A check immediately before release narrows that window. It does not magically make a distributed system atomic.
The fixture is sequential. A real service needs an explicit contract between policy changes and answer release. You might use short-lived authorization leases with a documented maximum stale-access window, or a coordinated release mechanism that serializes admission against revocation. These have different latency and availability costs. State the chosen guarantee rather than describing either as “instant revocation”.
Streaming makes the promise harder. Once a token reaches the client, revoking access cannot recall it. If the data class requires approval against current policy before any disclosure, buffer the answer and validate before sending it. If you allow streaming under a lease, specify when a revocation stops subsequent output and measure that bound. Do not claim that a final check protects tokens already sent.
Likewise, revocation can stop future server disclosures. It cannot erase a response the employee already downloaded or copied while authorized. Keep that limit separate from avoidable new disclosures through your cache.
Test the other stores that contain the answer
The answer cache is only the first place to inspect. Conversation history may reintroduce restricted text into a later prompt. A summary of that history can retain the same information after the original message disappears. Debug traces and exported review packets may create additional copies with different readers.
Write down each store, its reader authorization and its retention rule. Decide whether revocation invalidates the material, blocks access to it or forces regeneration. Avoid logging the restricted answer while investigating its disclosure; identifiers, policy versions and decision outcomes are often sufficient for the first diagnostic pass.
The trade-off should be visible in operations. Track denied stale hits separately from ordinary misses. Measure the time from policy acknowledgement to enforced denial at the answer endpoint. Alert on any fallback that serves a restricted answer when policy verification fails. A high cache hit rate is not a useful success metric if it includes responses the caller can no longer receive.
For a related boundary, see why stopping Claude Code does not cancel queued MCP work. Both problems require checking authority where the consequence occurs, rather than trusting an earlier decision indefinitely.
For the broader evaluation workflow, start with AI Agent Evals. Make the revocation transition part of the release gate, not a security ticket discovered after the performance work is finished.