RAG & PERMISSIONS / 01

Same question.
Different permissions.

By Sachin Gupta7 min read

At fictional company Sachin, Maya helps customers and Arun builds the product. Both ask about the same launch. They’re allowed to see different evidence.

Learn why a perfect match can still be excluded from an answer, then trace that decision in code.

The asker is part of the question.

Portrait of Sachin Gupta rendered in binary
START WITH SOMETHING FAMILIAR

Imagine a records desk at Sachin.

Maya asks the clerk: “Why is the Orion launch delayed?” Follow what happens before the writer gets any notes.

1

First, check the badge.

The clerk checks that the badge belongs to Maya and looks up her team: Support. Being an employee does not give her access to every folder.

BADGE → VERIFIED IDENTITY

Authentication establishes who is asking. In a deployed application, the server uses a verified session and trusted group membership. Authentication and authorization.

2

Read the folder’s rules.

The engineering report, D2, is a great match for the question. But its access list allows Engineering. Maya has Support access, so the clerk leaves D2 out.

FOLDER LABELS → PERMISSIONS

Authorization determines what Maya may read. Retrieval finds source material relevant to the question. A document can be relevant and still be forbidden. How permission filters use identity.

3

Give the writer a packet.

The clerk puts extracts from D1 and D3 into Maya’s briefing packet. The writer uses those notes to prepare her answer. D2 never enters the packet.

EXTRACTS → CHUNKS · PACKET → CONTEXT

A chunk is a piece of source text, so retrieval can select part of a long document. Selected chunks become evidence in the model’s context, its input alongside the question. Generation is writing the response. Why documents are split into chunks.

NOW NAME THE IDEA

The question alone does not supply Sachin’s launch details. The packet does. Retrieval-augmented generation (RAG) means finding source material, adding it to the model’s input, and using it to help generate an answer. The packet supplies the material; the writer represents the language model. Read the RAG explanation.

The analogy has a limit: a model is not a trusted records clerk. The application must enforce access. This page simulates that rule and assembles fixed answers; it does not call a model or guarantee how a real model would respond.

YOUR TURN · PREDICT, THEN TRY

Arun walks up to the same desk.

His badge says Engineering. The question and folder rules stay the same. Which document can he use that Maya cannot?

Choose a prediction, then check it against the live example below.

THE SAME DESK, EXPRESSED AS SOFTWARE

“Why is the Orion launch delayed?”

Try changing the asker
1 KNOW THE ASKER
sachinEMPLOYEE ID

Maya

Support

GROUPsupport

The badge becomes a trusted identity. The server verifies who is asking and obtains their groups.

2 CHECK THE EVIDENCE

Sachin’s knowledge base

4 DOCUMENTS

The folder labels become permission data. Match scores describe relevance; the access rule decides eligibility.

D2: Best match. Still off-limits.

No matching permission. This chunk stays out of the answer’s context.

Click a document to read its chunk and inspect the rule.

3 PREPARE CONTEXT & ANSWER
BRIEFING PACKET → CONTEXT

One extract per document in this example. Inspect a source to read its text.

EXAMPLE ANSWER FOR

MayaSupport

Orion’s launch has moved to 14 October while the team completes reliability checks.

Existing workspaces stay available. Customers do not need to take any action.

2 permitted sources · 2 excluded

This example assembles fixed facts from permitted evidence. Each statement points to its source.

Teaching view · fictional documents · illustrative match scores

Maya can use D1 and D3. Change the asker to compare.

!

A great match is not a permission slip.

The clerk’s decision becomes a rule in our application:

same company AND (a direct user grant OR a matching group)

A direct grant names the person; a group grant covers members of that group. Maya and D2 belong to Sachin, but D2 neither names Maya nor allows Support. The result is deny. Arun’s Engineering group matches, so his result is allow. This is the example’s policy, not a universal permission model.

Why check before handing over the packet? Putting D2 in the packet and telling the writer to keep it secret still gives the writer D2. The application must keep denied text out of that person’s context; an instruction in the prompt does not grant or enforce access.

CHECK YOUR INTUITION

What if the match were 100%?

Maya has Support access only. The Engineering incident note is a perfect match for her question. Can it enter her answer’s context?

Connect the story to the code.

Open a document’s fields and follow the rule into the prepared context. Then run the same example yourself.

Build this same exampleOne file · Node.js · no packages

The download contains the same documents and permission functions used by this page, followed by executable checks. With Node.js 20 or later, save the file and run:

node sachin-permissions-1.mjs

Expected output

Maya: D1, D3
Arun: D2, D1
Maya (no groups): (none)
All permission checks passed.

Change it and explain the result

Add "maya" to D2’s allowedUsers. Predict whether Maya can now use D2 without Engineering group membership. Run the file. Its assertions will flag the changed result. Update both Maya cases: her usual sources now include D2, and her no-groups case now allows D2 through the direct grant.

Try changing D2’s company too. A direct grant still requires a matching company.

Read the complete runnable file
// @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.",
  };
}

// Run with: node sachin-permissions.mjs
// No packages, keys, network requests, or model calls are needed.
import assert from "node:assert/strict";

const [maya, arun] = PEOPLE;
const cases = [
  { label: "Maya", user: maya, expected: ["D1", "D3"] },
  { label: "Arun", user: arun, expected: ["D2", "D1"] },
  { label: "Maya (no groups)", user: { ...maya, groups: [] }, expected: [] },
];

for (const { label, user, expected } of cases) {
  const answer = buildAnswer(user);
  const ids = answer.evidence.map(chunk => chunk.id);
  assert.deepEqual(ids, expected);
  for (const chunk of DOCUMENTS) {
    if (!checkAccess(user, chunk).allowed) {
      assert.ok(!JSON.stringify(answer.messages).includes(chunk.fact));
    }
  }
  console.log(label + ": " + (ids.join(", ") || "(none)"));
}

assert.equal(checkAccess(null, DOCUMENTS[0]).allowed, false);
const foreignChunk = { ...DOCUMENTS[0], tenantId: "another-company", allowedUsers: [maya.id] };
assert.equal(checkAccess(maya, foreignChunk).allowed, false);
assert.deepEqual(buildAnswer({ ...maya, groups: [] }).messages, []);
console.log("All permission checks passed.");

// Explore the exact example request:
// console.log(JSON.stringify(buildAnswer(maya).messages, null, 2));

This is a local permission model and prompt-assembly example. For a deployed system, replace the fictional identity and permission data with trusted server-side data, then integrate retrieval and generation. Search indexes and permission updates need their own implementation work.

Why trusted identity matters: Microsoft’s security filter explanation distinguishes filtering identifier strings from authenticating the person behind them.

YOU NOW KNOW

Who is asking determines which evidence is eligible.

This lesson uses a simple group/direct-grant policy and deterministic answer assembly. Next, make permissions travel with every document copy.

Related

Tagged