<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Oauth on Markus Hupfauer</title>
    <link>https://hupfauer.one/tags/oauth/</link>
    <description>Recent content in Oauth on Markus Hupfauer</description>
    <image>
      <title>Markus Hupfauer</title>
      <url>https://hupfauer.one/og-default.jpg</url>
      <link>https://hupfauer.one/og-default.jpg</link>
    </image>
    <generator>Hugo</generator>
    <language>en-us</language>
    <lastBuildDate>Sat, 30 May 2026 09:00:00 +0200</lastBuildDate>
    <atom:link href="https://hupfauer.one/tags/oauth/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Which agent bricked prod?</title>
      <link>https://hupfauer.one/posts/which-agent-bricked-prod/</link>
      <pubDate>Sat, 30 May 2026 09:00:00 +0200</pubDate>
      <guid>https://hupfauer.one/posts/which-agent-bricked-prod/</guid>
      <description>Signing a user into an agentic web app is the easy part. Keeping the user&amp;#39;s identity — and the agent&amp;#39;s — intact across every downstream hop is where enterprise IAM quietly collapses into one unauditable credential.</description>
      <content:encoded><![CDATA[<p>Every hop an agent makes has to answer two questions: <em>whose</em> authority is being exercised, and <em>which</em> agent exercised it. Most enterprise agents in production have already lost at least one of them, which means the auditability claim is fiction — and the role model, the permission documentation, and the incident-response plan are fiction along with it. That is the contrarian sentence; the rest of this post is what it looks like in wiring.</p>
<aside class="podcast" aria-label="Audio versions of this post" data-podcast>
  <div class="podcast-head">
    <div class="podcast-eyebrow">Prefer to listen?</div>
    <div class="podcast-title">Two cuts, two languages</div>
    <div class="podcast-sub">No bytes load until you press play.</div>
    <div class="podcast-langs" role="tablist" aria-label="Language">
      <button class="podcast-lang is-active" role="tab" aria-selected="true" data-lang="en">English</button>
      <button class="podcast-lang" role="tab" aria-selected="false" data-lang="de">Deutsch</button>
    </div>
  </div>
  <div class="podcast-grid">
    <div class="podcast-card">
      <div class="podcast-card-head">
        <span class="podcast-card-tag">Governance</span>
        <span class="podcast-card-len">~10 min</span>
      </div>
      <div class="podcast-card-title">From the Boardroom</div>
      <p class="podcast-card-blurb">For heads of identity, CISOs, audit and risk. Every collapse translated into the audit-committee question behind it — what does the auditor see, what does the regulator demand, where is the program exposed.</p>
      <audio class="podcast-audio" data-lang="en" controls preload="none" src="/audio/which-agent-bricked-prod-boardroom-en.mp3">
        Your browser doesn&rsquo;t support inline audio. <a href="/audio/which-agent-bricked-prod-boardroom-en.mp3">MP3</a>.
      </audio>
      <audio class="podcast-audio" data-lang="de" controls preload="none" src="/audio/which-agent-bricked-prod-boardroom-de.mp3" hidden>
        Ihr Browser unterstützt kein eingebettetes Audio. <a href="/audio/which-agent-bricked-prod-boardroom-de.mp3">MP3</a>.
      </audio>
    </div>
    <div class="podcast-card">
      <div class="podcast-card-head">
        <span class="podcast-card-tag">Engineering</span>
        <span class="podcast-card-len">~12 min</span>
      </div>
      <div class="podcast-card-title">From the Bench</div>
      <p class="podcast-card-blurb">For identity engineers, app developers, identity ops. Lives in the wire — token claims, OBO mechanics, S4U flags, Keycloak mappers, AI Search ACL fields — and ends every section on something you could ship on Monday.</p>
      <audio class="podcast-audio" data-lang="en" controls preload="none" src="/audio/which-agent-bricked-prod-bench-en.mp3">
        Your browser doesn&rsquo;t support inline audio. <a href="/audio/which-agent-bricked-prod-bench-en.mp3">MP3</a>.
      </audio>
      <audio class="podcast-audio" data-lang="de" controls preload="none" src="/audio/which-agent-bricked-prod-bench-de.mp3" hidden>
        Ihr Browser unterstützt kein eingebettetes Audio. <a href="/audio/which-agent-bricked-prod-bench-de.mp3">MP3</a>.
      </audio>
    </div>
  </div>
</aside>
<style>
.podcast {
  --p-ink: #0c0c0d;
  --p-panel: #151517;
  --p-line: #2a2a2e;
  --p-paper: #e9e6df;
  --p-muted: #8d8d8a;
  --p-rust: #c25a2e;
  color: var(--p-paper);
  background: var(--p-ink);
  border: 1px solid var(--p-line);
  border-radius: 10px;
  padding: 1rem 1.05rem 1.1rem;
  margin: 1.5rem 0 2.2rem;
  font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.podcast-head { margin-bottom: 0.85rem; }
.podcast-eyebrow {
  font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.14em;
  color: var(--p-rust); font-weight: 600;
}
.podcast-title { font-size: 1rem; font-weight: 600; margin-top: 0.15rem; }
.podcast-sub { color: var(--p-muted); font-size: 0.74rem; margin-top: 0.15rem; }
.podcast-langs { display: flex; gap: 0.35rem; margin-top: 0.6rem; }
.podcast-lang {
  background: transparent; color: var(--p-muted);
  border: 1px solid var(--p-line); border-radius: 999px;
  padding: 0.28rem 0.7rem; font-size: 0.78rem; cursor: pointer;
  font-family: inherit;
  transition: color .15s, border-color .15s, background .15s;
}
.podcast-lang:hover { color: var(--p-paper); }
.podcast-lang.is-active {
  color: var(--p-ink); background: var(--p-paper); border-color: var(--p-paper); font-weight: 600;
}
.podcast-grid {
  display: grid; grid-template-columns: 1fr 1fr; gap: 0.7rem;
}
@media (max-width: 640px) { .podcast-grid { grid-template-columns: 1fr; } }
.podcast-card {
  background: var(--p-panel);
  border: 1px solid var(--p-line);
  border-radius: 8px;
  padding: 0.7rem 0.8rem 0.8rem;
  display: flex; flex-direction: column;
}
.podcast-card-head {
  display: flex; align-items: baseline; justify-content: space-between;
  font-size: 0.72rem;
}
.podcast-card-tag {
  text-transform: uppercase; letter-spacing: 0.12em; color: var(--p-rust);
  font-weight: 600;
}
.podcast-card-len { color: var(--p-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.podcast-card-title { font-size: 0.98rem; font-weight: 600; margin-top: 0.25rem; }
.podcast-card-blurb {
  color: var(--p-paper); font-size: 0.82rem; line-height: 1.45;
  margin: 0.4rem 0 0.65rem; opacity: 0.86;
}
.podcast-audio {
  display: block; width: 100%; margin-top: auto;
  color-scheme: dark;
}
.podcast-audio[hidden] { display: none; }
.podcast-audio::-webkit-media-controls-panel { background: var(--p-ink); }
</style>
<script>
(function () {
  document.querySelectorAll("[data-podcast]").forEach(function (root) {
    var langButtons = root.querySelectorAll(".podcast-lang");
    var audios = root.querySelectorAll(".podcast-audio");
    function switchTo(lang) {
      audios.forEach(function (a) {
        var match = a.getAttribute("data-lang") === lang;
        a.hidden = !match;
        if (!match) {
          try { a.pause(); } catch (e) {}
        }
      });
      langButtons.forEach(function (b) {
        var match = b.getAttribute("data-lang") === lang;
        b.classList.toggle("is-active", match);
        b.setAttribute("aria-selected", match ? "true" : "false");
      });
    }
    langButtons.forEach(function (btn) {
      btn.addEventListener("click", function () {
        switchTo(btn.getAttribute("data-lang"));
      });
    });
  });
})();
</script>

<p>A while back I argued on this site that <a href="/posts/identity-is-the-control-plane/">identity is the control plane</a>: probabilistic recognizers are sensors, and the controls that actually hold are identity scoping, sandboxing, and a human on the irreversible actions. That post made the case in the abstract. This series spends it down in the place where it is genuinely hard — an estate with on-prem Active Directory, Entra ID, and a Keycloak nobody is allowed to turn off — and shows how the principle breaks the moment you stop drawing diagrams and start wiring tokens.</p>
<p>By &ldquo;which agent&rdquo; I mean the concrete thing that acted — the deployed workflow, the bot instance, the tool-calling runtime — not the app registration it happens to share with forty others. That distinction is the whole game, because the principal collapses in three different places and each erases a different half: the <em>user</em> disappears behind an app-only token or a service account; the <em>agent</em> disappears behind a platform-wide client identity; and the <em>authorization</em> disappears when data is copied out of the system that knew the ACLs into one that doesn&rsquo;t. The rest of this post is those three collapses, in the order you meet them building the thing.</p>
<p>This part is the server-side case: the full-stack app with LLM calls behind it, the <code>smolagents</code>-shaped service, the low-code &ldquo;agent&rdquo; a colleague built last quarter. The agents that run <em>on your device</em> — Claude Code, the IDE assistant — are a different problem with a different trust boundary, and they get their own post.</p>
<h2 id="the-invariant-before-anything-else">The invariant, before anything else</h2>
<p>A user must never <em>silently</em> gain authority by acting through an agent. The qualifier matters, because users trigger actions they can&rsquo;t perform directly all the time — a deploy, an approval-gated purchase order, a provisioning workflow — and that is fine when the action is openly a service-owned operation, governed by its own policy and attributed as such. What is never fine is <em>laundering</em>: a user-initiated, user-attributed, user-audited action that is actually carried out with broader, non-user credentials behind the curtain. The moment an agent quietly does something the human driving it could not — while the books still read as if the human did it — every sentence you have written about roles and least privilege is void, because the agent is now the way around all of it.</p>
<p>State it operationally so it survives contact with real systems. When an action is represented as the user&rsquo;s delegated authority, its effective permission after policy must be no greater than the intersection of what the user could do <em>directly</em> against that resource and what the app was delegated consent to request. When it is genuinely service-owned automation, don&rsquo;t dress it up as the user. Either is defensible. The unauditable middle — the user&rsquo;s name on the service&rsquo;s power — is the whole disease. And mind that the intersection is not a property OAuth gives you: scopes are not ACLs and not business policy. A delegated token can carry a broad read scope and still return only the items the user&rsquo;s own ACL allows — <em>if</em> the resource bothers to enforce on the user, which plenty of APIs don&rsquo;t. The danger is the hop where the resource stops seeing the user at all, or never did.</p>
<p>Hold that next to the two questions. Preserving authority answers &ldquo;on whose behalf.&rdquo; It does not answer &ldquo;which agent,&rdquo; and you need both. Keep them in mind; every section below is the same invariant breaking in a different place.</p>
<h2 id="the-easy-part-is-the-boring-part">The easy part is the boring part</h2>
<p>Signing the user in is solved. Register the web app in Entra, put App Service Authentication (&ldquo;EasyAuth&rdquo;) in front of it, run the OIDC authorization-code flow, and you get a properly authenticated end user with almost no code. This is the part vendors demo. It is also the least interesting thing that has to go right.</p>
<p>One sharp edge worth naming, because it is where the rest of the post starts: EasyAuth terminates the sign-in for you and hands your app a set of injected headers. The ID token proves <em>who signed in</em> — it is not a key to anything downstream. To call another API as the user you need the <em>access token</em>, which App Service exposes as <code>X-MS-TOKEN-AAD-ACCESS-TOKEN</code> (with a token store and refresh path you have to actually configure and use). If you skip that and grab the ID token, or mint a fresh app-only token instead, you have already silently dropped the user. And because EasyAuth authenticates at the front door and hands your code the result in headers, the app has to be unreachable except <em>through</em> that front door — code that trusts injected identity headers on a directly reachable port is trivially spoofable. Everything that follows depends on preserving that user-bound access token and <em>exchanging</em> it — not blindly forwarding it — for each new audience.</p>
<h2 id="two-doors-to-the-second-app">Two doors to the second app</h2>
<p>Now the app has to call a second Entra-protected API — say a line-of-business service holding the data the agent reasons over. There are two doors, and they are not interchangeable.</p>
<p><strong>Application permissions</strong> (the client-credentials grant). The app authenticates as <em>itself</em> with a client secret or certificate, gets a token whose authority shows up as <code>roles</code>, and requests the <code>.default</code> scope after an admin has consented. There is no user in this token. The downstream API sees the application, not a person — identified by its client/app id, and only if it bothers to log and authorize on that. This is correct, and I want to be precise about it: for genuinely app-owned background work — a nightly indexer, a reconciliation job, a daemon that owns its own data, cross-tenant aggregation no single user is entitled to — application permissions are <em>right</em>. Pretending that work is a person would be the lie. The sin is not the grant. The sin is using it for a <em>user-initiated</em> action and then claiming user-level auditability you threw away at the token endpoint.</p>
<p><strong>On-Behalf-Of</strong> (the delegated door). The app takes the user&rsquo;s access token and exchanges it at the token endpoint — <code>grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer</code>, <code>requested_token_use=on_behalf_of</code> — for a <em>new</em> access token whose audience is the downstream API.<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> That token carries the user as <code>oid</code> + <code>tid</code> (use those; <code>upn</code> is mutable, sometimes absent, and a display aid, not an identifier) and the delegated scopes as <code>scp</code>. The downstream API sees the app acting <em>as</em> the user. Two preconditions people skip: the incoming token&rsquo;s <code>aud</code> must be your API, and the downstream scopes must already be consented — no consent, no exchange. And OBO is delegated-only by construction: an app-only token has no user in it, so there is nothing to exchange. The two doors don&rsquo;t open onto the same room.</p>
<p>Door one loses the principal entirely. Door two keeps the <em>user</em> half of it — and, as it turns out, the immediate caller, but not the chain behind it. Hold that &ldquo;user half&rdquo; qualifier, because it is about to matter.</p>
<h2 id="the-hop-nobody-plans-for">The hop nobody plans for</h2>
<p>The second app has its own agent now. It calls a third app. Where does the user go?</p>
<p>If the middle tier keeps using OBO, the user-bound chain can in principle continue — Entra does support exchanging again from a middle tier — but every additional tier needs its own app registration, its own exposed scopes, its own consent, its own audience handling, and it runs face-first into Conditional Access claims challenges and audience mismatches. A single tier with a custom token-signing key silently breaks the whole chain, because the next hop can no longer validate the signature. &ldquo;Just keep exchanging&rdquo; is a sentence; in an estate it is a quarter of work, which is exactly why people don&rsquo;t, and reach for a client secret instead. The moment the middle tier calls the third app with <em>application</em> permissions, the user-bound chain terminates inside the middle tier. The third app sees a service principal with whatever that service was granted — frequently more than the user ever held. That is the invariant breaking, quietly, one tier in.</p>
<p>Here is the part worth being honest about, because it is where I see the most over-claiming. The clean answer — <em>carry the actor</em> — is real as a standard and mostly absent as a deployment. RFC 8693 token exchange describes the cleanest shape for it: a token with the user as <code>subject</code> and the agent as <code>actor</code> (<code>act</code>, with <code>may_act</code> able to <em>express</em> who may act for whom — enforcement still depends on the authorization and resource servers honoring it), so a downstream service can see both &ldquo;this was Alice&rsquo;s authority&rdquo; and &ldquo;exercised by agent X&rdquo; in one trace. That is the tidiest standard form, not the only correct design; real estates often carry the actor in a compensating trace outside the token instead. Entra OBO is <strong>not</strong> that. It is Microsoft&rsquo;s own JWT-bearer flow, it predates and sits beside RFC 8693, and it does not hand you <code>act</code>/<code>may_act</code> actor chains in ordinary downstream tokens. So in a normal Entra estate you can preserve the user across a hop or two with real effort, and the resource can see the <em>immediate</em> caller — the middle-tier app — in <code>azp</code>/<code>appid</code>. What it cannot reconstruct is the chain behind that caller: Alice, via agent X, in platform Y, through middle-tier B. You get the user and the last app; you lose the actor chain, and the actor chain is the half the incident bridge asks for first.</p>
<p>There is a second failure mode that has nothing to do with token types, and it is the one I find most under-appreciated: <strong>trust drift.</strong> Consent is granted to <em>scopes</em>, not to a frozen behavior. You integrated with a well-behaved internal API last year, consented to exactly what it needed, signed off. This quarter its team shipped &ldquo;agent&rdquo; features and it now fans your users&rsquo; delegated access out to a dozen systems it never touched before — under the consent you already gave, because the scopes did not change, only the code paths behind them did. Nothing renegotiated. Nobody re-consented. You authorized a <em>connection</em>; what you are exposed to is that connection&rsquo;s <em>future</em> blast radius, and there is no Entra event that fires when it grows. Permission governance helps less than you&rsquo;d hope here: app-consent reviews and permission reports can tell you a scope changed or that an app looks risky, but none of them fire when the behavior behind an <em>unchanged</em> scope quietly grows.</p>

<div class="tflow" data-tflow>
  <div class="tflow-head">
    <div class="tflow-title">Trace the token. Read the logs. Name the agent.</div>
    <div class="tflow-sub">Step a request through the hops. Watch what each token carries and what the logs can actually prove.</div>
  </div>

  <div class="tflow-tabs" role="tablist" aria-label="Scenario">
    <button class="tflow-tab" data-scn="obo" role="tab">On-Behalf-Of</button>
    <button class="tflow-tab" data-scn="appperm" role="tab">Application permissions</button>
    <button class="tflow-tab" data-scn="pat" role="tab">Maker&rsquo;s PAT &middot; prod incident</button>
  </div>

  <div class="tflow-blurb" data-blurb></div>

  <div class="tflow-grid">
    <div class="tflow-col">
      <div class="tflow-colhead">The hops</div>
      <ol class="tflow-trace" data-trace></ol>
    </div>
    <div class="tflow-col">
      <div class="tflow-colhead">What the logs record</div>
      <div class="tflow-logs" data-logs></div>
    </div>
  </div>

  <div class="tflow-controls">
    <button class="tflow-btn" data-prev aria-label="Previous hop">&larr; Step back</button>
    <button class="tflow-btn tflow-btn-key" data-next aria-label="Next hop">Step &rarr;</button>
    <button class="tflow-btn" data-play>Play</button>
    <button class="tflow-btn" data-reset>Reset</button>
    <span class="tflow-progress" data-progress></span>
  </div>

  <div class="tflow-verdict" data-verdict>
    <div class="tflow-verdict-q">From the authoritative logs alone, can you prove&hellip;</div>
    <div class="tflow-verdict-pills">
      <span class="tflow-pill" data-pill-whose><b>Whose</b> authority? <i data-whose>&mdash;</i></span>
      <span class="tflow-pill" data-pill-which><b>Which</b> agent? <i data-which>&mdash;</i></span>
    </div>
    <div class="tflow-verdict-note" data-vnote></div>
  </div>
</div>

<style>
.tflow {
  --ink: #0c0c0d;
  --panel: #151517;
  --line: #2a2a2e;
  --paper: #e9e6df;
  --muted: #8d8d8a;
  --rust: #c25a2e;
  --rust-dim: #6b3a26;
  color: var(--paper);
  background: var(--ink);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 1.1rem 1.1rem 1.25rem;
  margin: 2rem 0;
  font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  font-size: 0.95rem;
  line-height: 1.5;
}
.tflow * { box-sizing: border-box; }
.tflow-title { font-weight: 700; font-size: 1.05rem; letter-spacing: 0.01em; }
.tflow-sub { color: var(--muted); font-size: 0.85rem; margin-top: 0.2rem; }
.tflow-tabs { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.9rem 0 0.6rem; }
.tflow-tab {
  background: transparent; color: var(--muted);
  border: 1px solid var(--line); border-radius: 999px;
  padding: 0.34rem 0.8rem; font-size: 0.82rem; cursor: pointer;
  transition: color .15s, border-color .15s, background .15s;
}
.tflow-tab:hover { color: var(--paper); }
.tflow-tab[aria-selected="true"] {
  color: var(--ink); background: var(--paper); border-color: var(--paper); font-weight: 600;
}
.tflow-blurb { color: var(--paper); font-size: 0.9rem; min-height: 1.4em; margin-bottom: 0.9rem; }
.tflow-blurb b { color: var(--rust); }
.tflow-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; }
@media (max-width: 640px) { .tflow-grid { grid-template-columns: 1fr; } }
.tflow-col { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 0.7rem 0.8rem; }
.tflow-colhead {
  font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.12em;
  color: var(--muted); margin-bottom: 0.6rem;
}
.tflow-trace { list-style: none; margin: 0; padding: 0; }
.tflow-hop {
  position: relative; padding: 0.5rem 0 0.5rem 1.4rem;
  border-left: 2px solid var(--line); opacity: 0.32;
  transition: opacity .25s;
}
.tflow-hop::before {
  content: ""; position: absolute; left: -7px; top: 0.78rem;
  width: 11px; height: 11px; border-radius: 50%;
  background: var(--ink); border: 2px solid var(--line);
}
.tflow-hop.on { opacity: 1; }
.tflow-hop.on::before { border-color: var(--rust); background: var(--rust); }
.tflow-hop.done { opacity: 0.7; }
.tflow-hop.done::before { border-color: var(--paper); }
.tflow-hop:last-child { border-left-color: transparent; }
.tflow-hop-actor { font-weight: 600; font-size: 0.9rem; }
.tflow-hop-via { color: var(--muted); font-size: 0.78rem; }
.tflow-hop-via b { color: var(--rust); font-weight: 600; }
.tflow-claims { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.4rem; }
.tflow-claim {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.72rem; padding: 0.12rem 0.4rem; border-radius: 4px;
  background: #1d1d20; border: 1px solid var(--line); color: var(--paper);
  white-space: nowrap;
}
.tflow-claim.lost { color: var(--rust); border-color: var(--rust-dim); text-decoration: line-through; text-decoration-color: var(--rust); }
.tflow-hop-note { color: var(--muted); font-size: 0.78rem; margin-top: 0.35rem; max-width: 42ch; }
.tflow-hop:not(.on) .tflow-hop-note, .tflow-hop:not(.on) .tflow-claims { display: none; }

.tflow-logs { display: flex; flex-direction: column; gap: 0.55rem; min-height: 6rem; }
.tflow-log {
  border: 1px solid var(--line); border-radius: 6px; padding: 0.45rem 0.55rem;
  background: #131316; opacity: 0; transform: translateY(4px); transition: opacity .25s, transform .25s;
}
.tflow-log.show { opacity: 1; transform: none; }
.tflow-log-src { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); }
.tflow-log-line { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.78rem; margin-top: 0.15rem; }
.tflow-log.lost { border-color: var(--rust-dim); }
.tflow-log-miss { color: var(--rust); font-size: 0.75rem; margin-top: 0.2rem; }
.tflow-log-miss b { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.tflow-logs-empty { color: var(--muted); font-size: 0.82rem; font-style: italic; }

.tflow-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem; margin-top: 0.9rem; }
.tflow-btn {
  background: transparent; color: var(--paper); border: 1px solid var(--line);
  border-radius: 6px; padding: 0.36rem 0.7rem; font-size: 0.82rem; cursor: pointer;
  transition: border-color .15s, background .15s;
}
.tflow-btn:hover { border-color: var(--paper); }
.tflow-btn:disabled { opacity: 0.35; cursor: default; }
.tflow-btn-key { background: var(--rust); border-color: var(--rust); color: #fff; font-weight: 600; }
.tflow-btn-key:hover { border-color: #fff; }
.tflow-progress { color: var(--muted); font-size: 0.8rem; margin-left: auto; }

.tflow-verdict { margin-top: 1rem; padding-top: 0.9rem; border-top: 1px solid var(--line); }
.tflow-verdict-q { font-size: 0.85rem; color: var(--muted); }
.tflow-verdict-pills { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.5rem 0; }
.tflow-pill {
  border: 1px solid var(--line); border-radius: 999px; padding: 0.3rem 0.75rem; font-size: 0.85rem;
}
.tflow-pill i { font-style: normal; font-weight: 700; margin-left: 0.25rem; }
.tflow-pill.yes { border-color: var(--paper); }
.tflow-pill.yes i { color: var(--paper); }
.tflow-pill.no { border-color: var(--rust); }
.tflow-pill.no i { color: var(--rust); }
.tflow-verdict-note { font-size: 0.88rem; min-height: 1.3em; }
.tflow-verdict-note b { color: var(--rust); }
@media (prefers-reduced-motion: reduce) {
  .tflow-hop, .tflow-log { transition: none; }
}
</style>

<script>
(function () {
  var SCN = {
    obo: {
      label: "On-Behalf-Of",
      blurb: "Honest delegation, done right. The <b>user</b> survives every hop — and the actor chain still does not.",
      hops: [
        { actor: "Alice (user)", via: "OIDC sign-in &rarr; <b>Web app</b>",
          claims: [["sub", "oid: alice", 0], ["aud", "web-app", 0]],
          note: "Front-door sign-in. EasyAuth proves who signed in — nothing downstream yet." },
        { actor: "Web app", via: "<b>OBO</b> exchange &rarr; Middle API",
          claims: [["sub", "oid: alice", 0], ["scp", "Records.Read", 0], ["azp", "web-app", 0], ["act", "none", 1]],
          note: "New token, audience = Middle API. User preserved as oid+tid. No actor claim minted." },
        { actor: "Middle API agent", via: "<b>OBO</b> again &rarr; Records API",
          claims: [["sub", "oid: alice", 0], ["scp", "Records.Read", 0], ["azp", "middle-api", 0], ["act", "none", 1]],
          note: "User still here. The token names only the immediate caller, never the chain behind it." },
        { actor: "Records API", via: "authorize &amp; act",
          claims: [["sub", "oid: alice", 0], ["azp", "middle-api", 0], ["act", "none", 1]],
          note: "Sees Alice and the last app. Not ‘which agent, in which platform, via which tier’." }
      ],
      logs: [
        { src: "Entra sign-in", line: "alice → web-app  13:58:11", lost: 0 },
        { src: "Middle-tier", line: "token sub=alice azp=web-app", lost: 0 },
        { src: "Records API audit", line: "read sub=alice azp=middle-api", lost: 1, miss: "actor chain: —  (alice→agentX→platformY→B not recoverable)" }
      ],
      whose: 1, which: 0,
      note: "Best realistic case in a normal Entra estate. You can name the <b>user</b>. You still cannot name the <b>agent</b>."
    },
    appperm: {
      label: "Application permissions",
      blurb: "One hop quietly switches to a <b>client secret</b>. The user is laundered into the app’s broad grant.",
      hops: [
        { actor: "Alice (user)", via: "OIDC sign-in &rarr; <b>Web app</b>",
          claims: [["sub", "oid: alice", 0]],
          note: "Same clean start." },
        { actor: "Web app", via: "<b>OBO</b> &rarr; Middle API",
          claims: [["sub", "oid: alice", 0], ["scp", "Records.Read", 0], ["azp", "web-app", 0]],
          note: "Still honest. User preserved." },
        { actor: "Middle API agent", via: "<b>client-credentials</b> &rarr; Records API",
          claims: [["sub", "—", 1], ["roles", "Records.ReadWrite.All", 0], ["appid", "middle-api", 0]],
          note: "App acts as itself. The user is gone at the token endpoint — and the app role is broader than Alice ever held." },
        { actor: "Records API", via: "authorize",
          claims: [["sub", "—", 1], ["roles", "Records.ReadWrite.All", 0], ["appid", "middle-api", 0]],
          note: "Sees a service principal with god-mode. No person. No agent." }
      ],
      logs: [
        { src: "Entra sign-in", line: "alice → web-app  13:58:11", lost: 0 },
        { src: "Middle-tier", line: "OBO ok: sub=alice", lost: 0 },
        { src: "Records API audit", line: "write by appid=middle-api roles=*", lost: 1, miss: "sub: —  (no user; shared service principal did it)" }
      ],
      whose: 0, which: 0,
      note: "The user vanished at the app-permissions hop. Every action attributes to one over-privileged <b>service principal</b> shared by everything."
    },
    pat: {
      label: "Maker’s PAT",
      blurb: "The low-code build everyone actually ships. One <b>admin PAT</b>, and you let it rip. Then prod breaks.",
      hops: [
        { actor: "Alice (user)", via: "chat: ‘tidy stale tickets before the release’",
          claims: [["sub", "oid: alice", 0], ["context", "chat only", 1]],
          note: "Alice’s identity never leaves the chat surface." },
        { actor: "Platform agent", via: "runs as <b>maker’s PAT</b>",
          claims: [["sub", "—", 1], ["cred", "PAT: maker", 0], ["azp", "n/a (Atlassian)", 1]],
          note: "Different identity domain. Entra-Alice and Atlassian-Alice never reconcile. The agent is the maker now." },
        { actor: "Jira", via: "bulk-close across projects Alice can’t see",
          claims: [["author", "maker", 0], ["onBehalfOf", "—", 1]],
          note: "One closed ticket is the change record an automation rule keys off." },
        { actor: "Release pipeline", via: "rule fires &rarr; cut prod build",
          claims: [["trigger", "automation", 0], ["who", "—", 1]],
          note: "Prod is now running a build nobody signed off on." }
      ],
      logs: [
        { src: "Jira audit", line: "bulk-close author=maker  14:02:07", lost: 1, miss: "onBehalfOf: —  (Alice nowhere in it)" },
        { src: "Entra sign-in", line: "alice → platform  13:58:11", lost: 1, miss: "no event tied to the change" },
        { src: "Platform run log", line: "runId=7f3a…  13:59  (never forwarded)", lost: 1, miss: "correlation id not in Jira or pipeline logs" },
        { src: "Release pipeline", line: "cut by automation  14:02:50", lost: 1, miss: "who: —  clocks skew across all three" }
      ],
      whose: 0, which: 0,
      note: "Reconcile timestamps across three clocks that disagree and you can <b>guess</b>. From the authoritative logs you cannot <b>prove</b> it. Impossible by construction."
    }
  };

  var ORDER = ["obo", "appperm", "pat"];

  function init(root) {
    var state = { scn: "obo", step: 0, timer: null };
    var els = {
      tabs: root.querySelectorAll(".tflow-tab"),
      blurb: root.querySelector("[data-blurb]"),
      trace: root.querySelector("[data-trace]"),
      logs: root.querySelector("[data-logs]"),
      prev: root.querySelector("[data-prev]"),
      next: root.querySelector("[data-next]"),
      play: root.querySelector("[data-play]"),
      reset: root.querySelector("[data-reset]"),
      progress: root.querySelector("[data-progress]"),
      whose: root.querySelector("[data-whose]"),
      which: root.querySelector("[data-which]"),
      pillWhose: root.querySelector("[data-pill-whose]"),
      pillWhich: root.querySelector("[data-pill-which]"),
      vnote: root.querySelector("[data-vnote]")
    };

    function buildTrace() {
      var s = SCN[state.scn];
      els.trace.innerHTML = "";
      s.hops.forEach(function (h, i) {
        var li = document.createElement("li");
        li.className = "tflow-hop";
        var chips = h.claims.map(function (c) {
          return '<span class="tflow-claim' + (c[2] ? " lost" : "") + '">' + c[0] + ": " + c[1] + "</span>";
        }).join("");
        li.innerHTML =
          '<div class="tflow-hop-actor">' + h.actor + "</div>" +
          '<div class="tflow-hop-via">' + h.via + "</div>" +
          '<div class="tflow-claims">' + chips + "</div>" +
          '<div class="tflow-hop-note">' + h.note + "</div>";
        els.trace.appendChild(li);
      });
    }

    function render() {
      var s = SCN[state.scn];
      els.blurb.innerHTML = s.blurb;
      var hops = els.trace.querySelectorAll(".tflow-hop");
      hops.forEach(function (li, i) {
        li.classList.toggle("on", i === state.step);
        li.classList.toggle("done", i < state.step);
      });
      
      var logEls = els.logs.querySelectorAll(".tflow-log");
      logEls.forEach(function (el, i) { el.classList.toggle("show", i <= state.step); });
      var last = state.step === s.hops.length - 1;
      els.progress.textContent = "Hop " + (state.step + 1) + " of " + s.hops.length;
      els.prev.disabled = state.step === 0;
      els.next.disabled = last;
      
      setVerdict(last ? s : null);
    }

    function setVerdict(s) {
      if (!s) {
        els.whose.innerHTML = "&mdash;"; els.which.innerHTML = "&mdash;";
        els.pillWhose.className = "tflow-pill"; els.pillWhich.className = "tflow-pill";
        els.vnote.innerHTML = "";
        return;
      }
      els.whose.textContent = s.whose ? "yes" : "no";
      els.which.textContent = s.which ? "yes" : "no";
      els.pillWhose.className = "tflow-pill " + (s.whose ? "yes" : "no");
      els.pillWhich.className = "tflow-pill " + (s.which ? "yes" : "no");
      els.vnote.innerHTML = s.note;
    }

    function buildLogs() {
      var s = SCN[state.scn];
      els.logs.innerHTML = "";
      s.logs.forEach(function (lg) {
        var d = document.createElement("div");
        d.className = "tflow-log" + (lg.lost ? " lost" : "");
        d.innerHTML =
          '<div class="tflow-log-src">' + lg.src + "</div>" +
          '<div class="tflow-log-line">' + lg.line + "</div>" +
          (lg.miss ? '<div class="tflow-log-miss">missing &mdash; <b>' + lg.miss + "</b></div>" : "");
        els.logs.appendChild(d);
      });
    }

    function setScenario(name) {
      stopPlay();
      state.scn = name; state.step = 0;
      els.tabs.forEach(function (t) {
        var sel = t.getAttribute("data-scn") === name;
        t.setAttribute("aria-selected", sel ? "true" : "false");
      });
      buildTrace(); buildLogs(); render();
    }

    function step(d) {
      var s = SCN[state.scn];
      state.step = Math.max(0, Math.min(s.hops.length - 1, state.step + d));
      render();
    }

    function stopPlay() {
      if (state.timer) { clearInterval(state.timer); state.timer = null; els.play.textContent = "Play"; }
    }
    function togglePlay() {
      if (state.timer) { stopPlay(); return; }
      var s = SCN[state.scn];
      if (state.step === s.hops.length - 1) state.step = 0;
      render();
      els.play.textContent = "Pause";
      state.timer = setInterval(function () {
        var sc = SCN[state.scn];
        if (state.step >= sc.hops.length - 1) { stopPlay(); return; }
        step(1);
      }, 1100);
    }

    els.tabs.forEach(function (t) {
      t.addEventListener("click", function () { setScenario(t.getAttribute("data-scn")); });
    });
    els.prev.addEventListener("click", function () { stopPlay(); step(-1); });
    els.next.addEventListener("click", function () { stopPlay(); step(1); });
    els.play.addEventListener("click", togglePlay);
    els.reset.addEventListener("click", function () { stopPlay(); state.step = 0; render(); });

    setScenario("obo");
  }

  var roots = document.querySelectorAll("[data-tflow]");
  roots.forEach(init);
})();
</script>

<h2 id="down-to-the-metal-when-the-target-is-on-prem-ad">Down to the metal: when the target is on-prem AD</h2>
<p>Eventually some service has to read or write against a directory-integrated system on the old network. The lazy version is everywhere: one service account, a simple LDAP bind with a bind DN and a password, broad rights, and every action the agent ever takes attributed to that single account forever. The principal doesn&rsquo;t just degrade here — it collapses to one identity that is neither the user nor the agent.</p>
<p>The honest version is Kerberos, and it is worth being exact about what bridges what, because &ldquo;the user logged in with OIDC at the web tier&rdquo; does <strong>not</strong> grant anyone Kerberos powers. The KDC does not accept an Entra token and mint tickets from it. What happens is that a service account <em>you</em> have trusted for constrained delegation with protocol transition — classic or resource-based, the S4U shape is the same — asks the KDC, via <strong>S4U2Self</strong>, for a ticket to <em>itself</em> on behalf of a named AD user, no user password involved, and then via <strong>S4U2Proxy</strong> for a delegated ticket to a <em>specific</em> backend SPN it has been explicitly allowed to reach. The bind to LDAP is then SASL/GSSAPI with that ticket, not a simple bind. The backend can finally authorize <em>as</em> the user — the user half of the trace, preserved all the way to the directory. (The agent half is still missing: Kerberos carries the user and the delegating service, not &ldquo;agent X acted through service Y.&rdquo;)</p>
<p>The thing nobody puts on the slide is the join in the middle: the Entra user has to <em>be</em> an AD principal for any of this to resolve, and that means more than identifier equality — UPN alignment via Entra Connect, or an explicit, trustworthy mapping from the cloud-authenticated subject to the on-prem impersonation target. That hybrid identity plumbing is the hard, brittle part. And note what you have built to get here: a service account trusted to impersonate arbitrary users to named services. That is a loaded capability. Done with tightly scoped SPNs and monitoring it is far safer than a god-mode bind account; the user-side controls exist for exactly this risk — marking sensitive accounts as &ldquo;cannot be delegated,&rdquo; putting them in Protected Users — and the classic way it goes wrong is the lazy upgrade to <em>unconstrained</em> delegation, or an SPN allow-list nobody has pruned since 2019. The reason teams skip all of it and use the fat account is not ignorance. It is that the correct thing is genuinely painful, and the painful thing is the only thing that preserves the user.</p>

<div class="krb" data-krb>
  <div class="krb-head">
    <div class="krb-title">Down to the metal. What survives the bind?</div>
    <div class="krb-sub">Step a request from the web tier to Active Directory. Watch the principal collapse — or not.</div>
  </div>

  <div class="krb-tabs" role="tablist" aria-label="Scenario">
    <button class="krb-tab" data-scn="bind" role="tab">Service account &middot; simple bind</button>
    <button class="krb-tab" data-scn="s4u" role="tab">S4U2Self &rarr; S4U2Proxy &rarr; SASL/GSSAPI</button>
  </div>

  <div class="krb-blurb" data-blurb></div>

  <div class="krb-grid">
    <div class="krb-col">
      <div class="krb-colhead">The hops</div>
      <ol class="krb-trace" data-trace></ol>
    </div>
    <div class="krb-col">
      <div class="krb-colhead">What the logs record</div>
      <div class="krb-logs" data-logs></div>
    </div>
  </div>

  <div class="krb-controls">
    <button class="krb-btn" data-prev aria-label="Previous hop">&larr; Step back</button>
    <button class="krb-btn krb-btn-key" data-next aria-label="Next hop">Step &rarr;</button>
    <button class="krb-btn" data-play aria-label="Play through all hops">Play</button>
    <button class="krb-btn" data-reset aria-label="Reset to first hop">Reset</button>
    <span class="krb-progress" data-progress></span>
  </div>

  <div class="krb-verdict" data-verdict>
    <div class="krb-verdict-q">From the AD security log alone, can you prove&hellip;</div>
    <div class="krb-verdict-pills">
      <span class="krb-pill" data-pill-whose><b>Whose</b> authority? <i data-whose>&mdash;</i></span>
      <span class="krb-pill" data-pill-which><b>Which</b> agent? <i data-which>&mdash;</i></span>
    </div>
    <div class="krb-verdict-note" data-vnote></div>
  </div>
</div>

<style>
.krb {
  --ink: #0c0c0d;
  --panel: #151517;
  --line: #2a2a2e;
  --paper: #e9e6df;
  --muted: #8d8d8a;
  --rust: #c25a2e;
  --rust-dim: #6b3a26;
  color: var(--paper);
  background: var(--ink);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 1.1rem 1.1rem 1.25rem;
  margin: 2rem 0;
  font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  font-size: 0.95rem;
  line-height: 1.5;
}
.krb * { box-sizing: border-box; }
.krb-title { font-weight: 700; font-size: 1.05rem; letter-spacing: 0.01em; }
.krb-sub { color: var(--muted); font-size: 0.85rem; margin-top: 0.2rem; }
.krb-tabs { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.9rem 0 0.6rem; }
.krb-tab {
  background: transparent; color: var(--muted);
  border: 1px solid var(--line); border-radius: 999px;
  padding: 0.34rem 0.8rem; font-size: 0.82rem; cursor: pointer;
  transition: color .15s, border-color .15s, background .15s;
}
.krb-tab:hover { color: var(--paper); }
.krb-tab[aria-selected="true"] {
  color: var(--ink); background: var(--paper); border-color: var(--paper); font-weight: 600;
}
.krb-blurb { color: var(--paper); font-size: 0.9rem; min-height: 1.4em; margin-bottom: 0.9rem; }
.krb-blurb b { color: var(--rust); }
.krb-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; }
@media (max-width: 640px) { .krb-grid { grid-template-columns: 1fr; } }
.krb-col { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 0.7rem 0.8rem; }
.krb-colhead {
  font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.12em;
  color: var(--muted); margin-bottom: 0.6rem;
}
.krb-trace { list-style: none; margin: 0; padding: 0; }
.krb-hop {
  position: relative; padding: 0.5rem 0 0.5rem 1.4rem;
  border-left: 2px solid var(--line); opacity: 0.32;
  transition: opacity .25s;
}
.krb-hop::before {
  content: ""; position: absolute; left: -7px; top: 0.78rem;
  width: 11px; height: 11px; border-radius: 50%;
  background: var(--ink); border: 2px solid var(--line);
}
.krb-hop.on { opacity: 1; }
.krb-hop.on::before { border-color: var(--rust); background: var(--rust); }
.krb-hop.done { opacity: 0.7; }
.krb-hop.done::before { border-color: var(--paper); }
.krb-hop:last-child { border-left-color: transparent; }
.krb-hop-actor { font-weight: 600; font-size: 0.9rem; }
.krb-hop-via { color: var(--muted); font-size: 0.78rem; }
.krb-hop-via b { color: var(--rust); font-weight: 600; }
.krb-claims { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.4rem; }
.krb-claim {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.72rem; padding: 0.12rem 0.4rem; border-radius: 4px;
  background: #1d1d20; border: 1px solid var(--line); color: var(--paper);
  white-space: nowrap;
}
.krb-claim.lost { color: var(--rust); border-color: var(--rust-dim); text-decoration: line-through; text-decoration-color: var(--rust); }
.krb-hop-note { color: var(--muted); font-size: 0.78rem; margin-top: 0.35rem; max-width: 44ch; }
.krb-hop:not(.on) .krb-hop-note, .krb-hop:not(.on) .krb-claims { display: none; }

.krb-logs { display: flex; flex-direction: column; gap: 0.55rem; min-height: 6rem; }
.krb-log {
  border: 1px solid var(--line); border-radius: 6px; padding: 0.45rem 0.55rem;
  background: #131316; opacity: 0; transform: translateY(4px); transition: opacity .25s, transform .25s;
}
.krb-log.show { opacity: 1; transform: none; }
.krb-log-src { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); }
.krb-log-line { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.78rem; margin-top: 0.15rem; }
.krb-log.lost { border-color: var(--rust-dim); }
.krb-log-miss { color: var(--rust); font-size: 0.75rem; margin-top: 0.2rem; }
.krb-log-miss b { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }

.krb-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem; margin-top: 0.9rem; }
.krb-btn {
  background: transparent; color: var(--paper); border: 1px solid var(--line);
  border-radius: 6px; padding: 0.36rem 0.7rem; font-size: 0.82rem; cursor: pointer;
  transition: border-color .15s, background .15s;
}
.krb-btn:hover { border-color: var(--paper); }
.krb-btn:disabled { opacity: 0.35; cursor: default; }
.krb-btn-key { background: var(--rust); border-color: var(--rust); color: #fff; font-weight: 600; }
.krb-btn-key:hover { border-color: #fff; }
.krb-progress { color: var(--muted); font-size: 0.8rem; margin-left: auto; }

.krb-verdict { margin-top: 1rem; padding-top: 0.9rem; border-top: 1px solid var(--line); }
.krb-verdict-q { font-size: 0.85rem; color: var(--muted); }
.krb-verdict-pills { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.5rem 0; }
.krb-pill {
  border: 1px solid var(--line); border-radius: 999px; padding: 0.3rem 0.75rem; font-size: 0.85rem;
}
.krb-pill i { font-style: normal; font-weight: 700; margin-left: 0.25rem; }
.krb-pill.yes { border-color: var(--paper); }
.krb-pill.yes i { color: var(--paper); }
.krb-pill.no { border-color: var(--rust); }
.krb-pill.no i { color: var(--rust); }
.krb-verdict-note { font-size: 0.88rem; min-height: 1.3em; }
.krb-verdict-note b { color: var(--rust); }
@media (prefers-reduced-motion: reduce) {
  .krb-hop, .krb-log { transition: none; }
}
</style>

<script>
(function () {
  var SCN = {
    bind: {
      label: "Simple bind",
      blurb: "The lazy version. One <b>bind DN</b>, one password, broad rights. Every action attributes to the same account forever.",
      hops: [
        { actor: "Alice (user)", via: "OIDC sign-in &rarr; <b>web tier</b>",
          claims: [["oid", "alice", 0], ["tid", "contoso.onmicrosoft", 0]],
          note: "Front-door sign-in at the web app. No AD principal yet." },
        { actor: "Web app", via: "<b>OBO</b> &rarr; middle-tier service",
          claims: [["sub", "oid: alice", 0], ["azp", "web-app", 0], ["scp", "Records.Read", 0]],
          note: "User preserved into the middle tier as an Entra subject." },
        { actor: "Middle-tier service", via: "<b>LDAP simple bind</b> &rarr; AD",
          claims: [["bindDN", "CN=svc-app,OU=svc,DC=corp", 0], ["password", "shared", 0], ["oid", "alice", 1], ["agent", "—", 1]],
          note: "User and agent both drop here. The bind is a username and a password. AD never sees Alice." },
        { actor: "Active Directory", via: "authorize as <b>svc-app</b>",
          claims: [["principal", "svc-app", 0], ["rights", "Domain-wide read/write", 0], ["onBehalfOf", "—", 1]],
          note: "Every read, every write — the directory attributes to one account. Forever." }
      ],
      logs: [
        { src: "Entra sign-in", line: "alice &rarr; web-app  09:14:02", lost: 0 },
        { src: "Middle-tier", line: "OBO ok: sub=alice", lost: 0 },
        { src: "AD security (4624)", line: "logon: svc-app  type=8  09:14:07", lost: 1, miss: "onBehalfOf: —  (no user, no agent — just the bind DN)" },
        { src: "AD security (4662)", line: "object access: svc-app  rights=*", lost: 1, miss: "actor: —  (every action is the same account)" }
      ],
      whose: 0, which: 0,
      note: "The principal collapses to one identity that is <b>neither the user nor the agent</b>. Authoritative AD logs cannot reconstruct either."
    },
    s4u: {
      label: "S4U constrained delegation",
      blurb: "The honest version. The KDC mints tickets <b>as the user</b>, to a <b>named SPN</b>, with <b>no user password</b>. The user survives. The agent does not.",
      hops: [
        { actor: "Alice (user)", via: "OIDC sign-in &rarr; <b>web tier</b>",
          claims: [["oid", "alice@contoso", 0], ["tid", "contoso.onmicrosoft", 0]],
          note: "Web-tier sign-in. No Kerberos yet — the KDC does not accept Entra tokens." },
        { actor: "Web app", via: "<b>OBO</b> &rarr; middle-tier service",
          claims: [["sub", "oid: alice", 0], ["azp", "web-app", 0]],
          note: "User-bound exchange into the middle tier. Now the join begins." },
        { actor: "Middle-tier service", via: "<b>S4U2Self</b> &rarr; KDC",
          claims: [["impersonator", "svc-middle$", 0], ["target", "ALICE@CORP", 0], ["password", "none", 0], ["agent", "—", 1]],
          note: "The service asks the KDC for a ticket to itself, on behalf of a named AD user. No user password involved. Requires UPN alignment via Entra Connect." },
        { actor: "Middle-tier service", via: "<b>S4U2Proxy</b> &rarr; KDC (allow-list)",
          claims: [["client", "ALICE@CORP", 0], ["targetSPN", "ldap/dc01.corp", 0], ["delegationTo", "specific SPN only", 0], ["agent", "—", 1]],
          note: "KDC mints a delegated ticket to <b>one</b> backend SPN that the service has been explicitly allowed to reach. Not unconstrained." },
        { actor: "Middle-tier service", via: "<b>SASL/GSSAPI</b> bind &rarr; AD",
          claims: [["bind", "GSSAPI", 0], ["principal", "ALICE@CORP", 0], ["via", "svc-middle$", 0], ["agent", "—", 1]],
          note: "No password on the wire. The directory sees Alice, delegated through the named service." },
        { actor: "Active Directory", via: "authorize <b>as Alice</b>",
          claims: [["principal", "ALICE@CORP", 0], ["delegatedBy", "svc-middle$", 0], ["agent", "—", 1]],
          note: "User half preserved all the way down. Agent half: still missing — Kerberos carries user + delegating service, not the platform agent above it." }
      ],
      logs: [
        { src: "Entra sign-in", line: "alice &rarr; web-app  09:14:02", lost: 0 },
        { src: "Middle-tier", line: "OBO ok: sub=alice", lost: 0 },
        { src: "KDC (4769)", line: "S4U2Self  client=ALICE@CORP  by=svc-middle$", lost: 0 },
        { src: "KDC (4769)", line: "S4U2Proxy  client=ALICE@CORP  spn=ldap/dc01.corp", lost: 0 },
        { src: "AD security (4624)", line: "logon: ALICE@CORP  type=3  via=svc-middle$  09:14:11", lost: 1, miss: "agent: —  (Kerberos names the delegating service, not agent X above it)" }
      ],
      whose: 1, which: 0,
      note: "User half: proven, end-to-end. Agent half: still missing — and the whole chain depends on the <b>Entra&harr;AD principal join</b> (UPN alignment via Entra Connect), which is the brittle part nobody puts on the slide."
    }
  };

  var ORDER = ["bind", "s4u"];

  function init(root) {
    var state = { scn: "bind", step: 0, timer: null };
    var els = {
      tabs: root.querySelectorAll(".krb-tab"),
      blurb: root.querySelector("[data-blurb]"),
      trace: root.querySelector("[data-trace]"),
      logs: root.querySelector("[data-logs]"),
      prev: root.querySelector("[data-prev]"),
      next: root.querySelector("[data-next]"),
      play: root.querySelector("[data-play]"),
      reset: root.querySelector("[data-reset]"),
      progress: root.querySelector("[data-progress]"),
      whose: root.querySelector("[data-whose]"),
      which: root.querySelector("[data-which]"),
      pillWhose: root.querySelector("[data-pill-whose]"),
      pillWhich: root.querySelector("[data-pill-which]"),
      vnote: root.querySelector("[data-vnote]")
    };

    function buildTrace() {
      var s = SCN[state.scn];
      els.trace.innerHTML = "";
      s.hops.forEach(function (h) {
        var li = document.createElement("li");
        li.className = "krb-hop";
        var chips = h.claims.map(function (c) {
          return '<span class="krb-claim' + (c[2] ? " lost" : "") + '">' + c[0] + ": " + c[1] + "</span>";
        }).join("");
        li.innerHTML =
          '<div class="krb-hop-actor">' + h.actor + "</div>" +
          '<div class="krb-hop-via">' + h.via + "</div>" +
          '<div class="krb-claims">' + chips + "</div>" +
          '<div class="krb-hop-note">' + h.note + "</div>";
        els.trace.appendChild(li);
      });
    }

    function buildLogs() {
      var s = SCN[state.scn];
      els.logs.innerHTML = "";
      s.logs.forEach(function (lg) {
        var d = document.createElement("div");
        d.className = "krb-log" + (lg.lost ? " lost" : "");
        d.innerHTML =
          '<div class="krb-log-src">' + lg.src + "</div>" +
          '<div class="krb-log-line">' + lg.line + "</div>" +
          (lg.miss ? '<div class="krb-log-miss">missing &mdash; <b>' + lg.miss + "</b></div>" : "");
        els.logs.appendChild(d);
      });
    }

    function render() {
      var s = SCN[state.scn];
      els.blurb.innerHTML = s.blurb;
      var hops = els.trace.querySelectorAll(".krb-hop");
      hops.forEach(function (li, i) {
        li.classList.toggle("on", i === state.step);
        li.classList.toggle("done", i < state.step);
      });
      var logEls = els.logs.querySelectorAll(".krb-log");
      var logsForStep = Math.min(state.step, logEls.length - 1);
      logEls.forEach(function (el, i) { el.classList.toggle("show", i <= logsForStep); });
      var last = state.step === s.hops.length - 1;
      els.progress.textContent = "Hop " + (state.step + 1) + " of " + s.hops.length;
      els.prev.disabled = state.step === 0;
      els.next.disabled = last;
      setVerdict(last ? s : null);
    }

    function setVerdict(s) {
      if (!s) {
        els.whose.innerHTML = "&mdash;"; els.which.innerHTML = "&mdash;";
        els.pillWhose.className = "krb-pill"; els.pillWhich.className = "krb-pill";
        els.vnote.innerHTML = "";
        return;
      }
      els.whose.textContent = s.whose ? "yes" : "no";
      els.which.textContent = s.which ? "yes" : "no";
      els.pillWhose.className = "krb-pill " + (s.whose ? "yes" : "no");
      els.pillWhich.className = "krb-pill " + (s.which ? "yes" : "no");
      els.vnote.innerHTML = s.note;
    }

    function setScenario(name) {
      stopPlay();
      state.scn = name; state.step = 0;
      els.tabs.forEach(function (t) {
        var sel = t.getAttribute("data-scn") === name;
        t.setAttribute("aria-selected", sel ? "true" : "false");
      });
      buildTrace(); buildLogs(); render();
    }

    function step(d) {
      var s = SCN[state.scn];
      state.step = Math.max(0, Math.min(s.hops.length - 1, state.step + d));
      render();
    }

    function stopPlay() {
      if (state.timer) { clearInterval(state.timer); state.timer = null; els.play.textContent = "Play"; }
    }
    function togglePlay() {
      if (state.timer) { stopPlay(); return; }
      var s = SCN[state.scn];
      if (state.step === s.hops.length - 1) state.step = 0;
      render();
      els.play.textContent = "Pause";
      state.timer = setInterval(function () {
        var sc = SCN[state.scn];
        if (state.step >= sc.hops.length - 1) { stopPlay(); return; }
        step(1);
      }, 1100);
    }

    els.tabs.forEach(function (t) {
      t.addEventListener("click", function () { setScenario(t.getAttribute("data-scn")); });
    });
    els.prev.addEventListener("click", function () { stopPlay(); step(-1); });
    els.next.addEventListener("click", function () { stopPlay(); step(1); });
    els.play.addEventListener("click", togglePlay);
    els.reset.addEventListener("click", function () { stopPlay(); state.step = 0; render(); });

    setScenario("bind");
  }

  var roots = document.querySelectorAll("[data-krb]");
  roots.forEach(init);
})();
</script>

<h2 id="the-legacy-corner-keycloak">The legacy corner: Keycloak</h2>
<p>Some apps don&rsquo;t speak Entra; they speak to the Keycloak that was supposed to be decommissioned two reorgs ago. The real shape here is brokering: Keycloak trusts Entra as an upstream identity provider over OIDC or SAML — metadata and signing-certificate exchange, claim and role mapping — and then issues <em>its own</em> tokens to the legacy apps behind it. (If your estate genuinely still runs WS-Federation in this corner, that is the oldest path and it works, but it is not where new trust should be built; OIDC/SAML brokering is.)</p>
<p>Every problem above still applies, and Keycloak adds a fresh way to break the invariant at the <em>mapping</em> layer. A sloppy mapper takes a narrow Entra group claim and lands it on a broad Keycloak realm role; or it gives every authenticated user a default <code>legacy-user</code> role that happens to imply write. Now Keycloak hands out a token broader than anything the user holds upstream — the second IdP has <em>laundered</em> privilege, cleanly, with a green check in the brokering config. Brokering a login is not the same as preserving authority across it, and token exchange inside Keycloak (configuration- and version-dependent, and easy to set to &ldquo;impersonate&rdquo; when you meant &ldquo;delegate&rdquo;) will happily widen the audience while you are not looking.</p>
<h2 id="where-this-actually-lives-agent-platforms">Where this actually lives: agent platforms</h2>
<p>Almost nobody writes the bespoke app above. They use a platform — Copilot Studio, Power Automate, n8n, dify — where a developer stands up the platform and non-developer &ldquo;makers&rdquo; build the actual &ldquo;agents&rdquo; on top. The auth models differ a lot between these, and the platform tends to normalize the differences badly, so resist the urge to treat them as one thing; the <em>pattern</em> is shared even where the products aren&rsquo;t.</p>
<p>The current enterprise-agent wave mostly entered through &ldquo;chat with your documents&rdquo; — an &ldquo;agent&rdquo; was a namespace in Azure AI Search plus some uploaded files and a system prompt — then grew connectors into SharePoint, Confluence, and Jira, and now, because <em>couldn&rsquo;t it also do something</em>, takes actions and talks MCP. MCP is not an auth model, by the way; it is a way to expose tools and context. It doesn&rsquo;t break your authorization. It surfaces the credential boundary you already failed to design.</p>
<p>And you failed to design it a year ago, on the <em>read</em> side, before anyone added a single write. The common low-code and DIY RAG build ingests SharePoint, Confluence, and Jira through document intelligence under one broad crawler credential, chunks and embeds the text into a shared index, and retrieves from that index with no per-user security trimming. The source ACL is a property of the system you copied <em>out of</em>; it is not in the chunk — and there is no convenient place to put it back. Upload files as knowledge to a Copilot Studio agent and they are flat: everyone who can reach the agent reads everything you ingested, full stop. Reach for Azure AI Search instead and you <em>can</em> carry a per-document ACL field and trim on it at query time — but that copy is frozen at index time and never syncs back to the origin, so the day someone loses access at the source your index keeps handing it to them. The only version that actually holds is to keep the origin URI on every chunk and re-check the caller&rsquo;s <em>live</em> access against the source system on each retrieval: correct, and brutal — an extra authorization round-trip per hit, against the same SaaS APIs that already rate-limit you, times every query from every user. At a hundred thousand seats nobody enables that by default. So the index ships flat, or it ships stale, and flat is what&rsquo;s in production. Share that bot with a colleague and they can now read, through it, things they could never open at source. The authorization failure is complete before the agent can do anything but talk. And the store you built is invisible to the tooling your privacy office runs on: Purview eDiscovery and Priva subject-rights search reach Exchange, SharePoint, OneDrive, and Teams — they do not reach an Azure AI Search index.<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup> One chunk of PII in there is personal data your DSAR and legal-hold workflows cannot enumerate, redact, or produce — a shadow copy of the corpus with the ACLs stripped and the compliance plumbing unplugged, and GDPR&rsquo;s upper tier prices that exposure at up to 4% of global turnover.</p>

<div class="rag" data-ragacl>
  <div class="rag-head">
    <div class="rag-title">Ask the bot. Watch what it returns to <span class="rag-bob">Bob</span>.</div>
    <div class="rag-sub">Same corpus, same query. Three common builds. Pick one and see which doors the index left open.</div>
  </div>

  <div class="rag-tabs" role="tablist" aria-label="Index build">
    <button class="rag-tab" data-scn="flat" role="tab">Flat ingest</button>
    <button class="rag-tab" data-scn="frozen" role="tab">ACL at index time</button>
    <button class="rag-tab" data-scn="live" role="tab">Live re-check per hit</button>
  </div>

  <div class="rag-blurb" data-blurb></div>

  <div class="rag-controls-top">
    <div class="rag-queries" role="group" aria-label="Query">
      <span class="rag-qlbl">Ask as Bob:</span>
      <button class="rag-q" data-q="salaries">"salaries?"</button>
      <button class="rag-q" data-q="roadmap">"Q3 roadmap?"</button>
    </div>
    <label class="rag-toggle">
      <input type="checkbox" data-revoke>
      <span class="rag-toggle-box" aria-hidden="true"></span>
      <span class="rag-toggle-lbl">Alice&rsquo;s access to <b>DOC-3</b> was revoked yesterday at source.</span>
    </label>
  </div>

  <div class="rag-grid">
    <div class="rag-col">
      <div class="rag-colhead">The corpus &middot; <span class="rag-mutedinline">5 docs, ingested by Alice</span></div>
      <div class="rag-corpus" data-corpus></div>
    </div>
    <div class="rag-col">
      <div class="rag-colhead">What the bot returns to <span class="rag-bob">Bob</span></div>
      <div class="rag-response" data-response>
        <div class="rag-empty">Pick a query above.</div>
      </div>
      <div class="rag-meter" data-meter></div>
    </div>
  </div>

  <div class="rag-verdict">
    <div class="rag-verdict-q">From the index alone, can you say&hellip;</div>
    <div class="rag-verdict-pills">
      <span class="rag-pill" data-pill-read><b>Bob</b> cannot read what he can&rsquo;t open at source? <i data-read>&mdash;</i></span>
      <span class="rag-pill" data-pill-dsar>DSAR / <b>Purview</b> can enumerate this corpus? <i data-dsar>&mdash;</i></span>
    </div>
    <div class="rag-verdict-note" data-vnote></div>
  </div>
</div>

<style>
.rag {
  --ink: #0c0c0d;
  --panel: #151517;
  --line: #2a2a2e;
  --paper: #e9e6df;
  --muted: #8d8d8a;
  --rust: #c25a2e;
  --rust-dim: #6b3a26;
  color: var(--paper);
  background: var(--ink);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 1.1rem 1.1rem 1.25rem;
  margin: 2rem 0;
  font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  font-size: 0.95rem;
  line-height: 1.5;
}
.rag * { box-sizing: border-box; }
.rag-title { font-weight: 700; font-size: 1.05rem; letter-spacing: 0.01em; }
.rag-bob { color: var(--rust); font-weight: 700; }
.rag-sub { color: var(--muted); font-size: 0.85rem; margin-top: 0.2rem; }
.rag-mutedinline { color: var(--muted); font-weight: 400; text-transform: none; letter-spacing: 0; }

.rag-tabs { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.9rem 0 0.6rem; }
.rag-tab {
  background: transparent; color: var(--muted);
  border: 1px solid var(--line); border-radius: 999px;
  padding: 0.34rem 0.8rem; font-size: 0.82rem; cursor: pointer;
  transition: color .15s, border-color .15s, background .15s;
}
.rag-tab:hover { color: var(--paper); }
.rag-tab[aria-selected="true"] {
  color: var(--ink); background: var(--paper); border-color: var(--paper); font-weight: 600;
}
.rag-blurb { color: var(--paper); font-size: 0.9rem; min-height: 1.4em; margin-bottom: 0.9rem; }
.rag-blurb b { color: var(--rust); }

.rag-controls-top {
  display: flex; flex-wrap: wrap; gap: 0.7rem 1.2rem; align-items: center;
  margin-bottom: 0.85rem;
}
.rag-queries { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; }
.rag-qlbl { color: var(--muted); font-size: 0.82rem; margin-right: 0.2rem; }
.rag-q {
  background: transparent; color: var(--paper);
  border: 1px solid var(--line); border-radius: 6px;
  padding: 0.32rem 0.7rem; font-size: 0.82rem; cursor: pointer;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  transition: border-color .15s, color .15s, background .15s;
}
.rag-q:hover { border-color: var(--paper); }
.rag-q.on { background: var(--rust); border-color: var(--rust); color: #fff; }

.rag-toggle { display: inline-flex; align-items: center; gap: 0.5rem; cursor: pointer; font-size: 0.82rem; color: var(--paper); }
.rag-toggle input { position: absolute; opacity: 0; pointer-events: none; }
.rag-toggle-box {
  width: 32px; height: 18px; border: 1px solid var(--line); border-radius: 999px;
  background: var(--panel); position: relative; transition: background .15s, border-color .15s;
  flex-shrink: 0;
}
.rag-toggle-box::after {
  content: ""; position: absolute; top: 2px; left: 2px;
  width: 12px; height: 12px; border-radius: 50%;
  background: var(--muted); transition: left .15s, background .15s;
}
.rag-toggle input:checked + .rag-toggle-box { background: var(--rust-dim); border-color: var(--rust); }
.rag-toggle input:checked + .rag-toggle-box::after { left: 16px; background: var(--rust); }
.rag-toggle input:focus-visible + .rag-toggle-box { outline: 2px solid var(--paper); outline-offset: 2px; }
.rag-toggle-lbl b { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--rust); font-weight: 700; }

.rag-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; }
@media (max-width: 640px) { .rag-grid { grid-template-columns: 1fr; } }
.rag-col { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 0.7rem 0.8rem; }
.rag-colhead {
  font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.12em;
  color: var(--muted); margin-bottom: 0.6rem;
}

.rag-corpus { display: flex; flex-direction: column; gap: 0.45rem; }
.rag-doc {
  border: 1px solid var(--line); border-radius: 6px;
  padding: 0.5rem 0.6rem; background: #131316;
  position: relative; transition: border-color .15s, opacity .15s;
}
.rag-doc-row1 { display: flex; align-items: baseline; gap: 0.5rem; }
.rag-doc-badge {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.7rem; font-weight: 700; color: var(--rust);
  border: 1px solid var(--rust-dim); border-radius: 3px;
  padding: 0.04rem 0.3rem; flex-shrink: 0;
}
.rag-doc-id {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.72rem; color: var(--muted);
}
.rag-doc-title { font-size: 0.85rem; color: var(--paper); margin-top: 0.15rem; font-weight: 500; }
.rag-doc-acl {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.72rem; color: var(--muted); margin-top: 0.3rem;
}
.rag-doc-acl b { color: var(--paper); font-weight: 500; }
.rag-doc.revoked .rag-doc-acl b.revoked-name {
  color: var(--rust); text-decoration: line-through; text-decoration-color: var(--rust);
}
.rag-doc.hit { border-color: var(--paper); }
.rag-doc.miss { opacity: 0.45; }

.rag-response { display: flex; flex-direction: column; gap: 0.45rem; min-height: 7rem; }
.rag-empty { color: var(--muted); font-style: italic; font-size: 0.85rem; padding: 0.4rem 0; }
.rag-hit {
  border: 1px solid var(--line); border-radius: 6px;
  padding: 0.5rem 0.6rem; background: #131316;
  opacity: 0; transform: translateY(4px);
  transition: opacity .25s, transform .25s, border-color .15s;
}
.rag-hit.show { opacity: 1; transform: none; }
.rag-hit.leak { border-color: var(--rust-dim); }
.rag-hit.ok { border-color: var(--paper); }
.rag-hit-row1 { display: flex; align-items: baseline; gap: 0.5rem; }
.rag-hit-title { font-size: 0.85rem; color: var(--paper); margin-top: 0.15rem; }
.rag-hit-overlay {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.72rem; margin-top: 0.3rem;
}
.rag-hit-overlay.bad { color: var(--rust); }
.rag-hit-overlay.good { color: var(--paper); }
.rag-hit-overlay b { font-weight: 700; }

.rag-meter {
  margin-top: 0.55rem; padding-top: 0.5rem;
  border-top: 1px dashed var(--line);
  font-size: 0.75rem; color: var(--muted);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.rag-meter b { color: var(--paper); font-weight: 700; }
.rag-meter .rag-meter-warn { color: var(--rust); }

.rag-verdict { margin-top: 1rem; padding-top: 0.9rem; border-top: 1px solid var(--line); }
.rag-verdict-q { font-size: 0.85rem; color: var(--muted); }
.rag-verdict-pills { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.5rem 0; }
.rag-pill {
  border: 1px solid var(--line); border-radius: 999px; padding: 0.3rem 0.75rem; font-size: 0.85rem;
}
.rag-pill b { font-weight: 700; }
.rag-pill i { font-style: normal; font-weight: 700; margin-left: 0.25rem; }
.rag-pill.yes { border-color: var(--paper); }
.rag-pill.yes i { color: var(--paper); }
.rag-pill.no { border-color: var(--rust); }
.rag-pill.no i { color: var(--rust); }
.rag-verdict-note { font-size: 0.88rem; min-height: 1.3em; }
.rag-verdict-note b { color: var(--rust); }
.rag-verdict-note code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.82rem; color: var(--paper);
}

@media (prefers-reduced-motion: reduce) {
  .rag-hit, .rag-doc, .rag-toggle-box, .rag-toggle-box::after { transition: none; }
}
</style>

<script>
(function () {
  
  var BOB_GROUPS = ["Eng-All", "SRE"]; 

  
  
  
  var DOCS = [
    { id: "DOC-1", src: "SP", title: "Q3 roadmap.docx",
      sourceAcl: ["Alice", "Bob", "Eng-All"], indexAcl: ["Alice", "Bob", "Eng-All"],
      topic: "roadmap" },
    { id: "DOC-2", src: "SP", title: "Comp band guidance 2026.pdf",
      sourceAcl: ["Alice", "HR-Read"], indexAcl: ["Alice", "HR-Read"],
      topic: "salaries" },
    { id: "DOC-3", src: "SP", title: "Strategy deck — merger Q4",
      sourceAcl: ["Alice", "Bob", "Eng-All"], indexAcl: ["Alice", "Bob", "Eng-All"],
      
      revokedSourceAcl: ["Alice", "Eng-All"],
      revokedTarget: "Bob",
      topic: "roadmap" },
    { id: "DOC-4", src: "CF", title: "Salary review notes",
      sourceAcl: ["Alice", "HR-Read"], indexAcl: ["Alice", "HR-Read"],
      topic: "salaries" },
    { id: "DOC-5", src: "JR", title: "INC-4471 prod outage post-mortem",
      sourceAcl: ["Alice", "Bob", "SRE"], indexAcl: ["Alice", "Bob", "SRE"],
      topic: "roadmap" }
  ];

  var QUERIES = {
    salaries: { label: '"salaries?"', topic: "salaries" },
    roadmap:  { label: '"Q3 roadmap?"', topic: "roadmap" }
  };

  var SCN = {
    flat: {
      label: "Flat ingest",
      blurb: "One shared crawler credential ingested everything. <b>No per-doc ACL</b> on the chunks. Reach the bot, reach the corpus."
    },
    frozen: {
      label: "ACL at index time",
      blurb: "Per-doc ACL field, trimmed at query time. <b>Correct today.</b> Frozen at ingest, never re-syncs with the origin."
    },
    live: {
      label: "Live re-check per hit",
      blurb: "Trim against the <b>origin system</b> on every hit. Correct — and brutal: an extra round-trip per chunk, against the APIs that already rate-limit you."
    }
  };

  function canRead(acl) {
    
    if (acl.indexOf("Bob") !== -1) return true;
    for (var i = 0; i < BOB_GROUPS.length; i++) {
      if (acl.indexOf(BOB_GROUPS[i]) !== -1) return true;
    }
    return false;
  }

  function effectiveSourceAcl(doc, revoked) {
    if (revoked && doc.revokedSourceAcl) return doc.revokedSourceAcl;
    return doc.sourceAcl;
  }

  
  
  function botReturns(scn, queryKey, revoked) {
    var q = QUERIES[queryKey];
    if (!q) return [];
    var hits = DOCS.filter(function (d) { return d.topic === q.topic; });

    if (scn === "flat") {
      
      return hits.map(function (d) {
        var srcAcl = effectiveSourceAcl(d, revoked);
        var bobAtSource = canRead(srcAcl);
        return {
          doc: d,
          status: bobAtSource ? "ok" : "leak",
          reason: bobAtSource
            ? "Bob can read this at source &mdash; returned, ACL never checked."
            : "Bob has <b>no access at source</b>. ACL stripped at ingest."
        };
      });
    }

    if (scn === "frozen") {
      
      return hits
        .filter(function (d) { return canRead(d.indexAcl); })
        .map(function (d) {
          var srcAcl = effectiveSourceAcl(d, revoked);
          var bobAtSource = canRead(srcAcl);
          if (bobAtSource) {
            return { doc: d, status: "ok", reason: "Index ACL and source agree. Bob reads it." };
          }
          
          return {
            doc: d, status: "leak",
            reason: "Revoked at source <b>yesterday</b> &mdash; still in the index. Frozen snapshot leaks it."
          };
        });
    }

    
    return hits
      .filter(function (d) { return canRead(effectiveSourceAcl(d, revoked)); })
      .map(function (d) {
        return { doc: d, status: "ok", reason: "Origin re-checked. Bob is on the current ACL." };
      });
  }

  function roundTrips(scn, queryKey) {
    if (scn !== "live") return 0;
    var q = QUERIES[queryKey];
    if (!q) return 0;
    return DOCS.filter(function (d) { return d.topic === q.topic; }).length;
  }

  function verdict(scn, queryKey, revoked) {
    
    
    var returned = botReturns(scn, queryKey, revoked);
    var anyLeak = returned.some(function (r) { return r.status === "leak"; });

    if (scn === "flat") {
      return {
        read: false, dsar: false,
        note: "Every chunk Alice ever ingested is reachable through the bot. Share the URL and you have shared the corpus. " +
              "And the index is a <b>shadow copy</b> your privacy office can&rsquo;t reach: Purview eDiscovery and Priva reach " +
              "Exchange, SharePoint, OneDrive, Teams &mdash; not an <code>Azure AI Search</code> index."
      };
    }
    if (scn === "frozen") {
      
      
      
      return {
        read: !revoked, dsar: false,
        note: revoked
          ? "Source ACL changed yesterday. Index didn&rsquo;t. <b>Frozen snapshot is now wrong</b> &mdash; and there is no convenient hook " +
            "to re-sync ACLs on every doc the corpus has ever seen. Compliance reach unchanged: still no Purview, still no Priva."
          : "Trims correctly <b>today</b>. The day someone loses access at source, the index keeps handing it to them. " +
            "Compliance reach unchanged: still no Purview, still no Priva."
      };
    }
    
    return {
      read: true, dsar: false,
      note: "Read-side is honest &mdash; at the cost of an extra origin call per hit, against APIs that already <b>rate-limit you</b>. " +
            "The index is still invisible to Purview and Priva: one chunk of PII in there is personal data your DSAR and " +
            "legal-hold workflows cannot enumerate, redact, or produce."
    };
  }

  function init(root) {
    var state = { scn: "flat", q: null, revoked: false };

    var els = {
      tabs: root.querySelectorAll(".rag-tab"),
      blurb: root.querySelector("[data-blurb]"),
      qBtns: root.querySelectorAll(".rag-q"),
      revoke: root.querySelector("[data-revoke]"),
      corpus: root.querySelector("[data-corpus]"),
      response: root.querySelector("[data-response]"),
      meter: root.querySelector("[data-meter]"),
      pillRead: root.querySelector("[data-pill-read]"),
      pillDsar: root.querySelector("[data-pill-dsar]"),
      read: root.querySelector("[data-read]"),
      dsar: root.querySelector("[data-dsar]"),
      vnote: root.querySelector("[data-vnote]")
    };

    function renderCorpus() {
      els.corpus.innerHTML = "";
      DOCS.forEach(function (d) {
        var srcAcl = effectiveSourceAcl(d, state.revoked);
        var aclHtml = srcAcl.map(function (name) {
          return "<b>" + name + "</b>";
        }).join(", ");
        var revokedClass = "";
        var revokedNoteHtml = "";
        if (state.revoked && d.revokedTarget && d.revokedSourceAcl) {
          revokedClass = " revoked";
          
          revokedNoteHtml = " &middot; <b class=\"revoked-name\">" + d.revokedTarget + "</b>";
        }
        var div = document.createElement("div");
        div.className = "rag-doc" + revokedClass;
        div.setAttribute("data-doc-id", d.id);
        div.innerHTML =
          '<div class="rag-doc-row1">' +
            '<span class="rag-doc-badge">' + d.src + '</span>' +
            '<span class="rag-doc-id">' + d.id + '</span>' +
          '</div>' +
          '<div class="rag-doc-title">' + d.title + '</div>' +
          '<div class="rag-doc-acl">source ACL: ' + aclHtml + revokedNoteHtml + '</div>';
        els.corpus.appendChild(div);
      });
    }

    function renderResponse() {
      
      els.response.innerHTML = "";
      
      var docEls = els.corpus.querySelectorAll(".rag-doc");
      docEls.forEach(function (el) { el.classList.remove("hit", "miss"); });

      if (!state.q) {
        var empty = document.createElement("div");
        empty.className = "rag-empty";
        empty.textContent = "Pick a query above.";
        els.response.appendChild(empty);
        els.meter.innerHTML = "";
        renderVerdict();
        return;
      }

      var returned = botReturns(state.scn, state.q, state.revoked);
      var returnedIds = {};
      returned.forEach(function (r) { returnedIds[r.doc.id] = true; });

      
      docEls.forEach(function (el) {
        var id = el.getAttribute("data-doc-id");
        if (returnedIds[id]) el.classList.add("hit");
        else el.classList.add("miss");
      });

      if (returned.length === 0) {
        var none = document.createElement("div");
        none.className = "rag-empty";
        none.innerHTML = "No hits returned to Bob &mdash; ACL trimmed everything for this query.";
        els.response.appendChild(none);
      } else {
        returned.forEach(function (r, i) {
          var div = document.createElement("div");
          div.className = "rag-hit " + (r.status === "leak" ? "leak" : "ok");
          var overlayClass = r.status === "leak" ? "bad" : "good";
          div.innerHTML =
            '<div class="rag-hit-row1">' +
              '<span class="rag-doc-badge">' + r.doc.src + '</span>' +
              '<span class="rag-doc-id">' + r.doc.id + '</span>' +
            '</div>' +
            '<div class="rag-hit-title">' + r.doc.title + '</div>' +
            '<div class="rag-hit-overlay ' + overlayClass + '">' +
              (r.status === "leak" ? "&#9888; LEAK &middot; " : "OK &middot; ") + r.reason +
            '</div>';
          els.response.appendChild(div);
          
          setTimeout(function () { div.classList.add("show"); }, 30 + i * 90);
        });
      }

      
      var rt = roundTrips(state.scn, state.q);
      if (state.scn === "live") {
        els.meter.innerHTML =
          "API round-trips this query: <b>" + rt + "</b> &nbsp;&middot;&nbsp; " +
          '<span class="rag-meter-warn">and the SaaS already rate-limits you.</span>';
      } else if (state.scn === "frozen") {
        els.meter.innerHTML = "API round-trips this query: <b>0</b> &nbsp;&middot;&nbsp; ACL field carried in the index.";
      } else {
        els.meter.innerHTML = "API round-trips this query: <b>0</b> &nbsp;&middot;&nbsp; ACL never consulted.";
      }

      renderVerdict();
    }

    function renderVerdict() {
      var v = verdict(state.scn, state.q, state.revoked);
      els.read.textContent = v.read ? "yes" : "no";
      els.dsar.textContent = v.dsar ? "yes" : "no";
      els.pillRead.className = "rag-pill " + (v.read ? "yes" : "no");
      els.pillDsar.className = "rag-pill " + (v.dsar ? "yes" : "no");
      els.vnote.innerHTML = v.note;
    }

    function setScenario(name) {
      state.scn = name;
      els.tabs.forEach(function (t) {
        var sel = t.getAttribute("data-scn") === name;
        t.setAttribute("aria-selected", sel ? "true" : "false");
      });
      els.blurb.innerHTML = SCN[name].blurb;
      renderResponse();
    }

    function setQuery(q) {
      state.q = q;
      els.qBtns.forEach(function (b) {
        b.classList.toggle("on", b.getAttribute("data-q") === q);
      });
      renderResponse();
    }

    function setRevoked(v) {
      state.revoked = !!v;
      renderCorpus();
      renderResponse();
    }

    els.tabs.forEach(function (t) {
      t.addEventListener("click", function () { setScenario(t.getAttribute("data-scn")); });
    });
    els.qBtns.forEach(function (b) {
      b.addEventListener("click", function () { setQuery(b.getAttribute("data-q")); });
    });
    els.revoke.addEventListener("change", function () { setRevoked(els.revoke.checked); });

    
    renderCorpus();
    setScenario("flat");
  }

  var roots = document.querySelectorAll("[data-ragacl]");
  roots.forEach(init);
})();
</script>

<p>Then the maker adds write — create tickets, update lists, publish pages — and the same broken model gets hands. How that write is wired runs down a depressingly short ladder. At the bottom, and far too common, the maker pastes in a high-privilege API key, sometimes a personal access token, and every action the agent takes belongs to that one credential. Jira and Confluence don&rsquo;t consume Entra OBO at all — and the deeper problem there isn&rsquo;t a missing flow, it&rsquo;s that the identity domains don&rsquo;t compose: Entra&rsquo;s notion of Alice and Atlassian&rsquo;s notion of Alice are different objects with different authority, and nothing reconciles them but a human pasting a token. One rung up, the platform actually preserves the human at the target — often by holding a per-user OAuth refresh token for the SaaS behind a single shared connector app — but the <em>agent</em> collapses into that one platform identity: the client id is identical for every agent every maker ever built, and the resource is left correlating on platform-internal run IDs that, more often than not, were never propagated to its logs. At the top, rare enough that I would not assume it exists unless you can point to both halves in the downstream logs, each agent has its own identity and clean per-user delegation — and even then the resource only sees the full trace if someone propagated a correlation id or modeled the actor outside the token, because the token alone will not carry it.</p>
<h2 id="down-to-where-the-value-is-actually-made">Down to where the value is actually made</h2>
<p>Read everything above as the warm-up. For an AI-first enterprise, the prize is not another assistant that rewrites slides. The prize is the shop floor. That is where the company&rsquo;s margin is <em>made</em> — a line that produces, a batch that yields, a setpoint that decides whether the next twelve hours meet spec or scrap. If your chatbot can summarise an OKR document but it cannot ask <em>is line 3 going to make the morning deadline</em> and adjust the schedule when the answer is no, your AI program has not reached the part of the business that pays for it. The fully-integrated version of the strategy deck connects agents to MES, to the historian, to the SCADA HMI, to the recipe executor. There is no realistic version of that deck where it does not, and the roadmaps already reflect it. What that closes is the same loop the rest of this post has been about — <em>whose</em> authority, <em>which</em> agent — except that the chain now terminates somewhere that physically moves.</p>
<p>The layer those connectors land on is not the layer the rest of this post prepares you for. The system on the other end has no concept of <em>Alice asked for this</em>; it has a concept of <em>the right thing is on the wire</em>, and it logs accordingly. <em>Whose authority</em> and <em>which agent</em> are not first-class concepts in Modbus or in the historian&rsquo;s bulk-write API — the protocols and conventions at this tier were not designed for the kind of IT-style per-action delegation an agent assumes. Modbus has no authentication at all. OPC Classic rides DCOM, and the Windows identity it ends up using has, in many estates, collapsed to a shared operator or service login the integrator pasted in years ago. Plenty of PLCs and HMIs still gate on a single panel password — sometimes the one printed in the manual. OPC UA, by contrast, has a sane identity story with per-user certificates and session establishment, and the moment your estate runs serious UA <em>and the integrator wired it per-user rather than under one shared client cert</em>, most of this stops applying. The operative claim is not &ldquo;all OT is laminated passwords.&rdquo; Mature estates have validated MES workflows, electronic batch records, domain-integrated HMIs, and safety layers that take change seriously. The claim is narrower and harder to dismiss: the tier the agent roadmap intends to reach was built around different controls — the air gap, then the Purdue-model boundary, then operational mediation — and &ldquo;IT/OT convergence&rdquo; has been the polite word for dissolving those for fifteen years. The dissolving is the point. The dissolving is what makes the AI-first manufacturing story possible at all.</p>
<p>Hand an enterprise agent a connector into that tier and the two questions stop being unanswered and start being unrepresentable. &ldquo;Whose authority&rdquo; collapses to network position. &ldquo;Which agent&rdquo; collapses to whatever account the integrator put in the script. Every action ever taken through that path attributes, in the historian, to one login a dozen people share and three more know the password to. That is not a logging gap. That is the field by design — and the field is exactly what the AI roadmap intends to reach.</p>
<p>A plant-floor reader will object, fairly, that this never makes it to a real setpoint. There are DMZs and read-only historian replicas, there is management of change, there are interlocks and safety-instrumented systems, and there is a culture that does not trust a model to twist a valve. All true, all real controls. The trajectory is also real, and it is how every integration of this kind has gone in the last two years. The first version is read-only — the agent reads tags and answers questions about yield. The second is <em>and could it also open a maintenance ticket</em>. The third is <em>and could it adjust the next batch&rsquo;s parameters, within the validated window</em>. Each step is defensible in isolation; each step accretes a write path through scheduling, recipes, maintenance, or batch parameters; and none of those steps re-asks whether the chain that issues the write can name the person and the agent behind it. A misrouted agent write to a Jira ticket is a noisy email. A misrouted agent write to a setpoint, or a recipe row in a batch executor, moves actuators. The blast radius of <em>which agent, on whose behalf, did the thing</em> stops being the books and becomes the line, and on a bad day, the people next to it. OT teams have lived with shared-credential pain for decades and built compensating practice around it; what is new — and what the AI-first roadmap specifically introduces — is putting an LLM-driven actor on the IT side of a chain that already could not tell you who acted, and pointing it at the part of the company that physically moves.</p>
<h2 id="which-agent-bricked-prod">Which agent bricked prod?</h2>
<p>Here is what actually happens, stripped of charity. The technical user the agent runs as is over-privileged, or it is an API key, and someone shrugs and lets it rip. It works, until the day it doesn&rsquo;t.</p>
<p>Alice asks the internal agent to &ldquo;tidy up stale tickets before the release.&rdquo; It does — with the maker&rsquo;s admin PAT, across projects Alice can&rsquo;t even see — and one of the tickets it bulk-closes is the <em>change-freeze hold</em> on a build whose forward-only schema migration had been flagged &ldquo;not yet reviewed.&rdquo; Closing the hold removes the gate. The deploy pipeline takes the green light, cuts the build, runs the migration against prod, takes an exclusive lock on the orders table, and finishes in a state the rollback playbook cannot undo because the migration is one-way. The site is down for three and a half hours while the on-call DBA restores the orders table from the previous night&rsquo;s backup and reconciles the gap from the write-ahead log by hand. Now reconstruct the <em>decision</em>. Jira&rsquo;s author field says the maker. Entra&rsquo;s sign-in logs have nothing tied to the change, because it never went through Entra as Alice. The platform has a run ID nobody forwarded anywhere the resource could see. So you reconcile sign-in timestamps across three systems whose clocks don&rsquo;t quite agree, trying to prove which agent, on whose behalf, lifted the hold — and from the authoritative logs you can&rsquo;t. You can <em>guess</em> from chat transcripts and adjacent timestamps; you cannot <em>prove</em> it. The forensics aren&rsquo;t hard. They&rsquo;re <em>impossible</em>, by construction.</p>
<p>That is the part that should worry a regulated shop more than any jailbreak. Financial-record provenance regimes — Germany&rsquo;s GoBD, the internal-control attestations behind SOX, the audit expectations baked into SOC 2 and ISO 27001 — all want the same property: a firm has to be able to show how an entry in its books came to be. Provenance, not vibes. When a shared key made the change and you can&rsquo;t tie it to a person acting within their rights, you don&rsquo;t just lose the one entry; you lose confidence in the ledger around it. The property that fails the auditor on a quiet Tuesday is the same one that fails the incident bridge during an outage: if you cannot separate agent from user, your logs are not evidence, they are material for an argument you will lose.</p>
<h2 id="why-this-hasnt-blown-up-yet">Why this hasn&rsquo;t blown up yet</h2>
<p>Every failure named in this post has, so far, been bounded by accident. The maker&rsquo;s PAT only flattens what the maker happened to wire it into. The flat RAG index leaks what one team crawled, not the company. The shared OT login lights up one plant, not the next. The agents do not talk to each other yet, and the compartments are doing all the work.</p>
<p>That is not a control. It is the property of the technology being eighteen months old. The compartment walls are about to come down, because the business case for agent-to-agent writes itself in a sentence: the onboarding agent calls the IT-provisioning agent calls the facilities agent calls the payroll agent, no human in the loop, in seconds. The first time a board sees that demo, the second time someone insists their competitor already runs it, the question is settled. It will ship.</p>
<p>The day it does, every one of these flatten-and-collapse patterns turns transitive. An attacker with a foothold in any one agent does not need a payload, a binary, a C2 channel, or a CVE — they need polite REST calls to the next agent over. <em>Living off the land</em> becomes <em>living off the chatbot</em>: lateral movement is the published API, and privilege escalation is whichever neighbour has a broader connector. The onboarding agent now holds, transitively, the union of every connector every agent it can reach ever held — and because none of those connectors carry a faithful actor chain past their first hop, nothing downstream of the breach can tell that the action it just executed was the seventh hop of an attack rather than the first.</p>
<p>The narrow claim, the one I am willing to defend, is this: commercial pressure will push inter-agent orchestration into production before actor-chain controls are mature. That is enough; the rest follows. The case against — &ldquo;we cannot audit it&rdquo; — sounds, in the room, like the people who said the same thing about cloud, mobile, and SaaS, and the room mostly stopped listening to them. So A2A ships, and the first incident makes the policy retroactively. The discovery phase will feel familiar to anyone who has worked one of the big transitive-dependency post-mortems of the last decade: <em>wait, that thing could call this thing?</em>, across weeks, while the answer to <em>which agent, on whose behalf</em> is a shrug and a timestamp adjacency. The bar I am about to state for Part 1 is going to read, in two years, as the floor we should have built before we let them call each other.</p>
<p>So the bar for Part 1 is lower than any architecture diagram, and it just restates the two questions as an evidence requirement. If your system cannot show, from its own authoritative logs, <em>whose</em> authority an action carried and <em>which</em> agent exercised it — along with the credential identity it ran under, what it was authorized to do, and what it actually did downstream — then you do not have agent authentication. You have a shared account with an LLM bolted onto it. The next posts are about closing that gap on purpose. This one is just about admitting how wide it is.</p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>Microsoft, <a href="https://learn.microsoft.com/entra/identity-platform/v2-oauth2-on-behalf-of-flow">&ldquo;Microsoft identity platform and OAuth 2.0 On-Behalf-Of flow&rdquo;</a> — the <code>jwt-bearer</code> grant, <code>requested_token_use=on_behalf_of</code>, the delegated-only constraint, and the audience/consent preconditions.&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>Scope confirmed against Microsoft Learn: <a href="https://learn.microsoft.com/purview/edisc">&ldquo;Learn about eDiscovery&rdquo;</a> (supported services are Exchange Online, Microsoft Teams, Microsoft 365 Groups, OneDrive, SharePoint, and Viva Engage) and <a href="https://learn.microsoft.com/privacy/priva/priva-overview">&ldquo;Learn about Microsoft Priva&rdquo;</a> (&ldquo;Priva evaluates data that is only within your organization&rsquo;s Microsoft 365 environment&rdquo; — Exchange Online, SharePoint Online, OneDrive for Business, Teams). Neither enumerates an Azure AI Search index as a native source.&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded>
    </item>
  </channel>
</rss>
