<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dudko Development</title>
    <description>The latest articles on DEV Community by Dudko Development (@dudko-dev).</description>
    <link>https://dev.to/dudko-dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4241648%2F34f1aa26-93fe-44c2-8146-c560e626a902.png</url>
      <title>DEV Community: Dudko Development</title>
      <link>https://dev.to/dudko-dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dudko-dev"/>
    <language>en</language>
    <item>
      <title>When the data arrives with someone else's rules</title>
      <dc:creator>Dudko Development</dc:creator>
      <pubDate>Fri, 13 Nov 2026 15:39:56 +0000</pubDate>
      <link>https://dev.to/dudko-dev/when-the-data-arrives-with-someone-elses-rules-cao</link>
      <guid>https://dev.to/dudko-dev/when-the-data-arrives-with-someone-elses-rules-cao</guid>
      <description>&lt;p&gt;On Monday's article about &lt;a href="https://nacre.work/blog/whose-permissions-does-your-mcp-search-run-with" rel="noopener noreferrer"&gt;whose permissions an MCP search runs with&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;A commenter on Hacker News described how their team &lt;a href="https://news.ycombinator.com/item?id=50423828" rel="noopener noreferrer"&gt;feeds company knowledge to agents&lt;/a&gt;: 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: &lt;em&gt;"Such connectors are source specific and must be aware how the system manages permissions."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Design one: copy the source's ACL onto every chunk
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;What you take on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Drift.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One translator per source.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revocation runs at sync speed.&lt;/strong&gt; That's the real cost, and it's the one that shows up in an incident review.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We actually specified a version of this, built it, and took it out. Our &lt;a href="https://github.com/nacre-work/nacre/blob/main/docs/authz.md#the-vector-payload-carries-no-permission-cache" rel="noopener noreferrer"&gt;authorization spec&lt;/a&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design two: keep each source in its own boundary, grant at the boundary
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;In Nacre that container is a &lt;strong&gt;layer&lt;/strong&gt;. Scopes form a tree, workspace → layer → document; grants name a user, group or service account; and with no grant there's no access. The &lt;a href="https://github.com/nacre-work/connectors" rel="noopener noreferrer"&gt;reference connectors&lt;/a&gt; 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 &lt;code&gt;write&lt;/code&gt; on those layers. It never searches.&lt;/p&gt;

&lt;p&gt;What this makes you own is different, and I'd rather say it straight than have you find it out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The mapping is yours.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The grain is coarser by default.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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: &lt;a href="https://nacre.work/blog/whose-permissions-does-your-mcp-search-run-with" rel="noopener noreferrer"&gt;79 ms for a direct grant and 58 ms for leaving a group&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Either way, the boundary has to hold before ranking
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;from that source by name&lt;/em&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it: one source by name, two readers
&lt;/h2&gt;

&lt;p&gt;Our public stand has a demo company with two read-only logins, printed on &lt;a href="https://demo.nacre.work" rel="noopener noreferrer"&gt;demo.nacre.work&lt;/a&gt;. The engineer reads two layers, &lt;code&gt;engineering&lt;/code&gt; and &lt;code&gt;handbook&lt;/code&gt;. The contractor reads only &lt;code&gt;handbook&lt;/code&gt;. The search API takes an optional &lt;code&gt;layers&lt;/code&gt; list, and the &lt;a href="https://github.com/nacre-work/nacre/blob/main/docs/api.md#search-parameters" rel="noopener noreferrer"&gt;API docs&lt;/a&gt; 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."&lt;/p&gt;

&lt;p&gt;Here's that claim as a script. The passwords rotate, so copy them from the demo page rather than from here.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Ask for one layer by name, as two readers, and compare with a layer that doesn't exist.&lt;/span&gt;
&lt;span class="c"&gt;# Passwords: copy them from https://demo.nacre.work&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail
&lt;span class="nv"&gt;API&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://playground.nacre.work/v1
&lt;span class="nv"&gt;Q&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'what is the contract number for Northwind'&lt;/span&gt;

token&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;   &lt;span class="c"&gt;# $1 login name, $2 password&lt;/span&gt;
  curl -s &lt;span class="s2"&gt;"$API/auth/login"&lt;/span&gt; -H &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; \
    -d &lt;span class="s2"&gt;"{\"email\":\"$1@demo.nacre.work\",\"password\":\"$2\",\"organization\":\"demo\"}"&lt;/span&gt; \
  &lt;span class="o"&gt;|&lt;/span&gt; jq -r .access_token
&lt;span class="o"&gt;}&lt;/span&gt;

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

&lt;span class="nv"&gt;ENG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;token engineer &lt;span class="s2"&gt;"$ENGINEER_PASSWORD"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;CON&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;token contractor &lt;span class="s2"&gt;"$CONTRACTOR_PASSWORD"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

ask engineer   &lt;span class="s2"&gt;"$ENG"&lt;/span&gt; engineering
ask contractor &lt;span class="s2"&gt;"$CON"&lt;/span&gt; engineering
ask contractor &lt;span class="s2"&gt;"$CON"&lt;/span&gt; no-such-layer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it printed today, 12 November:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="n"&gt;engineer&lt;/span&gt; &lt;span class="n"&gt;asks&lt;/span&gt; &lt;span class="n"&gt;for&lt;/span&gt; &lt;span class="n"&gt;engineering&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;engineering&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3435&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;
&lt;span class="n"&gt;contractor&lt;/span&gt; &lt;span class="n"&gt;asks&lt;/span&gt; &lt;span class="n"&gt;for&lt;/span&gt; &lt;span class="n"&gt;engineering&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;
&lt;span class="n"&gt;contractor&lt;/span&gt; &lt;span class="n"&gt;asks&lt;/span&gt; &lt;span class="n"&gt;for&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="n"&gt;such&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Side by side, with the plain search (no &lt;code&gt;layers&lt;/code&gt; at all) for the contractor added:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;reader&lt;/th&gt;
&lt;th&gt;asks for layer&lt;/th&gt;
&lt;th&gt;HTTP&lt;/th&gt;
&lt;th&gt;results&lt;/th&gt;
&lt;th&gt;response body&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;engineer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;engineering&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;3,435 bytes, all from &lt;code&gt;engineering&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;contractor&lt;/td&gt;
&lt;td&gt;&lt;code&gt;engineering&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{"items":[]}&lt;/code&gt;, 12 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;contractor&lt;/td&gt;
&lt;td&gt;&lt;code&gt;no-such-layer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{"items":[]}&lt;/code&gt;, 12 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;contractor&lt;/td&gt;
&lt;td&gt;&lt;em&gt;(none: every readable layer)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;all from &lt;code&gt;handbook&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;code&gt;layers&lt;/code&gt; at all, the contractor still gets three results, from the one layer they can read, rather than three minus whatever was stripped.&lt;/p&gt;

&lt;p&gt;One thing this test doesn't show, and I'd want you to know it: response &lt;em&gt;time&lt;/em&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What neither design fixes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A source whose own permissions are wrong.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grants that are too broad.&lt;/strong&gt; Granting a whole workspace to "all staff" because it was quicker undoes the layers below it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whatever's already in the model's context.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The mapping work itself.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The open-source build is &lt;a href="https://github.com/nacre-work/nacre" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt; under Apache-2.0, and the &lt;a href="https://github.com/nacre-work/nacre/blob/main/docs/authz.md" rel="noopener noreferrer"&gt;authorization spec&lt;/a&gt; is the document to read before trusting any of this, including from us.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nacre.work/blog/when-the-data-arrives-with-someone-elses-rules" rel="noopener noreferrer"&gt;https://nacre.work/blog/when-the-data-arrives-with-someone-elses-rules&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>permissions</category>
      <category>acl</category>
      <category>connectors</category>
    </item>
    <item>
      <title>Whose permissions does your MCP search run with?</title>
      <dc:creator>Dudko Development</dc:creator>
      <pubDate>Fri, 13 Nov 2026 15:39:48 +0000</pubDate>
      <link>https://dev.to/dudko-dev/whose-permissions-does-your-mcp-search-run-with-1fc</link>
      <guid>https://dev.to/dudko-dev/whose-permissions-does-your-mcp-search-run-with-1fc</guid>
      <description>&lt;p&gt;Someone on Hacker News asked last week what people's &lt;a href="https://news.ycombinator.com/item?id=50368160" rel="noopener noreferrer"&gt;biggest concern is when building an MCP server&lt;/a&gt;. The top answer is permissions: what a user may see on the site and what the agent may see through MCP are two different things, and nobody quite owns the gap between them.&lt;/p&gt;

&lt;p&gt;It's a fair worry, and it isn't hypothetical. A &lt;a href="https://patchbay-mcp.dev/blog/state-of-mcp-auth-nov-2026" rel="noopener noreferrer"&gt;survey of 20 reachable public MCP servers&lt;/a&gt; published this month found 8 with no authentication at all, and only 2 advertising OAuth discovery.&lt;/p&gt;

&lt;p&gt;So here's the question I'd ask of any MCP server that searches company documents, ours included: &lt;strong&gt;when the agent calls &lt;code&gt;search&lt;/code&gt;, whose permissions does that search run with?&lt;/strong&gt; There are three parts to a good answer, and most servers have one or two of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part one: who is asking
&lt;/h2&gt;

&lt;p&gt;Most MCP servers that sit in front of a document store authenticate with one key. It goes in a config file, it belongs to the integration, and it can usually read everything the integration was set up to read. Every person who talks to the agent searches with that key.&lt;/p&gt;

&lt;p&gt;That works until two people with different access use the same agent. Then the agent shows the intern whatever the key can reach, and the only thing standing between the intern and the board minutes is the system prompt.&lt;/p&gt;

&lt;p&gt;The fix is to make the search run as the person. The MCP spec already has the plumbing: the server answers an unauthenticated call with &lt;code&gt;401&lt;/code&gt; and a pointer to its protected-resource metadata (&lt;a href="https://www.rfc-editor.org/rfc/rfc9728" rel="noopener noreferrer"&gt;RFC 9728&lt;/a&gt;), and the client runs an OAuth flow against the authorization server named there. That part is the spec's, not ours, and any server can do it. You can see it on our public stand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -si -X POST https://playground.nacre.work/mcp \
  -H &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; -d &lt;span class="s1"&gt;'{}'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; grep -i www-authenticate
&lt;span class="c"&gt;# www-authenticate: Bearer resource_metadata="https://playground.nacre.work/.well-known/oauth-protected-resource"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What OAuth gives you is an identity. It doesn't decide what that identity may read. That's the second part, and it's where the interesting failures are.&lt;/p&gt;

&lt;p&gt;In Nacre, a connection approved through OAuth is a &lt;strong&gt;delegation&lt;/strong&gt;: it acts as the person who approved it, and the token carries their id rather than a copy of what they can see. The person can narrow it further when they approve it, to some layers or to read-only, but never widen it (&lt;a href="https://github.com/nacre-work/nacre/blob/main/docs/authz.md#delegated-authority" rel="noopener noreferrer"&gt;&lt;code&gt;docs/authz.md&lt;/code&gt;, "Delegated authority"&lt;/a&gt;). Service account keys still exist, for unattended pipelines that belong to the organization rather than to anyone in it. They're just not how a person's agent should search.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part two: where the filter runs
&lt;/h2&gt;

&lt;p&gt;Say the search does run as the right person. The next question is when their permissions are applied.&lt;/p&gt;

&lt;p&gt;The easy way is a post-filter: ask the index for the 10 nearest chunks, then drop the ones this person can't read. It's easy because the vector index doesn't need to know anything about permissions. It also breaks in two ways that are hard to spot in testing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The count.&lt;/strong&gt; Ask for 10, get 3, because 7 of the nearest neighbours belonged to a layer you can't read. The agent writes its answer from 3 chunks and has no idea anything is missing. On a small test corpus where everybody can read everything, this never shows up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The leak.&lt;/strong&gt; The 7 dropped chunks still shaped the response. Fewer results than asked for, or a slower answer, or a strange ranking, tells a careful caller that something it can't see matched the query. That's information.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The alternative is a pre-filter: the permission check runs inside the index traversal, so a chunk the person can't read is never a candidate in the first place. Our &lt;a href="https://github.com/nacre-work/nacre/blob/main/docs/authz.md#invariants" rel="noopener noreferrer"&gt;authorization spec&lt;/a&gt; writes the property down as an invariant:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;I2.&lt;/strong&gt; The number of results does not depend on whether the filter ran before or after ranking. A user with access to 1 layer of 20 asking for &lt;code&gt;top_k=10&lt;/code&gt; gets 10 results — not 10 minus whatever was stripped.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the companion to it, for fetching a single document:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;I6.&lt;/strong&gt; A request without permission is indistinguishable from a request for something that does not exist: &lt;code&gt;404&lt;/code&gt;, never &lt;code&gt;403&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I re-ran the I6 check against the public stand while writing this. A contractor asking for an engineering document that exists gets &lt;code&gt;404&lt;/code&gt;. The same contractor asking for an id that never existed also gets &lt;code&gt;404&lt;/code&gt;, and the two response bodies are identical once the request id is removed. The control is what makes that worth anything: the engineer asking for the same document gets &lt;code&gt;200&lt;/code&gt;. A server that answers &lt;code&gt;404&lt;/code&gt; to everything would pass the first half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part three: how fast a revocation bites
&lt;/h2&gt;

&lt;p&gt;Someone leaves a project. Their access is removed. How long can their agent keep finding the project's documents?&lt;/p&gt;

&lt;p&gt;If the server copies permissions into the index, as metadata on each vector, the answer is "until the copy is refreshed". That might be a minute, or it might be the next nightly job.&lt;/p&gt;

&lt;p&gt;Nacre doesn't copy them. The permitted set is computed per request from the grants table, and the one cache in front of it is keyed on a version number that every write to grants and groups bumps. So a revoked grant shows up in the very next search, because the next search computes a different key, not because something expired.&lt;/p&gt;

&lt;p&gt;We measured it on a local stack in August, holding one bearer token throughout:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;change&lt;/th&gt;
&lt;th&gt;next search&lt;/th&gt;
&lt;th&gt;time to take effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;a direct grant revoked&lt;/td&gt;
&lt;td&gt;narrower&lt;/td&gt;
&lt;td&gt;79 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;added to a group that holds a grant&lt;/td&gt;
&lt;td&gt;3 → 8 results&lt;/td&gt;
&lt;td&gt;65 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;removed from that group&lt;/td&gt;
&lt;td&gt;8 → 3 results&lt;/td&gt;
&lt;td&gt;58 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two caveats that belong with those numbers. They cover groups managed through Nacre's own &lt;code&gt;/v1/groups&lt;/code&gt; API. Syncing groups from your identity provider over SCIM is a commercial module and isn't in the Apache-2.0 build, so if your directory is the source of truth, the delay that matters is your sync's, and these numbers say nothing about it. And they're one machine on one day. The guarantee is structural, the milliseconds are just what it looked like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this doesn't help
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Grants live in Nacre. They aren't mirrored from the source.&lt;/strong&gt; This is the gap I'd want to know about before anything else, so it goes first.&lt;/p&gt;

&lt;p&gt;Nacre doesn't read the sharing settings of the system a document came from. You push documents into layers, through the API, the CLI or one of the &lt;a href="https://nacre.work/connectors" rel="noopener noreferrer"&gt;reference connectors&lt;/a&gt;, and you decide who may read each layer. The connectors map a folder, a bucket prefix or a query to a layer. They don't carry per-file sharing across. The Google Drive one, for instance, walks a folder as a service account and sees what was shared with that account, not who else the file was shared with.&lt;/p&gt;

&lt;p&gt;So if your permissions today live in a dozen per-file sharing settings, someone has to turn them into layers and grants, and keep that mapping honest as the source changes. That's real work and it's yours. It's also where systems like this usually go wrong, so if a vendor tells you their connector "inherits permissions automatically", ask what happens when the source changes them and how long until search agrees.&lt;/p&gt;

&lt;p&gt;It works best when access already follows a structure you can name (a team, a project, a client, a clearance level) rather than a thousand individual decisions.&lt;/p&gt;

&lt;p&gt;A few other things per-user permissions don't fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prompt injection.&lt;/strong&gt; A document the person may read can still contain instructions. Scoping what the agent reads to what the person may read limits the damage. It doesn't remove it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grants that are too broad.&lt;/strong&gt; If everyone has been given read on everything, a perfect pre-filter faithfully returns everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What the model already saw.&lt;/strong&gt; Revoking a grant stops the next search. It doesn't reach into a conversation that already has the document in it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it: two readers, one query
&lt;/h2&gt;

&lt;p&gt;The public stand has a demo company with two read-only logins printed on &lt;a href="https://demo.nacre.work" rel="noopener noreferrer"&gt;demo.nacre.work&lt;/a&gt;. They rotate, so copy them from the page rather than from here.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Two readers, one query. Passwords: copy them from https://demo.nacre.work&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail
&lt;span class="nv"&gt;API&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://playground.nacre.work/v1
&lt;span class="nv"&gt;ORG_DOMAIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo.nacre.work
&lt;span class="nv"&gt;Q&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'what is the contract number for Northwind'&lt;/span&gt;

search_as&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;   &lt;span class="c"&gt;# $1 the login's name (engineer, contractor), $2 its password&lt;/span&gt;
  &lt;span class="nv"&gt;tok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;curl -s &lt;span class="s2"&gt;"$API/auth/login"&lt;/span&gt; -H &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; \
    -d &lt;span class="s2"&gt;"{\"email\":\"$1@$ORG_DOMAIN\",\"password\":\"$2\",\"organization\":\"demo\"}"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; jq -r .access_token&lt;span class="o"&gt;)&lt;/span&gt;
  curl -s &lt;span class="s2"&gt;"$API/search"&lt;/span&gt; -H &lt;span class="s2"&gt;"authorization: Bearer $tok"&lt;/span&gt; -H &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; \
    -d &lt;span class="s2"&gt;"{\"query\":\"$Q\",\"top_k\":10}"&lt;/span&gt; \
  &lt;span class="o"&gt;|&lt;/span&gt; jq -r --arg who &lt;span class="s2"&gt;"$1"&lt;/span&gt; &lt;span class="s1"&gt;'"\($who): \(.items|length) results from \([.items[].layer]|unique|join(", "))"'&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

search_as engineer   &lt;span class="s2"&gt;"$ENGINEER_PASSWORD"&lt;/span&gt;
search_as contractor &lt;span class="s2"&gt;"$CONTRACTOR_PASSWORD"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it printed today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;engineer: 8 results from engineering, handbook
contractor: 3 results from handbook
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same query, same index, two different answers, and neither one knows the other exists. Neither login gets the contract number itself, by the way. That's the point of the query.&lt;/p&gt;

&lt;p&gt;To do the same through an agent, connect an MCP client to the stand and sign in when the browser opens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add --transport http nacre https://playground.nacre.work/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stand is a demo, not a hosted product: it's capped at 200 documents and erases itself every 24 hours. The real thing is the &lt;a href="https://github.com/nacre-work/nacre" rel="noopener noreferrer"&gt;Apache-2.0 build&lt;/a&gt; you run yourself, on Docker Compose or Kubernetes, and the &lt;a href="https://github.com/nacre-work/nacre/blob/main/docs/authz.md" rel="noopener noreferrer"&gt;authorization spec&lt;/a&gt; is the document to read before you trust any of the above, including from us.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nacre.work/blog/whose-permissions-does-your-mcp-search-run-with" rel="noopener noreferrer"&gt;https://nacre.work/blog/whose-permissions-does-your-mcp-search-run-with&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>permissions</category>
      <category>rag</category>
      <category>oauth</category>
    </item>
  </channel>
</rss>
