Scope Drift Trimmer
Compares what a non-human identity was granted against what it actually called, across Entra ID, GitHub Apps, AWS IAM, and Okta, and generates the config change to trim the gap.
Scope Drift Trimmer
Compares what a non-human identity was granted against what it actually called, across Entra ID, GitHub Apps, AWS IAM, and Okta, and generates the config change to trim the gap.
Date: 2026-11-05 Theme: Protect + Observability Type: Web App Platform: OAuth / IAM Grants Status: Prototype
What it is
A single-page console that loads a normalized feed of permission grants from four adapters and flags the ones that are broader than their owner's own usage data shows. Pick a flagged grant and the tool renders a config snippet to remove it — a Graph DELETE call, a GitHub App permission downgrade, an AWS IAM policy diff, an Okta scope swap — each one clearly marked as a draft that still needs a human to run it.
Who it serves
The platform or security engineer who owns a service account, a CI bot, or an AI agent's credentials and gets asked, during an access review or an incident, "does this thing actually need everything it's holding?" Also useful to whoever runs quarterly access certifications and wants to walk into that meeting with evidence instead of a guess.
The IAM pain it addresses
Granting a scope and using a scope are two different facts, and most consoles only show you the first one. Entra ID's enterprise-apps blade shows which appRoleAssignments and oauth2PermissionGrants a service principal holds, but not whether it has called anything in ninety days. AWS Access Advisor tracks the opposite — service and action last-accessed data — but does nothing to compare that against the policy actually attached to the role. GitHub shows a permission list per installation with no usage trail at all. Okta's scope model (okta.<resource>.read vs. okta.<resource>.manage) draws a clean read/write line, but nothing in the console tells you a client granted .manage has only ever called a GET.
This is OWASP's NHI5:2025, Overprivileged NHI, in its most common form: nobody revisits a grant after the initial setup, so write access survives long after the write call stopped happening, or never started. GitHub's own changelog from October 13, 2026 is a tell — they shipped a narrower repository_creation:write permission specifically because repository_administration:write was the only option for apps that just needed to create repos, and that gap gets closed one platform at a time, not all at once.
The sample data seeds one case that should jump out immediately: legacy-invoice-sync, an Entra ID service principal with Mail.ReadWrite and Mail.Send granted via admin consent, zero Graph activity in the ninety-day window, and no owner on record. Nobody would approve that combination today. It's just still there.
How it works
- Inventory table. Every grant from every adapter in one sortable list — identity, platform, permission, access level, last used. Flagged rows sort to the top on load, and a banner names the worst offender by identity so the reader doesn't have to hunt for it.
- Search and filter. Text search across identity and permission strings, a platform dropdown, and a flagged-only toggle.
- Identity detail. Selecting an identity shows every grant it holds side by side, not one scope in isolation — the point is to see the whole blast radius of one compromised credential at once.
- Generate trim. On a flagged grant, this renders the platform-appropriate config change: an Entra ID Graph
DELETEagainst theappRoleAssignmentoroauth2PermissionGrant, a GitHub App permission block with the narrower permission swapped in, an AWS IAM policy diff narrowing a wildcard action to what last-accessed data actually shows, or an Okta scope swap from.manageto.read. Every snippet carries the same line: structurally illustrative, validate against the target environment before use. - Not every grant is wrong, and the tool says so.
log-shipper-lambda'siam:PassRolegrant has a null last-used date too, but it isn't flagged — AWS explicitly excludesPassRolefrom action last-accessed tracking, so a null there is a documented blind spot, not evidence of drift. Showing that distinction in the same table as the real flags is what keeps the tool honest.
What's in this folder
README.md— this filecover-image.png— cover art for the idea's card and detail pagemetadata.md— generation metadata and sourcingrequirements.md— the common grant schema, functional requirements, and the per-platform snippet shapesindex.html— the single-page app; carries an inline copy of the sample data so it still runs iffetch()of local JSON is blockedstyle.css— layout and the navy/electric-blue/teal visual treatmentscript.js— data loading, filtering, identity detail rendering, and the trim-snippet generatorsample-data.json— 16 grants across the four adapters, five of them flagged, including the seededlegacy-invoice-synccase
How to run / read it
Open index.html in any modern browser. No build step, no server, no login. If your browser blocks fetch() of a local JSON file, the page falls back to an inline copy of the same data embedded in index.html, so the double-click case still works.
Estimated impact
No measured number from a live deployment — this is a prototype, not a field result. The credible claim is narrower: AWS's own guidance says last-accessed data needs a four-hour console refresh and up to a 400-day action history, work that already exists per platform but currently sits in four different consoles that nobody cross-references by hand. A tool that joins those four views turns "review every service account's permissions" from a quarterly multi-console slog into a filtered list an engineer can clear in under an hour, assuming the adapters are wired up to pull real grant and usage data instead of the sample JSON shipped here.
Adapters and scale
Four adapters, each reading a documented API or console surface: Entra ID (Graph servicePrincipals/{id}/appRoleAssignments and .../oauth2PermissionGrants), GitHub Apps (the installation permission list plus Actions audit log), AWS IAM (iam:GenerateServiceLastAccessedDetails and the attached policy document), and Okta (granted OAuth scopes plus System Log events for the service app's client ID). None of them require a full identity sync to run this check — only the grant list and a usage signal per identity, which is why it holds up at 50k+ human identities and a larger non-human population: the volume driver here is grants per non-human identity, not total headcount, and a single over-permissioned CI bot produces the same signal whether it sits in a 500-app estate or a five-app one.
Sources
- Official documentation / standards:
https://owasp.org/projects/non-human-identities-top-10— NHI5:2025 Overprivileged NHI, the named risk category this idea targetshttps://learn.microsoft.com/en-us/entra/identity/enterprise-apps/manage-application-permissions— the documented distinction and Graph endpoints foroauth2PermissionGrants(delegated) vs.appRoleAssignments(application permissions), including the revoke calls used in the Entra ID trim snippethttps://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_last-accessed.html— service and action last-accessed data, the 400-day tracking period, and the documented exclusion ofiam:PassRoleand data-plane events from action trackinghttps://docs.github.com/en/rest/overview/permissions-required-for-github-apps— GitHub App permission naming (contents: write,administration: write,metadata: read)https://developer.okta.com/docs/guides/implement-oauth-for-okta/main/— theokta.<resource>.readvs.okta.<resource>.managescope model
- Community / current research:
https://github.blog/changelog/2026-10-13-github-app-improvements-for-least-privilege-and-management/— the October 13, 2026 GitHub changelog introducingrepository_creation:writeas a narrower alternative torepository_administration:write, the real-world precedent behind the GitHub trim snippet
Requirements
Requirements — Scope Drift Trimmer
Purpose
Normalize granted permissions from four identity platforms into one grant model, compare each grant against documented usage data from the same platform, and flag write/admin/manage-level grants that show no use inside the tracking window. Surface the flagged grants first; let an operator open one and get a single illustrative config change to remove it.
Common grant model
Every adapter maps its native permission object into this shape before the UI ever sees it:
{
id: string
platform: "entra-id" | "github-app" | "aws-iam" | "okta"
identity_name: string
identity_type: "service-principal" | "github-app-installation" | "iam-role" | "oauth-client"
identity_owner: string | null
grant_type: "application-permission" | "delegated-permission" | "github-app-permission" | "iam-policy-action" | "okta-scope"
permission: string
access_level: "read" | "write" | "manage" | "admin" | "delegated"
consent_type: "admin-consent" | "user-consent" | "org-owner-approved" | "service-app-grant" | "n/a"
granted_date: string (YYYY-MM-DD)
last_used_date: string (YYYY-MM-DD) | null
flagged: boolean
flag_reason: string | null
recommended_action: string | null
}
flagged and flag_reason are precomputed in sample-data.json for this prototype. In a real deployment, flagging runs server-side (or in a scheduled job) against live usage data, not in the browser on every load.
Functional requirements
- FR-1 — Load and render inventory. On open,
script.jsfetchessample-data.jsonand renders every grant as a row in a sortable, filterable table: identity, platform, permission, access level, last used, flagged state. - FR-2 — Surface the problem first. On first load, the flagged grants sort to the top and a summary banner states the count and the single highest-severity item (the ownerless
legacy-invoice-syncservice principal) by name — the reader must not have to search for it. - FR-3 — Filter and search. Text search over identity name and permission string; dropdown filters for platform and flagged-only.
- FR-4 — Identity detail view. Selecting an identity shows every grant it holds side by side with
last_used_date, so a reviewer can see the full blast radius of one compromised credential, not one scope at a time. - FR-5 — Trim action. Selecting a flagged grant and clicking "Generate trim" renders one illustrative config snippet appropriate to its platform (see below) with a copy-to-clipboard control and the fixed disclaimer text from REQ-6.
- FR-6 — Disclaimer on every generated snippet. Every snippet includes, unconditionally: "Structurally illustrative. Validate against the target environment before use." This is not a toggle — it renders every time, no exceptions.
- FR-7 — No network calls. The app never calls a live Graph, GitHub, AWS, or Okta API. All data is local JSON; "Generate trim" only renders text, it does not execute anything.
- FR-8 — Unflagged grants stay visible. The UI does not hide passing grants — it must be possible to see why
grant-012(aniam:PassRoleaction with a nulllast_used_date) is explicitly marked not-flagged, since AWS does not track that action at all. A tool that only shows failures earns the wrong kind of trust.
Per-platform illustrative snippet shapes
- Entra ID: a Graph
DELETErequest against/v1.0/servicePrincipals/{resource-servicePrincipal-id}/appRoleAssignedTo/{appRoleAssignment-id}(application permission) or/v1.0/oAuth2PermissionGrants/{id}(delegated permission), per the documented revoke flow. - GitHub App: a before/after permission block showing the over-broad permission (e.g.
administration: write) replaced by a narrower one (repository_creation:write) or removed outright, with a note to re-approve the installation. - AWS IAM: a unified-diff-style policy statement showing the wildcard action (
s3:*) narrowed to the specific actions seen in last-accessed data. - Okta: the service app's granted-scopes list with the broad
.managescope removed and, if still needed, the matching.readscope added.
Non-functional requirements
- Vanilla HTML/CSS/JS, no build step, no bundler, no CDN dependency.
- Opens directly via
file://— no local server required. - No authentication, no external network calls, no telemetry.
- Works in a single
index.htmlplusstyle.cssandscript.js.script.jstriesfetch("sample-data.json")first; sincefile://fetch of local JSON is blocked in some browsers,index.htmlalso carries the identical data inline in a<script id="inline-data" type="application/json">tag thatloadData()falls back to on fetch failure, so the app runs from a double-clickedindex.htmlwith no server. - Desktop-width layout (1280px+), no mobile breakpoint required for a prototype.
Out of scope
- Live API calls to any of the four platforms.
- Persisting trim decisions (no write-back, no local storage).
- Role mining, certification workflows, or anything beyond the grant-vs-use comparison.
More from IAM Ideas