-
Notifications
You must be signed in to change notification settings - Fork 0
GroupAudit
A PowerShell supplement to GraphRunner that provides a resumable, three-phase workflow for auditing which Entra ID groups the current user can add or remove members from.
GraphRunner's built-in Get-UpdatableGroups cmdlet enumerates all groups and checks member-update access in a single pass. In large tenants this hits two problems:
-
401 errors are silently swallowed. The inner
estimateAccesscatch block only handles 429 (rate limiting). When the access token expires mid-page, every remaining group on that page fails quietly and is never rechecked. - No resume capability. On failure, the only option is to restart from the beginning and re-enumerate all groups from scratch.
-
Output file collision. Intermediate results are written as
DisplayName:GUIDtext but then overwritten byExport-Csvat the very end — if the run fails before completing, the CSV is never written.
Resume-GroupAudit.ps1 splits the work into three independent phases so each can be run, retried, or resumed without affecting the others.
- Windows PowerShell 5.1 or PowerShell 7+
-
GraphRunner dot-sourced in the same session (provides
Invoke-RefreshGraphTokens) - A valid Graph access token obtained via GraphRunner's
Get-GraphTokensorInvoke-RefreshGraphTokens
# Dot-source GraphRunner first, then this file
. .\GraphRunner\GraphRunner.ps1
. .\Resume-GroupAudit.ps1Encoding note: The file is pure ASCII. If you edit it, save as ASCII or UTF-8 with BOM. Windows PowerShell 5.1 reads files without a BOM as Windows-1252 by default, which silently corrupts multi-byte Unicode characters (em-dashes, smart quotes, etc.) and causes parse errors.
Phase 1 - Get-GraphGroups
Enumerate all groups -> all_groups.csv
|
v
Phase 2 - Test-GraphGroupMemberAccess
Check estimateAccess for each group -> updatable.csv + updatable_ids.txt
|
v
Phase 3 - Get-MemberAccessGroupDetails
Fetch full details + membership for updatable groups
-> updatable_details.csv + updatable_members.csv
Phases 2 and 3 are independent. If phase 2 fails partway through, phase 3 can still be run on whatever was found so far using the _ids.txt fallback file.
Authenticate using GraphRunner before running any phase. The ClientID used here matters — the refresh token is bound to the client it was issued for, and refreshing with a different ClientID returns HTTP 400.
# Example using the Azure Portal client
Invoke-RefreshGraphTokens -TenantID "contoso.com" `
-ClientID "04b07795-8ddb-461a-bbee-02f9e1bf7b46" `
-RefreshToken "0.A..."All three functions auto-detect the ClientID from the appid claim in the access token JWT, so you do not need to pass -ClientID explicitly unless you want to override it.
Enumerates all Entra ID groups and writes them to a CSV. No permission checks are performed. This is intentionally fast and cheap — it exists so you have a stable, complete group list before running the expensive estimateAccess calls in phase 2.
| Parameter | Required | Default | Description |
|---|---|---|---|
-Tokens |
Yes | Token object from GraphRunner | |
-OutputFile |
No | all_groups.csv |
Path to write the group list CSV |
-Keyword |
No | Filter groups by display name or description (substring match, multiple values OR'd) | |
-GraphApiEndpoint |
No | Graph v1.0 /groups
|
Override the starting endpoint |
| Column | Description |
|---|---|
id |
Group object ID (GUID) |
displayName |
Display name |
description |
Description |
mail |
Mail address (if set) |
# Enumerate all groups
Get-GraphGroups -Tokens $tokens -OutputFile .\all_groups.csv
# Enumerate only groups matching a keyword
Get-GraphGroups -Tokens $tokens -Keyword "admin","finance" -OutputFile .\filtered_groups.csvCalls the Graph estimateAccess endpoint for each group in the supplied list to determine whether the current user has the microsoft.directory/groups/members/update permission. This is the permission required to add or remove members from a group.
Unlike Get-UpdatableGroups, this function:
- Takes a pre-built group list as input rather than enumerating from scratch
- Handles HTTP 401 (token expiry) reactively — on receiving a 401 it refreshes the token immediately and retries the same group, rather than silently skipping it
- Respects the
Retry-Afterheader on HTTP 429 responses - Writes a
_ids.txtfallback file per-result as it runs, so progress survives a crash
| Parameter | Required | Default | Description |
|---|---|---|---|
-Tokens |
Yes | Token object from GraphRunner | |
-InputCsv |
One of three | CSV with an id column (e.g. from Get-GraphGroups) |
|
-InputFile |
One of three | Text file: bare GUIDs or DisplayName:GUID per line |
|
-GroupIds |
One of three | String array of GUIDs passed directly | |
-OutputFile |
No | updatable_groups_resumed.csv |
CSV for groups where access is allowed |
-Skip |
No | 0 |
Skip the first N groups (resume by position) |
-StartFromId |
No | Skip all groups before this GUID (resume by ID) | |
-RefreshInterval |
No | 300 |
Seconds between proactive token refreshes |
-ClientID |
No | Auto-detected | Override the client ID used for token refresh |
| File | Description |
|---|---|
<OutputFile> |
CSV written at the end: id, displayName, decision for each updatable group |
<OutputFile stem>_ids.txt |
Appended incrementally: DisplayName:GUID per updatable group found. This file is written as results come in and survives a crash. Used as input to phase 3. |
When a group exhausts all retries, the console prints:
[!] Giving up on 'Group Name' (xxxxxxxx-...) after 3 attempts.
Resume tip: -StartFromId 'xxxxxxxx-...'
Use that GUID with -StartFromId on the next run, pointing at the same all_groups.csv:
Test-GraphGroupMemberAccess -Tokens $tokens `
-InputCsv .\all_groups.csv `
-StartFromId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
-OutputFile .\updatable_resume.csvOr if you know roughly how many groups were checked, use -Skip:
Test-GraphGroupMemberAccess -Tokens $tokens -InputCsv .\all_groups.csv -Skip 300 -OutputFile .\updatable_resume.csv# Check all groups from phase 1
Test-GraphGroupMemberAccess -Tokens $tokens -InputCsv .\all_groups.csv -OutputFile .\updatable.csv
# Check a specific ad-hoc set of groups
Test-GraphGroupMemberAccess -Tokens $tokens -GroupIds @("guid1","guid2") -OutputFile .\updatable_specific.csv
# Resume from a known group ID
Test-GraphGroupMemberAccess -Tokens $tokens `
-InputCsv .\all_groups.csv `
-StartFromId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
-OutputFile .\updatable_resume.csvEnriches the updatable group list produced by phase 2 with full details fetched from Graph, and fetches the current membership of each group. Designed specifically for the _ids.txt fallback file written by Test-GraphGroupMemberAccess.
Produces two CSV files: a one-row-per-group summary, and a one-row-per-member membership file that joins back to the summary via groupId.
| Parameter | Required | Default | Description |
|---|---|---|---|
-Tokens |
Yes | Token object from GraphRunner | |
-InputFile |
Yes | Path to a _ids.txt file from Test-GraphGroupMemberAccess
|
|
-OutputFile |
No | updatable_group_details.csv |
Group summary CSV |
-MembersOutputFile |
No | updatable_group_members.csv |
Membership CSV |
-ClientID |
No | Auto-detected | Override the client ID used for token refresh |
| Column | Description |
|---|---|
id |
Group object ID |
displayName |
Display name |
description |
Description |
mail |
Mail address |
groupType |
Microsoft 365, Security, Mail-Enabled Security, Distribution, or Unknown
|
syncStatus |
Cloud-only or Synced (AD)
|
isAssignableToRole |
Whether the group can be assigned to an Entra ID role |
visibility |
Public, Private, or empty |
createdDateTime |
Group creation timestamp |
memberCount |
Number of direct members |
| Column | Description |
|---|---|
groupId |
Foreign key to group details CSV |
groupDisplayName |
Group display name (denormalised for readability) |
memberId |
Member object ID |
memberDisplayName |
Member display name |
memberUPN |
User principal name (users only; empty for groups, service principals, etc.) |
memberMail |
Mail address |
memberType |
User, Group, ServicePrincipal, Device, etc. (derived from @odata.type) |
Get-MemberAccessGroupDetails -Tokens $tokens `
-InputFile .\updatable_ids.txt `
-OutputFile .\updatable_details.csv `
-MembersOutputFile .\updatable_members.csv# Load dependencies
. .\GraphRunner\GraphRunner.ps1
. .\Resume-GroupAudit.ps1
# Authenticate
Invoke-RefreshGraphTokens -TenantID "contoso.com" `
-ClientID "04b07795-8ddb-461a-bbee-02f9e1bf7b46" `
-RefreshToken "0.A..."
# Phase 1: Enumerate all groups (fast, no permission checks)
Get-GraphGroups -Tokens $tokens -OutputFile .\all_groups.csv
# Phase 2: Check which groups allow member updates
Test-GraphGroupMemberAccess -Tokens $tokens `
-InputCsv .\all_groups.csv `
-OutputFile .\updatable.csv
# Phase 3: Enrich updatable groups with details and membership
# The _ids.txt filename mirrors the OutputFile stem from phase 2
Get-MemberAccessGroupDetails -Tokens $tokens `
-InputFile .\updatable_ids.txt `
-OutputFile .\updatable_details.csv `
-MembersOutputFile .\updatable_members.csv# If phase 2 failed partway through, resume from the last known group
Test-GraphGroupMemberAccess -Tokens $tokens `
-InputCsv .\all_groups.csv `
-StartFromId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
-OutputFile .\updatable_resume.csv
# Phase 3 still works on whatever was found — both runs wrote to _ids.txt
# Combine them first if needed, then deduplicate
Get-Content .\updatable_ids.txt, .\updatable_resume_ids.txt | Sort-Object -Unique | Set-Content .\combined_ids.txt
Get-MemberAccessGroupDetails -Tokens $tokens `
-InputFile .\combined_ids.txt `
-OutputFile .\updatable_details.csv `
-MembersOutputFile .\updatable_members.csvThis file is a supplement, not a replacement. It depends on GraphRunner being loaded and uses Invoke-RefreshGraphTokens from it. The estimateAccess logic in Test-GraphGroupMemberAccess is functionally identical to Get-UpdatableGroups — same endpoint, same request body, same response check — the difference is solely in resilience and structure.
GraphRunner is authored by Beau Bullock (@dafthack) and licensed under MIT.