Nacre

When the data arrives with someone else's rules

A shared RAG index pulls from systems that each have their own permissions. Copy the ACLs onto every chunk, or grant at the source boundary? A test you can run.

On Monday's article about whose permissions an MCP search runs with, the hard part was a single question: who is asking. This one is the next question, and people are now running into it in public.

A commenter on Hacker News described how their team feeds company knowledge to agents: everything ingested stays linked "to the source" and to "the authorisation layer of the source that was ingested". Their example is a wiki whose permissions live in Windows AD, so that information "needs to be collected when you ingest". And then the line that's the whole problem: "Such connectors are source specific and must be aware how the system manages permissions."

That's right, and it gets harder with every source you add. A shared index rarely holds one company's one system. It holds the wiki (AD groups), the ticket tracker (project roles), a shared drive (link sharing, folder inheritance), and sooner or later a partner's records that arrived under the partner's terms. Each of those has its own rulebook. One retrieval step has to honour all of them at once, and it's the same step that ranks.

There are two ways to build that, and I think it's worth being plain about what each one makes you own.

Design one: copy the source's ACL onto every chunk

At ingest, the connector reads the source's permissions for each document and writes them into the chunk's payload: a list of users or groups allowed to read it. At query time, the search filters on that list.

The appeal is obvious. Permissions come from the system that already has them, nobody re-types anything, and a document that's private in the wiki is private in the index.

What you take on:

  • Drift. The ACL on the chunk is a copy, made at ingest. How much that matters depends on what you copy. If the connector flattens groups into the users they contain, someone removed from an AD group at 10:00 can still read until the next sync touches that document, and if documents are re-synced only on content change, an unchanged page may keep its stale list for a long time. If the chunk stores group IDs and you resolve membership at query time, a group change lands on the next search, and what drifts is the document's own ACL instead: a group added to or removed from a wiki space after ingest. Either way, a change to the source's sharing settings waits for a sync.
  • One translator per source. Every source's model has to be flattened into whatever your index can filter on. AD nested groups, Drive's "anyone with the link", Jira's project roles plus issue security levels. Each connector carries its own translation, and each one is a place for a quiet mistake.
  • Revocation runs at sync speed. That's the real cost, and it's the one that shows up in an incident review.

We actually specified a version of this, built it, and took it out. Our authorization spec has the post-mortem: each chunk was to carry hashes of the principals allowed to read it, with a background sweep keeping them fresh and an alert on how far behind it fell. The tags were computed, the sweep ran, the alert paged, and no query ever read them. The reasons for removing rather than finishing it are in that section; the one that matters here is that a copied permission is "stale in the wrong direction". It can only ever lag the real one.

Design two: keep each source in its own boundary, grant at the boundary

The other way is to not copy the source's rules at all. Each source, or each part of a source with its own audience, goes into its own container. Access is granted on the container, and the search resolves the caller's permitted containers on every request, from the live grant table.

In Nacre that container is a layer. Scopes form a tree, workspace → layer → document; grants name a user, group or service account; and with no grant there's no access. The reference connectors work this way: a rule per layer (a path in a repository, a key prefix in a bucket, a query over a table) decides where each source item lands, and the connector's own key only needs write on those layers. It never searches.

What this makes you own is different, and I'd rather say it straight than have you find it out:

  • The mapping is yours. Nacre does not read the wiki's AD permissions and mirror them. If the wiki's HR space is readable only by HR, you route that space to a layer and grant that layer to the HR group. Group membership can be managed through the API; syncing it from your directory is a separate module, not part of the open-source build.
  • The grain is coarser by default. A layer is a boundary you choose. If a source has thousands of documents each with its own hand-made sharing list, a layer per audience may not be fine enough, and the honest answer for that source may be design one, or not indexing it at all.

What you get back is that revocation is a write to one table and takes effect on the next search, because nothing downstream holds a copy. We measured that on the core API in August: 79 ms for a direct grant and 58 ms for leaving a group.

Either way, the boundary has to hold before ranking

Whichever design you pick, apply it inside the search, not after it. If you rank the whole index and then strip what the caller can't see, two things go wrong. The caller asked for ten results and gets four. And anything built from the pre-strip set, a summary, a citation count, a "3 more results hidden", tells them that documents exist in a place they can't read.

That second leak is the one to test for, and it's easy to test: ask, as someone who can't read a source, for results from that source by name, and compare the answer to what you get for a source that doesn't exist. If the two differ in any way, status code, body, error wording, timing you can see without trying hard, the boundary leaks its own existence.

Try it: one source by name, two readers

Our public stand has a demo company with two read-only logins, printed on demo.nacre.work. The engineer reads two layers, engineering and handbook. The contractor reads only handbook. The search API takes an optional layers list, and the API docs say it narrows only: it can't reach a layer no grant reaches, and "naming a layer you cannot read returns nothing from it and is indistinguishable from naming one that does not exist."

Here's that claim as a script. The passwords rotate, so copy them from the demo page rather than from here.

#!/usr/bin/env bash
# Ask for one layer by name, as two readers, and compare with a layer that doesn't exist.
# Passwords: copy them from https://demo.nacre.work
set -euo pipefail
API=https://playground.nacre.work/v1
Q='what is the contract number for Northwind'

token() {   # $1 login name, $2 password
  curl -s "$API/auth/login" -H 'content-type: application/json' \
    -d "{\"email\":\"$1@demo.nacre.work\",\"password\":\"$2\",\"organization\":\"demo\"}" \
  | jq -r .access_token
}

ask() {     # $1 who, $2 token, $3 layer name
  body=$(curl -s "$API/search" \
    -H "authorization: Bearer $2" -H 'content-type: application/json' \
    -d "{\"query\":\"$Q\",\"top_k\":10,\"layers\":[\"$3\"]}")
  echo "$1 asks for $3: $(jq -r '"\(.items|length) results from [\([.items[].layer]|unique|join(","))]"' <<<"$body"), $(printf %s "$body" | wc -c) bytes"
}

ENG=$(token engineer "$ENGINEER_PASSWORD")
CON=$(token contractor "$CONTRACTOR_PASSWORD")

ask engineer   "$ENG" engineering
ask contractor "$CON" engineering
ask contractor "$CON" no-such-layer

What it printed today, 12 November:

engineer asks for engineering: 5 results from [engineering], 3435 bytes
contractor asks for engineering: 0 results from [], 12 bytes
contractor asks for no-such-layer: 0 results from [], 12 bytes

Side by side, with the plain search (no layers at all) for the contractor added:

reader asks for layer HTTP results response body
engineer engineering 200 5 3,435 bytes, all from engineering
contractor engineering 200 0 {"items":[]}, 12 bytes
contractor no-such-layer 200 0 {"items":[]}, 12 bytes
contractor (none: every readable layer) 200 3 all from handbook

The contractor's answer for a real layer they can't read is byte-for-byte the answer for a layer that was never there. And with no layers at all, the contractor still gets three results, from the one layer they can read, rather than three minus whatever was stripped.

One thing this test doesn't show, and I'd want you to know it: response time. I didn't measure it carefully enough to make a claim either way, so treat it as an open item if timing matters in your threat model.

What neither design fixes

  • A source whose own permissions are wrong. If the wiki's HR space is open to everyone, design one copies that faithfully and design two only helps if whoever writes the mapping notices. Garbage rules in, garbage rules out.
  • Grants that are too broad. Granting a whole workspace to "all staff" because it was quicker undoes the layers below it.
  • Whatever's already in the model's context. Once text has been returned to an agent, the index's permissions are finished with it. What the agent does next is the agent's problem and the user's.
  • The mapping work itself. In design two it's a one-time decision per source, plus upkeep when the source reorganises. That's real work. It's also visible work, in a grant table you can read, which I'd take over a translation buried in a connector.

If your sources are a few systems with clear audiences, I'd grant at the boundary. If one source has fine-grained, per-document sharing that people genuinely rely on, copying its ACLs may be the only way to honour it, and then the questions to ask of that design are how often it syncs and what happens in the gap.

The open-source build is on GitHub under Apache-2.0, and the authorization spec is the document to read before trusting any of this, including from us.