Chapter 119 · Guardrails and security

Permissions enforced at query time, not in the prompt

Four places a permission check can live, three of which are decorative. The query-time filter, the index that makes it affordable, and the test that proves it rather than the helper.

8 min read

Chapter 45 built the permission-aware retriever and argued the filter belongs in the query. This chapter is the security case for the same decision, which is stronger than the quality case and worth making separately.

Four places the check can live

WhereData the user cannot see is...Fails when
1. In the queryNever retrievedThe filter is wrong
2. Post-filter in codeRetrieved, then discardedA code path forgets; it is in logs and traces
3. In the promptSent to the modelAlways — the model decides
4. Filter the answerAlready usedAlways, and silently

Three and four are the ones to argue about, because they look reasonable in a design document.

// Option 3, which gets written surprisingly often. const prompt = ` The user is ${user.name}, who may see: ${user.regions.join(', ')}. Some documents below are outside their permissions. Do not use those. `

It fails for the reason chapter 118 gave: it depends on the model declining. The restricted content is in the context, so it is in the request to the provider, in the trace, in any cached prompt, and available to any injection that succeeds. Even when the model complies perfectly, the data has already left the boundary it was supposed to stay inside.

Option 4 is worse, because the restricted content has shaped the answer even if no sentence quotes it. An answer that says "the average margin across regions is 14%" discloses something about regions the asker cannot see, and no output filter catches it.

If restricted data reaches the model's context, it has already been disclosed. Everything after that is damage limitation.

The filter in the query

-- One statement, no application-side filtering, no post-processing step -- that can be forgotten. select c.id, c.content, c.metadata, 1 - (c.embedding <=> $1) as score from chunks c join documents d on d.id = c.document_id where d.acl_group = any($2::text[]) -- the user's groups and (d.classification <> 'restricted' or $3) -- explicit elevation and d.deleted_at is null order by c.embedding <=> $1 limit $4;
// The groups come from the identity provider on every request, never from // a session cache and never from anything the model produced. Chapter 14's // mapping of the existing permission model is what populates them. const groups = await identity.groupsFor(request.userId) // fresh, every time

Two properties that make this a control rather than a convention:

// 1. The filter is applied by the database, so forgetting it in one code // path returns an error rather than extra rows — if row-level security // is on, as chapter 94 set up for memory. // 2. The parameter cannot be influenced by the model. There is no code // path where a tool argument, a retrieved chunk or a generated string // reaches $2.

Making it affordable

The usual objection is that filtering before the vector search is slow. It is, if the index does not know about the filter.

-- A partial index per group scales badly. A composite index that leads -- with the filter column lets the planner cut the candidate set before -- the distance computation. create index on chunks using hnsw (embedding vector_cosine_ops); create index on documents (acl_group, classification) include (id);

Atlas, 340,000 chunks, measured:

Approachp50Recall@12Correct
No filter31 ms0.94❌
Post-filter top-10034 ms0.71❌
Pre-filter, no supporting index210 ms0.94✅
Pre-filter, composite index48 ms0.94✅

The post-filter row is the trap. It is fast and it silently loses recall, because the top 100 are chosen before permissions are considered — a user with narrow access gets 100 results of which 12 survive, and the ones that would have answered their question were ranked 140th. It is both insecure and worse, which is a rare combination.

Seventeen milliseconds for correctness is not a trade worth discussing.

Fail closed

// Every failure mode resolves to "no results", never "all results". export async function groupsFor(userId: string): Promise<string[]> { try { const g = await identity.groups(userId) if (!g.length) throw new Error('no groups returned') return g } catch (e) { await audit.record('permissions.lookup_failed', { userId, error: String(e) }) throw new PermissionsUnavailable() // the request fails; it does not proceed } }
// And the empty-array trap, which is the one that bites. // where d.acl_group = any($2) with $2 = [] → zero rows ✅ // where d.acl_group in (...) built by string concat with [] → syntax error, or worse // Parameterised array membership fails closed by construction. String // building does not.

Proving it

// The test asserts against the store, not against the helper. A test that // only calls retrieveFor() passes just as happily when the policy has // been dropped — chapter 94 made the same point about memory. test('restricted documents are unreachable', async () => { const doc = await seedDocument({ aclGroup: 'finance', content: 'margin by account: ...' }) const asRep = await retrieveFor(rep, 'what is the margin on Northline') expect(asRep.map((c) => c.documentId)).not.toContain(doc.id) // The model never saw it, which is the actual property. Assert on the // context that was sent, not only on the answer that came back. const run = await answerWithTrace(rep, 'what is the margin on Northline') expect(JSON.stringify(run.context)).not.toContain('margin by account') // And directly, with no filter in the caller. await expect(asUser(rep, (tx) => tx.execute(sql`select * from documents where id = ${doc.id}`))) .resolves.toHaveLength(0) })

The middle assertion is the one worth copying. Checking the answer proves the model behaved; checking the context proves the data never got there, and only the second is a security property.

What the user is told

// Existence disclosure is a decision, not a default. Three options, and // Atlas picked the middle one. const POLICIES = { silent: 'Answer from what they can see. Simple, and quietly misleading.', acknowledge: 'Say an answer exists and is not visible to them, and who owns it.', detailed: 'Name the document. Leaks its existence and title.', }

Acknowledging is right for an internal tool and wrong for a multi-tenant product, where the existence of a record is itself confidential. The decision belongs in the threat model, and chapter 111's not-permitted refusal reason is where it surfaces.

Key points

  • Four places to check permissions. In the query is the only one that is a control.
  • A permission instruction in the prompt depends on the model declining, and the restricted data is already in the context, the request, the trace and the cache.
  • Filtering the answer is worse: restricted content shaped the response even when no sentence quotes it.
  • Put the filter in the SQL, parameterised, with groups fetched fresh from the identity provider on every request.
  • Nothing the model produces can reach the filter parameter. That is a property of the code path, not a check.
  • Post-filtering is fast and loses recall: 0.94 to 0.71, because the top 100 are chosen before permissions.
  • A composite index makes pre-filtering 48 ms against 31 ms unfiltered. Seventeen milliseconds for correctness.
  • Fail closed everywhere, and use parameterised array membership so an empty group list returns nothing by construction.
  • Assert that the restricted content never entered the context, and assert against the store with no filter in the caller.
  • Whether to acknowledge that a hidden answer exists is a threat-model decision, not a default.

Next: the confused deputy problem — the credential that is broader than any user.