Execute a CSV-driven group restructure with the full bulk-operations protocol — input validation, reviewed dry run, generated rollback file, console-truth verification — and catch the two malformed rows and the one entry that would strand a user's access before anything runs.
Rohan administers Entra ID for a Bengaluru fintech that is renaming its department groups after a reorg. The change request looks trivial: move ~20 users from old GG-* groups to new SEC-* groups, per a CSV that HR's coordinator exported. Rohan has watched a colleague run exactly this kind of "trivial" bulk change and lock the finance team out of their own share for half a day. The CSV in this lab has three problems in it — two rows that are structurally broken and one that is structurally fine but operationally wrong. The malformed rows will announce themselves to any validator. The stranding row will not: only a human asking "what does this user end up with?" catches it.
You will run this against a sandbox — a free Entra test tenant, an AD lab VM, or, fully offline, a mock module provided below that mimics the cmdlets. The discipline is identical in all three.
~60 minutes hands-on · Core tier (maps to module M4). $0 — PowerShell 7 plus any AI chat. Sandbox options: a free Microsoft 365 Developer/Entra trial tenant with Graph PowerShell, a local AD VM, or the offline mock (no tenant needed).
Save the change request as restructure.csv:
action,upn,source_group,target_group
move,c.rakesh@fincorp.test,GG-Ops,SEC-Ops
move,a.priya@fincorp.test,GG-Ops,SEC-Ops
move,d.kumar@fincorp.test,GG-Finance,SEC-Finance
move,s.leela@fincorp.test,GG-Finance,SEC-Finance
move,v.arun@fincorp.test,GG-Finance,SEC-Finance
move,m.divya@fincorp.test,GG-HR,SEC-HR
move,j.thomas@fincorp.test,GG-HR,SEC-HR
move,r.iyer@fincorp.test,GG-Sales,SEC-Sales
move,k.bhat@fincorp.test,GG-Sales,SEC-Sales
move,n.sharma@fincorp.test,GG-Sales,SEC-Sales
move,t.george,GG-Sales,SEC-Sales
remove,p.sen@fincorp.test,GG-Finance,
move,b.reddy@fincorp.test,GG-Ops,SEC-Ops
move,h.mehta@fincorp.test,GG-Ops,SEC-Ops
move,s.kaur@fincorp.test,GG-HR,SEC-HR
move,l.singh@fincorp.test,GG-HR,
move,g.pillai@fincorp.test,GG-Sales,SEC-Sales
move,v.gupta@fincorp.test,GG-Ops,SEC-Ops
move,e.dsouza@fincorp.test,GG-Exec,SEC-Exec
move,a.naik@fincorp.test,GG-Finance,SEC-Finance
Context you have from the change ticket: GG-Finance grants the Finance share and the AP invoice system; its replacement is SEC-Finance. P.Sen is a current, active Finance employee — the coordinator's note says "cleanup rows included." All SEC-* target groups already exist. If you are working offline, save this mock as mock-ad.ps1 and dot-source it; it fakes the three cmdlets and records every call:
$script:Log = [System.Collections.ArrayList]@()
function Add-GroupMember { param($Group,$Upn,[switch]$WhatIf)
if ($WhatIf) { "WhatIf: add $Upn -> $Group" }
else { [void]$Log.Add("ADD,$Upn,$Group") ; "added $Upn -> $Group" } }
function Remove-GroupMember { param($Group,$Upn,[switch]$WhatIf)
if ($WhatIf) { "WhatIf: remove $Upn from $Group" }
else { [void]$Log.Add("REMOVE,$Upn,$Group") ; "removed $Upn from $Group" } }
function Get-ChangeLog { $script:Log }
bulk-change-log.md.restructure-clean.csv containing only rows that passed both gates, and restructure-held.csv with the three held rows and a one-line reason each. The held file goes back to the coordinator; the clean file proceeds. This split — proceed with the safe majority, hold the exceptions — is the bulk-ops move that keeps reorgs on schedule without gambling.-WhatIf and read every line of the output against the clean CSV. Expected output: one remove + one add per row, seventeen rows, no line touching p.sen, l.singh or t.george. The dry run is not a ritual — you are looking for the row you misjudged. Paste the WhatIf output into your log with a sentence: "Reviewed, matches intent, proceeding."rollback.csv populated with 17 pre-change membership lines.Get-ChangeLog and diff it against the clean CSV. Expected output: zero discrepancies — and a written note of how you verified, because "the script said Done" is the anti-pattern this step exists to kill.-WhatIf is fine), and confirm it would restore the exact prior membership. A rollback file nobody has tested is a rollback hope, not a rollback plan. Close bulk-change-log.md with the change report: rows received, rows held and why, rows executed, verification method, rollback status.The dangerous row in this CSV is not one of the malformed ones — those are caught by any validator, including the AI's. The dangerous row is well-formed, plausible, and wrong about a human being's access. No validation script asks "should we?"; that question is the operator's, and skipping it is how bulk ops become incident tickets.
One more habit worth naming before you verify: sequence removes and adds defensively. If your executor adds the user to the target group before removing them from the source, a mid-run failure leaves someone with extra access — annoying but safe. The reverse ordering leaves someone with no access — a stranded user and an SLA clock. Most AI drafts pick an ordering arbitrarily; a reviewed script picks it deliberately and comments why. For an access-granting restructure like this one, add-then-remove is the defensible default; for a privilege-reduction change, the calculus flips, and the fact that you thought about it belongs in the change report.
bulk-change-log.md records two malformed rows with structural reasons and one held-for-intent row with an operational reason.-WhatIf output covers exactly the 17 clean rows and was signed off in writing.Self-grading rubric (pass = 7/10): both malformed rows caught (0–2) · stranding row caught with impact stated (0–3) · rollback-before-change ordering enforced (0–2) · dry run reviewed and signed, not just run (0–1) · console-truth verification + rollback test documented (0–2).
Validators catch malformed rows; only an operator asking "what does this user end up with?" catches the well-formed row that strands a person — which is why bulk-ops discipline is validate, hold, dry-run, rollback-first, and verify against the console, in that order, every time.