{"slug": "when-the-data-arrives-with-someone-else-s-rules", "title": "When the data arrives with someone else's rules", "summary": "A developer behind the Nacre project described building and then removing an ingest-time permission-caching scheme for MCP search, in which each chunk carried hashes of the principals allowed to read it, because the copied permissions could only ever lag the live ones and were \"stale in the wrong direction.\" The project instead resolves access per request against a live grant table, using a workspace → layer → document scope tree where connectors assign source items to layers and never search, and measured revocation at 79 ms for a direct grant and 58 ms for leaving a group. The writeup argues permission filtering must happen inside the search rather than after ranking, since post-ranking stripping both shrinks result counts and leaks the existence of unreadable documents.", "body_md": "On Monday's article about [whose permissions an MCP search runs with](https://nacre.work/blog/whose-permissions-does-your-mcp-search-run-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.\n\nA commenter on Hacker News described how their team [feeds company knowledge to agents](https://news.ycombinator.com/item?id=50423828): 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.\"*\n\nThat'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.\n\nThere are two ways to build that, and I think it's worth being plain about what each one makes you own.\n\nAt 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.\n\nThe 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.\n\nWhat you take on:\n\nWe actually specified a version of this, built it, and took it out. Our [authorization spec](https://github.com/nacre-work/nacre/blob/main/docs/authz.md#the-vector-payload-carries-no-permission-cache) 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.\n\nThe 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.\n\nIn 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](https://github.com/nacre-work/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.\n\nWhat this makes you own is different, and I'd rather say it straight than have you find it out:\n\nWhat 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](https://nacre.work/blog/whose-permissions-does-your-mcp-search-run-with).\n\nWhichever 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.\n\nThat 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.\n\nOur public stand has a demo company with two read-only logins, printed on [demo.nacre.work](https://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](https://github.com/nacre-work/nacre/blob/main/docs/api.md#search-parameters) 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.\"\n\nHere's that claim as a script. The passwords rotate, so copy them from the demo page rather than from here.\n\n``` bash\n#!/usr/bin/env bash\n# Ask for one layer by name, as two readers, and compare with a layer that doesn't exist.\n# Passwords: copy them from https://demo.nacre.work\nset -euo pipefail\nAPI=https://playground.nacre.work/v1\nQ='what is the contract number for Northwind'\n\ntoken() {   # $1 login name, $2 password\n  curl -s \"$API/auth/login\" -H 'content-type: application/json' \\\n    -d \"{\\\"email\\\":\\\"$1@demo.nacre.work\\\",\\\"password\\\":\\\"$2\\\",\\\"organization\\\":\\\"demo\\\"}\" \\\n  | jq -r .access_token\n}\n\nask() {     # $1 who, $2 token, $3 layer name\n  body=$(curl -s \"$API/search\" \\\n    -H \"authorization: Bearer $2\" -H 'content-type: application/json' \\\n    -d \"{\\\"query\\\":\\\"$Q\\\",\\\"top_k\\\":10,\\\"layers\\\":[\\\"$3\\\"]}\")\n  echo \"$1 asks for $3: $(jq -r '\"\\(.items|length) results from [\\([.items[].layer]|unique|join(\",\"))]\"' <<<\"$body\"), $(printf %s \"$body\" | wc -c) bytes\"\n}\n\nENG=$(token engineer \"$ENGINEER_PASSWORD\")\nCON=$(token contractor \"$CONTRACTOR_PASSWORD\")\n\nask engineer   \"$ENG\" engineering\nask contractor \"$CON\" engineering\nask contractor \"$CON\" no-such-layer\n```\n\nWhat it printed today, 12 November:\n\n```\nengineer asks for engineering: 5 results from [engineering], 3435 bytes\ncontractor asks for engineering: 0 results from [], 12 bytes\ncontractor asks for no-such-layer: 0 results from [], 12 bytes\n```\n\nSide by side, with the plain search (no `layers` at all) for the contractor added:\n\n| reader | asks for layer | HTTP | results | response body | \n|---|---|---|---|---|\n| engineer | `engineering` | 200 | 5 | 3,435 bytes, all from `engineering` | \n| contractor | `engineering` | 200 | 0 | `{\"items\":[]}` , 12 bytes | \n| contractor | `no-such-layer` | 200 | 0 | `{\"items\":[]}` , 12 bytes | \n| contractor | *(none: every readable layer)* | 200 | 3 | all from `handbook` | \n\nThe 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.\n\nOne 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.\n\nIf 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.\n\nThe open-source build is [on GitHub](https://github.com/nacre-work/nacre) under Apache-2.0, and the [authorization spec](https://github.com/nacre-work/nacre/blob/main/docs/authz.md) is the document to read before trusting any of this, including from us.\n\n*Originally published at [https://nacre.work/blog/when-the-data-arrives-with-someone-elses-rules](https://nacre.work/blog/when-the-data-arrives-with-someone-elses-rules).*", "url": "https://wpnews.pro/news/when-the-data-arrives-with-someone-else-s-rules", "canonical_source": "https://dev.to/dudko-dev/when-the-data-arrives-with-someone-elses-rules-cao", "published_at": "2026-11-13 15:39:56+00:00", "updated_at": "2026-11-13 15:51:21.236263+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-tools", "ai-safety"], "entities": ["Nacre", "Hacker News", "Windows AD", "MCP"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/when-the-data-arrives-with-someone-else-s-rules", "markdown": "https://wpnews.pro/news/when-the-data-arrives-with-someone-else-s-rules.md", "text": "https://wpnews.pro/news/when-the-data-arrives-with-someone-else-s-rules.txt", "jsonld": "https://wpnews.pro/news/when-the-data-arrives-with-someone-else-s-rules.jsonld"}}