Skip to content

Read-Only Gate

The claim behind everything else on this site is that the collectors cannot write to your tenant. This is the script that makes that claim executable. It runs as the identity a collector uses, asks Microsoft Graph which application permissions that identity actually holds, and hard-fails the run if any of them is not read-only. The boundary is enforced by architecture — not by trust.

Download the script

1 · The script

It authenticates as a Managed Identity, resolves its own service principal, maps Microsoft Graph's app-role IDs to their values, lists the roles actually granted to the identity, and classifies each as read (.Read.All / .Read) or write. One write role and the gate throws. Dot-source it at the top of a collector runbook and the collector aborts before its first Graph call if the identity isn't clean.

View the full script
<#
    read-only-gate.ps1  —  the founding artifact of the Zero-Access Pattern.

    THE IDEA
    --------
    The Zero-Access Pattern claims that its collectors *cannot* write to your
    tenant. Most projects ask you to take that on trust. This script makes the
    claim executable: it runs AS the identity a collector uses, asks Microsoft
    Graph which application permissions that identity actually holds, and
    HARD-FAILS the run if any of them is not read-only.

    So the boundary is enforced by architecture, not by good intentions — if
    someone ever grants a write scope to the managed identity, the gate throws
    and the collector never gets to make its first call.

    HOW TO USE IT
    -------------
      • Standalone audit:  run it and read the report / exit code.
      • As a guard:        dot-source it at the top of every collector runbook —
                           `. .\read-only-gate.ps1` — so the collector aborts
                           before it touches Graph if the identity isn't clean.

    REQUIREMENTS
    ------------
      • Runs as a system-assigned Managed Identity (Connect-MgGraph -Identity).
      • To read its OWN permissions it needs a directory read scope —
        `Directory.Read.All` or `Application.Read.All`. Both are read-only and
        already part of the pattern; the gate simply reports them as such.
      • Module: Microsoft.Graph.Authentication (for Invoke-MgGraphRequest).
        Deliberately no full SDK — the Graph calls stay visible and light.

    HONEST CAVEAT
    -------------
      This verifies the app roles *granted* to the identity. It is a guardrail,
      not a licence to be careless: the first line of defence is still never
      granting a write scope. The gate is what catches the day someone does.
#>

[CmdletBinding()]
param(
    # A granted Graph app role counts as read-only if its value ends with one
    # of these suffixes...
    [string[]]$ReadOnlySuffixes = @('.Read.All', '.Read'),

    # ...or is on this explicit allow-list (rare read scopes that don't fit the
    # suffix rule). Keep this list tiny and reviewed.
    [string[]]$ReadOnlyExceptions = @(),

    # Report and warn instead of throwing. Use for a dry-run audit; leave OFF
    # when the gate guards a real collector.
    [switch]$ReportOnly
)

$ErrorActionPreference = 'Stop'
$GraphAppId = '00000003-0000-0000-c000-000000000000'   # Microsoft Graph, well-known

function Test-ReadOnlyRole {
    param([string]$Role)
    if ([string]::IsNullOrWhiteSpace($Role)) { return $false }
    if ($ReadOnlyExceptions -contains $Role) { return $true }
    foreach ($suffix in $ReadOnlySuffixes) {
        if ($Role.EndsWith($suffix)) { return $true }
    }
    return $false
}

function Get-AllPages {
    param([string]$Uri)
    $items = @()
    do {
        $page   = Invoke-MgGraphRequest -Method GET -Uri $Uri
        $items += $page.value
        $Uri    = $page.'@odata.nextLink'
    } while ($Uri)
    return $items
}

# --- Authenticate AS the managed identity -------------------------------------
Connect-MgGraph -Identity -NoWelcome
$ctx = Get-MgContext
if (-not $ctx.ClientId) { throw "No Managed Identity context — run this under a system-assigned identity." }

# --- Resolve the identity's own service principal -----------------------------
$miSp = (Get-AllPages "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$($ctx.ClientId)'")[0]
if (-not $miSp) { throw "Could not resolve the managed identity's service principal (need Directory.Read.All or Application.Read.All)." }

# --- Map Microsoft Graph's app-role IDs to their human-readable values ---------
$graphSp = (Get-AllPages "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$GraphAppId'")[0]
$roleMap = @{}
foreach ($role in $graphSp.appRoles) { $roleMap[$role.id] = $role.value }

# --- What Graph roles is THIS identity actually granted? -----------------------
$assignments = Get-AllPages "https://graph.microsoft.com/v1.0/servicePrincipals/$($miSp.id)/appRoleAssignments"
$graphGrants = $assignments | Where-Object { $_.resourceId -eq $graphSp.id }

$results = foreach ($a in $graphGrants) {
    $value = $roleMap[$a.appRoleId]
    [pscustomobject]@{
        Role     = if ($value) { $value } else { "(unknown role id $($a.appRoleId))" }
        ReadOnly = Test-ReadOnlyRole $value
    }
}

# --- Report -------------------------------------------------------------------
Write-Output "Zero-Access read-only gate — identity: $($miSp.displayName)  [$($ctx.ClientId)]"
$results | Sort-Object Role | ForEach-Object {
    "{0}  {1}" -f $(if ($_.ReadOnly) { '[read ]' } else { '[WRITE]' }), $_.Role
} | Write-Output

# --- Verdict ------------------------------------------------------------------
$writes = @($results | Where-Object { -not $_.ReadOnly })

if ($writes.Count -gt 0) {
    $bad = ($writes.Role -join ', ')
    if ($ReportOnly) {
        Write-Warning "GATE WOULD FAIL — write-capable Graph roles granted: $bad"
    } else {
        throw "READ-ONLY GATE FAILED — write-capable Graph roles granted: $bad. Refusing to run the collector."
    }
} else {
    Write-Output "READ-ONLY GATE PASSED — $($results.Count) Graph role(s), all read-only. Safe to collect."
}

2 · Two ways to run it

As a standalone audit — run it and read the report and exit code:

Zero-Access read-only gate — identity: aa-collectors  [<guid>]
[read ] DeviceManagementManagedDevices.Read.All
[read ] Directory.Read.All
[read ] User.Read.All
READ-ONLY GATE PASSED — 3 Graph role(s), all read-only. Safe to collect.

As a guard — the first lines of every collector runbook:

. .\read-only-gate.ps1          # throws and stops the run if a write scope is present
# ... collector logic only runs past this line if the gate passed ...

3 · What it needs

It runs under a system-assigned Managed Identity, and to read its own permissions it needs a directory read scope — Directory.Read.All or Application.Read.All. Both are read-only and already part of the pattern; the gate reports them as such. The only module is Microsoft.Graph.Authentication (for Invoke-MgGraphRequest) — no full SDK, so the Graph calls stay visible and light.

It's a guardrail, not a licence to be careless

The gate verifies the roles that are granted. The first line of defence is still never granting a write scope in the first place. The gate is what catches the day someone does — and turns a silent mistake into a failed run.