<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ketan Kamble</title>
    <description>The latest articles on DEV Community by Ketan Kamble (@ketan_kamble_d643aad66d22).</description>
    <link>https://dev.to/ketan_kamble_d643aad66d22</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4074629%2F6f2a0f10-dfc7-41ab-8ddc-2aa185ac4f09.png</url>
      <title>DEV Community: Ketan Kamble</title>
      <link>https://dev.to/ketan_kamble_d643aad66d22</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ketan_kamble_d643aad66d22"/>
    <language>en</language>
    <item>
      <title>The 403 that started Zero-Access: read-only by architecture, not trust</title>
      <dc:creator>Ketan Kamble</dc:creator>
      <pubDate>Wed, 12 Aug 2026 10:58:54 +0000</pubDate>
      <link>https://dev.to/ketan_kamble_d643aad66d22/the-403-that-started-zero-access-read-only-by-architecture-not-trust-439e</link>
      <guid>https://dev.to/ketan_kamble_d643aad66d22/the-403-that-started-zero-access-read-only-by-architecture-not-trust-439e</guid>
      <description>&lt;p&gt;&lt;em&gt;A 403 I fully expected to be a 200 — in a lab, not a bug — turned into the design rule for a whole project: let an AI answer questions about thousands of endpoints without ever giving it a way to change one.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Everything else on my site — the ten read-only collectors, the Power BI reports, the AI agent that answers questions about a fleet without touching it — points back to one moment in a lab tenant: a &lt;strong&gt;403&lt;/strong&gt; I fully expected to be a &lt;strong&gt;200&lt;/strong&gt;. Not a bug. The system working exactly as designed. That 403 is the reason the whole project is built the way it is, and this is the artifact that came out of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The proof, in one screen:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;PS&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;\read-only-gate.ps1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Zero-Access&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;read-only&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;gate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;identity:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;aa-collectors&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;guid&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DeviceManagementManagedDevices.Read.All&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Directory.Read.All&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;WRITE&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DeviceManagementManagedDevices.ReadWrite.All&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nx"&gt;READ-ONLY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;GATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;FAILED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;write-capable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Graph&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;roles&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;granted:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;DeviceManagementManagedDevices.ReadWrite.All.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Refusing&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;collector.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;One write scope slips onto the identity, and the collector never makes its first call. The boundary is executable, not a promise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The payoff&lt;/strong&gt; — Most "read-only" tooling is read-only because the author &lt;em&gt;intended&lt;/em&gt; it to be: one wrong scope, one copied snippet, and it quietly gains the ability to change your tenant. This pattern makes the boundary a thing the machine checks on every run. The difference is the gap between &lt;em&gt;"trust me, it only reads"&lt;/em&gt; and &lt;em&gt;"it cannot run if it can write."&lt;/em&gt; One of those you can hand to an AI.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;I was reproducing a SharePoint scenario in a personal lab with &lt;strong&gt;Sites.Selected&lt;/strong&gt; — the app-only Graph model where an application holds the &lt;em&gt;Sites.Selected&lt;/em&gt; permission but can still only reach the specific sites it's been individually granted. I'd granted the app-role, consented it, watched it light up green in the portal. Then the call to read a site came back &lt;strong&gt;403 Forbidden&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;My first instinct was the usual checklist: wrong scope, missing admin consent, token not refreshed. All fine. The permission &lt;em&gt;was&lt;/em&gt; there. What I'd missed is the whole point of Sites.Selected: holding the permission grants you &lt;strong&gt;nothing&lt;/strong&gt; until a second, per-site grant is written on the site itself. The app had the key to the building and no key to any room. The platform said no — and it was right to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The realisation
&lt;/h2&gt;

&lt;p&gt;The interesting part wasn't the fix. It was &lt;em&gt;why the deny felt so solid.&lt;/em&gt; The boundary held even though the app "had permission," because access wasn't a matter of the app behaving — it was a matter of a grant that simply didn't exist. Nothing was trusting the application to stay in its lane. There was no lane to leave.&lt;/p&gt;

&lt;p&gt;That reframed a problem I'd been circling for months: &lt;strong&gt;how do you let an AI answer questions about a fleet of thousands of endpoints without ever giving it a way to break one?&lt;/strong&gt; Every answer I'd tried was some flavour of "give it access and trust it not to misuse it" — better prompts, tighter tool definitions, a human in the loop. The 403 pointed at a different answer. Don't trust the boundary. &lt;em&gt;Build it so there's no lane to leave.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern that fell out of it
&lt;/h2&gt;

&lt;p&gt;The Zero-Access Pattern is that idea applied end to end:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The AI never touches Microsoft Graph or a live tenant. It reads &lt;strong&gt;sanitised CSV snapshots&lt;/strong&gt; and nothing else. Its access to your systems is zero — hence the name.&lt;/li&gt;
&lt;li&gt;The snapshots are produced by scheduled, read-only collectors running under a &lt;strong&gt;Managed Identity&lt;/strong&gt; — with &lt;strong&gt;only &lt;code&gt;.Read.All&lt;/code&gt;&lt;/strong&gt; Graph roles. No secrets to leak, no write path to misuse.&lt;/li&gt;
&lt;li&gt;And the identity itself is &lt;strong&gt;gated&lt;/strong&gt; — checked on every run — so the "only reads" claim is verified by a script, not asserted in a README.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last piece is the founding artifact, and it's small on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate, in one screen
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;read-only-gate.ps1&lt;/code&gt; runs &lt;em&gt;as&lt;/em&gt; the collector's identity, asks Graph which application permissions that identity actually holds, and refuses to continue if any of them can write. Dot-source it at the top of a runbook and the collector aborts before its first call the day someone grants it a write scope by mistake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;\read-only-gate.ps1&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="c"&gt;# throws if the identity can write — the collector never runs&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# ...collector logic only reaches here on a clean, read-only identity...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's deliberately not clever. It reads the identity's granted roles, classifies each as read (&lt;code&gt;.Read.All&lt;/code&gt; / &lt;code&gt;.Read&lt;/code&gt;) or write, and turns a silent misconfiguration into a loud, early failure. The value isn't the code — it's that the boundary is now something the machine enforces instead of something you hope stayed true.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ Honest about what it is&lt;/strong&gt; — The gate checks the roles that are &lt;em&gt;granted&lt;/em&gt;. It doesn't replace good hygiene — the first defence is never granting a write scope at all. It's the backstop that catches the day discipline slips. Every figure and identity name here is from a &lt;strong&gt;personal lab&lt;/strong&gt;; verify the scopes and behaviour in your own tenant before relying on it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where it goes from here
&lt;/h2&gt;

&lt;p&gt;This is beat one. The same lab reproduction that produced the 403 is the exact material for a call-by-call teardown of what Graph &lt;em&gt;actually does&lt;/em&gt; under a routine admin action — that's the next thing I'm writing.&lt;/p&gt;

&lt;p&gt;The founding idea is just this: give the AI the reports, never the systems — and make "never" something a script can prove.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code is open source
&lt;/h2&gt;

&lt;p&gt;The whole thing — the read-only collectors, the Power BI reports, the agent, and the gate script — is on GitHub under MIT. Clone it, break it, and tell me where the boundary leaks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;→ &lt;a href="https://github.com/KetanKamble3894/zero-access-agent" rel="noopener noreferrer"&gt;github.com/KetanKamble3894/zero-access-agent&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you want the short version of the architecture, it's in the &lt;a href="https://ketankamble.com/projects/zero-access-agent/" rel="noopener noreferrer"&gt;Zero-Access Agent overview&lt;/a&gt;; to stand the collection layer up yourself, start with &lt;a href="https://ketankamble.com/projects/zero-access-agent/azure-automation-setup/" rel="noopener noreferrer"&gt;setting up the collection layer&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  References — Microsoft documentation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sites.Selected&lt;/strong&gt; — &lt;a href="https://learn.microsoft.com/en-us/graph/permissions-selected-overview" rel="noopener noreferrer"&gt;Overview of Selected permissions in OneDrive and SharePoint&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graph permissions reference&lt;/strong&gt; — &lt;a href="https://learn.microsoft.com/en-us/graph/permissions-reference" rel="noopener noreferrer"&gt;Microsoft Graph permissions reference&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions and consent&lt;/strong&gt; — &lt;a href="https://learn.microsoft.com/en-us/entra/identity-platform/permissions-consent-overview" rel="noopener noreferrer"&gt;Permissions and consent in the Microsoft identity platform&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grant admin consent&lt;/strong&gt; — &lt;a href="https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent" rel="noopener noreferrer"&gt;Grant tenant-wide admin consent to an application&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed identities&lt;/strong&gt; — &lt;a href="https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview" rel="noopener noreferrer"&gt;Managed identities for Azure resources&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://ketankamble.com/blog/the-403-that-started-the-zero-access-pattern/" rel="noopener noreferrer"&gt;ketankamble.com&lt;/a&gt;. Independent content — not affiliated with, sponsored by, or endorsed by Microsoft. Microsoft, Intune, Entra, Microsoft Graph, Azure and Power BI are trademarks of the Microsoft group of companies. Everything here comes from a personal lab; verify in your own tenant.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>microsoftgraph</category>
      <category>azure</category>
    </item>
  </channel>
</rss>
