Protect the whole answer, including its sources.
The envelope matters as much as the letter.

The records clerk at Sachin prepares a safe packet for Maya. Its text contains only facts from folders she may read. Then someone adds a cover sheet listing every folder searched, including the restricted Engineering note.
The letter followed the rule. The envelope did not.
After Part 4, we know to check current permissions before using evidence. Here, we follow that rule through the other surfaces around an answer. A citation link, preview title, shared response, or diagnostic record can expose information independently of the main paragraph.
Where else can a restricted fact leave the room?
1The letter is allowed
Maya’s answer uses only D1 and D3. That protects this packet’s contents.
Letter → main answer2Inspect the envelope
A citation, preview, or reused answer may still reveal D2.
Envelope → surrounding response surfaces3Protect the ledger too
Keep minimal diagnostics on the server, with controlled access.
Dispatch ledger → protected audit recordsApply the reader’s permissions to every information-bearing surface.
A citation click is a new request
A citation is a reference to supporting evidence. Knowing its identifier does not grant access to that evidence.
If Maya opens /sources/D2, the server must resolve the current reader, find the source in that reader’s company, and check the current policy before returning its title or text. Arun’s earlier permission to cite it says nothing about Maya’s permission to open it now.
This is object-level authorization: checking whether the requester may perform this action on this particular record. OWASP identifies endpoints that accept object IDs without adequate checks as a route to unauthorized access. OWASP API object authorization.
Our readSource function gives Maya the same 404 status and “Source unavailable” body for a denied ID and an absent ID. Arun receives 200 for D2 while his grant remains valid. The generic response avoids explicitly confirming which of those two conditions occurred; it does not prove that timing, counts, or every other side channel reveal nothing.
A preview is information too
The folder title may itself be restricted. A heading such as an incident name can reveal something before the reader opens the document. Extracts and result counts can reveal still more.
For these fixtures, titles and text share the document’s permission. Build the preview from permitted evidence on the server. Sending every title to the browser and hiding some with CSS would already have delivered them.
The teaching view deliberately shows fictional restricted records so you can inspect the mistake. A real reader’s response should not contain the teaching view’s full collection.
Predict: with Maya selected, open D2 through the unchecked citation route. Does filtering her original answer protect this separate request? Compare the checked version, then explore the other surfaces.
Inspect the letter, envelope, and ledger.
Fictional records. Browser simulation. No live model call.
D2 citation
Maya receives{
"status": 404,
"body": {
"message": "Source unavailable"
}
}The source route checks this reader’s current access to D2.
All records and the token are fictional. Showing the unchecked path is an intentional teaching view, not a real source endpoint.
Inspect the input, rule, and output
Input / state
{
"reader": {
"id": "maya",
"name": "Maya",
"role": "Support",
"tenantId": "sachin",
"groups": [
"support"
]
},
"surface": "citation",
"checkedPath": true
}Decision rule
look up source within reader.tenantId
check current grants before returning title or text
denied or absent → identical 404 bodyOutput
{
"status": 404,
"body": {
"message": "Source unavailable"
}
}This view runs the same deterministic functions as the downloadable example. The short rule above summarizes the operation; the download contains the complete implementation.
An answer prepared for Arun is not automatically shareable with Maya
Imagine reusing a labeled envelope because it answers the same question. Arun’s envelope includes D2. Maya’s identical question does not make that content available to her.
A cache indexed only by question would mix these two permission contexts. In the experiment, the unchecked shared response reuses Arun’s fixed sentences. The checked path recomputes the response from the selected reader’s allowed evidence.
A real sharing feature needs an explicit policy: recompute for the recipient, or reauthorize every piece of evidence before releasing a stored answer. A permission-aware cache key helps separate responses, but it does not grant access to a later source request or solve revocation by itself.
Diagnostics need their own audience
The dispatch clerk keeps a ledger for investigating problems. Copying every letter, badge token, and rejected folder into that ledger creates another store of sensitive information.
The safe example records a request ID, outcome, and returned source IDs. The final service also records its policy revision. Its audit records remain on the server; they are not part of the public response.
The unsafe example includes the question, all candidate records, and a fictional session token. This is intentionally visible in the operator teaching view. OWASP’s logging guidance advises excluding sensitive values such as access tokens and restricting access to logs. Even document IDs may be sensitive in your application. OWASP logging guidance.
Our in-memory array demonstrates choosing fields and keeping them out of the response. It is not a durable audit store with retention, tamper protection, and operator access controls.
Permission and factual support are different checks
A source can be allowed yet fail to support a claim. Conversely, a highly relevant source can be denied. An authorization check answers whether this reader may receive the evidence; it does not judge whether a model used that evidence correctly.
These examples assemble fixed sentences directly from the selected records. No live model invents a claim or chooses its own citations. With a real generator, test attribution and unsupported answers separately, along with hostile instructions embedded in retrieved text. Filtering a context also cannot prove what a model knew from other inputs or prior training.
Exercise the response boundaries
Run the four safe surfaces and compare the denied and missing source responses. The assertions check that Maya’s safe outputs do not contain D2’s title, its fact, or the fictional token.
Run the same model yourself.
Node.js 20 or newer. No packages or API keys. Save the file, open a terminal in its directory, and run the command below.
Download part 5 · JavaScript ↓node sachin-permissions-5.mjsEXPECTED OUTPUT
citation: protected
preview: protected
shared-answer: protected
audit: protected
Denied and missing sources: identical response
Response checks passed.Read the complete source and assertions
// Sachin RAG permissions, part 5. Node.js 20+. Fictional fixtures; fixed answers.
// @ts-check
/** Fictional teaching fixtures. This simulation is not an authorization boundary.
* @typedef {{id: string, name: string, role: string, tenantId: string, groups: readonly string[]}} Principal
* @typedef {{id: string, title: string, team: string, tenantId: string, allowedGroups: readonly string[], allowedUsers: readonly string[], match: number, fact: string}} Evidence
*/
/** @type {readonly Principal[]} */
export const PEOPLE = [
{ id: "maya", name: "Maya", role: "Support", tenantId: "sachin", groups: ["support"] },
{ id: "arun", name: "Arun", role: "Engineering", tenantId: "sachin", groups: ["engineering"] },
];
export const QUESTION = "Why is the Orion launch delayed?";
/** @type {readonly Evidence[]} */
export const DOCUMENTS = [
{
id: "D1", title: "Customer launch update", team: "Shared", tenantId: "sachin",
allowedGroups: ["support", "engineering"], allowedUsers: [], match: 87,
fact: "Orion’s launch has moved to 14 October while the team completes reliability checks.",
},
{
id: "D2", title: "Engineering incident note", team: "Engineering", tenantId: "sachin",
allowedGroups: ["engineering"], allowedUsers: [], match: 98,
fact: "Sign-in requests time out under peak load. The identity team is fixing the session refresh path.",
},
{
id: "D3", title: "Customer support FAQ", team: "Support", tenantId: "sachin",
allowedGroups: ["support"], allowedUsers: [], match: 82,
fact: "Existing workspaces stay available. Customers do not need to take any action.",
},
{
id: "D4", title: "Launch cost review", team: "Finance", tenantId: "sachin",
allowedGroups: ["finance"], allowedUsers: [], match: 91,
fact: "The delay adds $24,000 to the internal launch budget.",
},
];
// @example:policy
/** Identity must already come from a trusted session; this function only checks permission.
* @param {Principal | null} user @param {Evidence} chunk */
export function checkAccess(user, chunk) {
const hasIdentity = user !== null;
const sameCompany = hasIdentity && user.tenantId === chunk.tenantId;
const directGrant = hasIdentity && chunk.allowedUsers.includes(user.id);
const matchingGroups = user?.groups.filter(group => chunk.allowedGroups.includes(group)) ?? [];
const groupGrant = matchingGroups.length > 0;
const allowed = sameCompany && (directGrant || groupGrant);
const reason = !hasIdentity ? "No verified identity"
: !sameCompany ? "Different company"
: directGrant ? "Direct user grant"
: groupGrant ? `${matchingGroups.join(", ")} group grants access`
: "No matching permission";
return { hasIdentity, sameCompany, directGrant, groupGrant, matchingGroups, allowed, reason };
}
// @example:end-policy
/** @param {Principal | null} user @param {readonly Evidence[]} documents */
export function retrieveEvidence(user, documents = DOCUMENTS) {
return documents
.filter(chunk => checkAccess(user, chunk).allowed)
.sort((a, b) => b.match - a.match);
}
/** @param {Principal | null} user @param {readonly Evidence[]} documents */
export function buildAnswer(user, documents = DOCUMENTS) {
const evidence = retrieveEvidence(user, documents);
const context = evidence.map(chunk => `[${chunk.id}] ${chunk.title}\n${chunk.fact}`).join("\n\n");
// These are example request messages. This lesson makes no model call.
const messages = evidence.length ? [
{ role: "system", content: "Use the supplied evidence to answer the question. Cite each source ID. If the evidence is insufficient, say so." },
{ role: "user", content: `Question: ${QUESTION}\n\nPermitted context:\n${context}` },
] : [];
return {
evidence,
context,
messages,
// Assemble fixed facts to make the experiment reproducible, without a model.
sentences: evidence.map(({ id, fact }) => ({ sourceId: id, text: fact })),
emptyMessage: evidence.length ? null : "I don’t have accessible evidence to answer this question.",
};
}
/** Fictional, deterministic teaching models shared by the diagrams and downloads.
* @typedef {import('./rag-permissions.mjs').Principal} Principal
* @typedef {import('./rag-permissions.mjs').Evidence} Evidence
*/
export function hasPolicy(record) {
const ids = value => Array.isArray(value) && value.every(id => typeof id === "string" && id.length > 0);
return typeof record?.tenantId === "string" && record.tenantId.length > 0
&& ids(record.allowedGroups) && ids(record.allowedUsers);
}
export function ingestDocument(document, paragraphs, aclVersion = 1) {
if (!hasPolicy(document)) throw new Error("Missing or invalid permission metadata");
if (!Number.isInteger(aclVersion) || aclVersion < 1) throw new Error("Invalid policy version");
if (!Array.isArray(paragraphs) || paragraphs.some(text => typeof text !== "string" || !text.trim())) {
throw new Error("Chunks must contain nonempty text");
}
return paragraphs.map((fact, index) => ({
...document, id: `${document.id}.${index + 1}`, parentId: document.id,
fact, aclVersion, allowedGroups: [...document.allowedGroups], allowedUsers: [...document.allowedUsers],
}));
}
// Ten fictional passages extend the same four documents. Scores are illustrative, not vector distances.
/** @type {Array<[string, number, number, string]>} */
const rankingRows = [
["D2", 1, 98, "Sign-in requests time out under peak load."],
["D4", 1, 96, "The delay adds $24,000 to the internal launch budget."],
["D1", 1, 94, "Orion’s launch has moved to 14 October."],
["D2", 2, 92, "The identity team is fixing the session refresh path."],
["D3", 1, 90, "Existing workspaces stay available."],
["D1", 2, 88, "The team is completing reliability checks."],
["D3", 2, 86, "Customers do not need to take any action."],
["D1", 3, 84, "The launch update will be revised after the next readiness review."],
["D4", 2, 82, "Finance will reconcile launch costs after release."],
["D3", 3, 80, "Support will share the next approved launch update."],
];
export const RANKED_CHUNKS = rankingRows.map(([parentId, ordinal, match, fact]) => {
const parent = DOCUMENTS.find(document => document.id === parentId);
if (!parent) throw new Error("Unknown parent document");
return { ...parent, id: `${parentId}.${ordinal}`, parentId, match, fact, aclVersion: 1 };
});
/** Exact ranked-list experiment; it does not simulate an ANN graph. */
export function compareRetrieval(user = PEOPLE[0], k = 5, fetchCount = 5) {
const ranked = [...RANKED_CHUNKS].sort((a, b) => b.match - a.match);
const isAllowed = chunk => hasPolicy(chunk) && checkAccess(user, chunk).allowed;
const eligible = ranked.filter(isAllowed);
const ideal = eligible.slice(0, k);
const fetched = ranked.slice(0, fetchCount);
const after = fetched.filter(isAllowed).slice(0, k);
const recovered = after.filter(chunk => ideal.some(target => target.id === chunk.id)).length;
return { ranked, eligible, ideal, fetched, after, missed: ideal.filter(chunk => !after.some(hit => hit.id === chunk.id)),
recall: ideal.length ? recovered / ideal.length : null };
}
/** Each stage is a new snapshot; moving backwards never mutates the fixtures. */
export function revocationSnapshot(stage = 0, enforceCurrent = true) {
const original = DOCUMENTS[1];
const source = { ...original, allowedGroups: stage >= 1 ? [] : [...original.allowedGroups], aclVersion: stage >= 1 ? 2 : 1 };
const indexed = stage >= 2 ? { ...source } : { ...original, aclVersion: 1 };
const indexAllows = checkAccess(PEOPLE[1], indexed).allowed;
const currentAllows = checkAccess(PEOPLE[1], source).allowed;
const oldCacheExists = stage < 3;
const cacheVersion = 1;
const cacheUsable = oldCacheExists && cacheVersion === source.aclVersion && currentAllows;
return { source, indexed, indexAllows, currentAllows, oldCacheExists, cacheUsable,
contextIncludesD2: indexAllows && (!enforceCurrent || currentAllows),
cacheVersion, policyVersion: source.aclVersion };
}
/** Source IDs are looked up and authorized again on every source request. */
export function readSource(user, id, documents = DOCUMENTS) {
const document = documents.find(item => item.id === id && item.tenantId === user?.tenantId);
if (!document || !hasPolicy(document) || !checkAccess(user, document).allowed) {
return { status: 404, body: { message: "Source unavailable" } };
}
return { status: 200, body: { id: document.id, title: document.title, text: document.fact } };
}
export function inspectSurface(personId = "maya", surface = "citation", enforce = true) {
const person = PEOPLE.find(user => user.id === personId) ?? PEOPLE[0];
const permitted = buildAnswer(person);
const restricted = DOCUMENTS[1];
const safe = surface === "citation" ? readSource(person, "D2")
: surface === "preview" ? { status: 200, body: permitted.evidence.map(({ id, title }) => ({ id, title })) }
: surface === "shared-answer" ? { status: 200, body: permitted.sentences }
: { status: 200, body: { requestId: "demo-1", outcome: "allowed", returnedIds: permitted.evidence.map(chunk => chunk.id) } };
const unsafe = surface === "citation" ? { status: 200, body: { id: restricted.id, title: restricted.title, text: restricted.fact } }
: surface === "preview" ? { status: 200, body: DOCUMENTS.map(({ id, title }) => ({ id, title })) }
: surface === "shared-answer" ? { status: 200, body: buildAnswer(PEOPLE[1]).sentences }
: { status: 200, body: { question: QUESTION, candidates: DOCUMENTS, sessionToken: "fictional-demo-token" } };
return { person, response: enforce ? safe : unsafe, protected: enforce };
}
export function createServiceState() {
return {
// Public exercise credentials, not secrets or a real login system.
sessions: { "support-demo": structuredClone(PEOPLE[0]), "engineering-demo": structuredClone(PEOPLE[1]),
"other-tenant-demo": { ...structuredClone(PEOPLE[0]), tenantId: "other" } },
documents: structuredClone(DOCUMENTS), policyVersion: 1, policyAvailable: true,
cache: new Map(), audit: [],
};
}
export function revokeD2(state) {
const document = state.documents.find(item => item.id === "D2");
document.allowedGroups = [];
document.allowedUsers = [];
state.policyVersion += 1;
}
export function prepareServiceRequest(state, token, body = {}) {
const user = Object.hasOwn(state.sessions, token) ? structuredClone(state.sessions[token]) : null;
const base = { user, sessionKey: token, evidence: [], version: state.policyVersion, key: "", query: "", trace: ["Resolve identity from the server’s session table"] };
if (!user) return { ...base, error: 401, message: "Sign in required" };
if (typeof body?.question !== "string" || body.question.trim() !== QUESTION) {
return { ...base, error: 400, message: "This exercise supports the Orion launch question only" };
}
if (!state.policyAvailable) return { ...base, error: 503, message: "Permission check unavailable" };
if (state.documents.some(document => !hasPolicy(document))) {
return { ...base, error: 503, message: "Permission metadata unavailable" };
}
// Body-supplied groups and tenantId never participate in the decision.
const evidence = buildAnswer(user, state.documents).evidence;
const key = JSON.stringify(["fixed-generator-v1", state.policyVersion, user.tenantId, user.id, [...user.groups].sort(), QUESTION]);
return { ...base, error: 0, message: "", evidence, query: QUESTION, key,
trace: [...base.trace, "Ignore client permission claims", "Check tenant and current document grants", "Prepare permitted evidence"] };
}
export function finishServiceRequest(state, plan) {
const reply = (status, body, cacheHit = false) => {
// This audit record stays on the server. No token, prompt, title, or document body.
state.audit.push({ requestId: `request-${state.audit.length + 1}`, status,
policyVersion: state.policyVersion, returnedIds: status === 200 ? (body.sources ?? []).map(source => source.id) : [] });
return { status, body, cacheHit, trace: plan.trace };
};
if (plan.error) return reply(plan.error, { message: plan.message });
if (!state.policyAvailable) return reply(503, { message: "Permission check unavailable" });
if (plan.version !== state.policyVersion) return reply(409, { message: "Permissions changed; retry the request" });
// Recheck current membership too. The version check covers policy changes through revokeD2.
const current = Object.hasOwn(state.sessions, plan.sessionKey) ? state.sessions[plan.sessionKey] : null;
if (!current || current.id !== plan.user.id || current.tenantId !== plan.user.tenantId
|| JSON.stringify([...current.groups].sort()) !== JSON.stringify([...plan.user.groups].sort())
|| plan.evidence.some(chunk => {
const source = state.documents.find(document => document.id === chunk.id);
return !source || !hasPolicy(source) || !checkAccess(current, source).allowed;
})) return reply(409, { message: "Permissions changed; retry the request" });
if (!plan.evidence.length) return reply(200, { answer: "I don’t have accessible evidence to answer this question.", sources: [], modelCalled: false });
const cached = state.cache.get(plan.key);
// Compare dependencies too: a missed revision update must not restore an excluded source.
const selectedIds = plan.evidence.map(chunk => chunk.id);
if (cached && JSON.stringify(cached.sources.map(source => source.id)) === JSON.stringify(selectedIds)) {
return reply(200, structuredClone(cached), true);
}
// Deterministic generation seam: a real model integration needs separate output evaluation.
const response = { answer: plan.evidence.map(chunk => `${chunk.fact} [${chunk.id}]`).join("\n\n"),
sources: plan.evidence.map(({ id, title }) => ({ id, title })), modelCalled: false };
state.cache.set(plan.key, structuredClone(response));
return reply(200, response);
}
export function runServiceScenario(scenario = "maya") {
const state = createServiceState();
let token = scenario === "arun" || scenario === "in-flight" || scenario === "revoked" ? "engineering-demo" : "support-demo";
if (scenario === "missing-session") token = "unknown";
if (scenario === "other-tenant") token = "other-tenant-demo";
if (scenario === "outage") state.policyAvailable = false;
const body = { question: QUESTION, ...(scenario === "forged-groups" ? { groups: ["engineering"], tenantId: "other" } : {}) };
if (scenario === "revoked") {
finishServiceRequest(state, prepareServiceRequest(state, token, body));
revokeD2(state);
}
const plan = prepareServiceRequest(state, token, body);
if (scenario === "in-flight") revokeD2(state);
const response = finishServiceRequest(state, plan);
return { body, plan, response, audit: state.audit, cacheEntries: state.cache.size, policyVersion: state.policyVersion };
}
import assert from "node:assert/strict";
for (const surface of ["citation", "preview", "shared-answer", "audit"]) {
const safe = JSON.stringify(inspectSurface("maya", surface, true).response);
assert.ok(!safe.includes(DOCUMENTS[1].fact));
assert.ok(!safe.includes(DOCUMENTS[1].title));
assert.ok(!safe.includes("fictional-demo-token"));
console.log(surface + ": protected");
}
assert.deepEqual(readSource(PEOPLE[0], "D2"), readSource(PEOPLE[0], "missing"));
assert.equal(readSource(PEOPLE[1], "D2").status, 200);
console.log("Denied and missing sources: identical response");
console.log("Response checks passed.");
Then try Arun: D2 should open. Remove D2’s grants in a fresh document collection and call readSource again with that collection. The new request should be denied even though the old citation still exists.
You now have the rules for the badge, copies, search, freshness, and dispatch. Connect them in Part 6: build it, then try to break it.