Lab Metadata
Core technologies
Scenario and Description
Enforcing MFA (Lab 03) requires a factor at sign-in, but a determined laggard can dodge it by never registering one and using only legacy paths, or by leaving a stale enabled account that no one is watching. Northgate's policy, echoing the brief, is blunt: if you have not registered MFA within a defined grace period, your account is disabled until you do. That converts MFA from an aspiration into a hard operational control.
Automating disablement is powerful and dangerous in equal measure. A script with permission to disable users, running unattended, is one logic error away from locking out the whole company on a Sunday night. This lab therefore treats safety as the primary feature, not an afterthought. You build the selection logic first and prove it in a dry-run that changes nothing, add an exclusion group, a new-joiner grace window and a per-run disablement cap, and only then wire it to run on a schedule.
For unattended execution you use an Azure Automation runbook authenticated by a system-assigned managed identity. That means no client secret or certificate is ever stored anywhere, the identity is Azure-managed and granted only the specific Microsoft Graph application permissions the job needs. This is the pattern the brief means by "automating administration and reporting through PowerShell", done to production standard.
Prerequisites
Prior labs
Labs 01 to 03 required. You reuse the registration report from Lab 01 to find not-MFA-capable accounts, and the break-glass accounts from Lab 03 must be in your exclusion group so the automation can never disable your recovery path.
For the unattended part
Phase C provisions an Azure Automation account, which needs an Azure subscription. The free Azure account from Lab 01 covers it, Automation has a monthly free-minutes allowance ample for a daily job. Phases A and B run entirely on your workstation and need no Azure resource.
Graph permissions used
| Permission | Type | Why |
|---|---|---|
AuditLog.Read.All | Delegated then Application | Read the MFA registration report |
User.Read.All | Delegated then Application | Read account age and enabled state |
User.ReadWrite.All | Delegated then Application | Disable accounts that fail the policy |
$DryRun flag to $false until you have run it in dry-run, read every candidate it lists, and confirmed your break-glass and service accounts are excluded. Every safety rail in this lab exists because unattended disablement, done wrong, is a self-inflicted denial of service.
Real-World Problem Statement
Policies that rely on humans to chase stragglers do not hold. An automated control that disables non-compliant accounts after a fair, communicated grace period is the only way to guarantee MFA coverage at scale, but it must be impossible for that control to cause a mass outage.
| Dimension | Why this matters |
|---|---|
| Risk | An enabled account with no MFA is a standing takeover target. Automated disablement removes the exposure the moment the grace period lapses. |
| Compliance | Auditors want evidence that MFA is not merely required but enforced with consequences, and a dated log of every action. |
| Productivity | Automation removes a recurring manual chase and applies the rule consistently, without favour or oversight gaps. |
| Security posture | This closes the gap between "MFA enforced at sign-in" and "no account can persist without MFA". |
Concrete scenario: Northgate gives new joiners 14 days to register MFA. On day 15, any member account still not MFA-capable is disabled automatically, the user and their manager having been warned on day 7. The security team needs this to run nightly, log everything, and be structurally incapable of disabling more than a handful of accounts in a single run without human review.
Skills Mapped to Production Solutions
| Skill learned in this lab | Real-world enterprise application |
|---|---|
| Building selection logic with grace and exclusions | Fair, defensible enforcement that does not catch new joiners or service accounts |
| Dry-run and circuit-breaker design | Safe automation of destructive actions at enterprise scale |
| Managed-identity authentication for runbooks | Secretless unattended automation, eliminating stored credentials |
| Granting Graph application permissions to a managed identity | Least-privilege service authorisation for scheduled jobs |
| Action logging to durable storage | Audit evidence for every automated change |
Architecture Overview
A scheduled runbook, authenticated by its managed identity, reads the registration report and user metadata from Graph, applies the policy with its safety rails, disables the accounts that qualify (or reports them in dry-run), and writes a log. No secret is stored anywhere.
Step-by-Step Implementation
Phase A - Build and prove the logic in dry-run
1Create the exclusion group and seed it with break-glass
Purpose: guarantee certain accounts can never be disabled by the automation.
Create the exclusion group and add break-glass accounts
Connect-MgGraph -Scopes 'User.Read.All','AuditLog.Read.All',
'User.ReadWrite.All','Group.ReadWrite.All','Directory.Read.All' -NoWelcome
$domain = (Get-MgOrganization).VerifiedDomains | Where-Object IsDefault | Select-Object -ExpandProperty Name
$excl = New-MgGroup -DisplayName 'MFA-Enforcement-Exclude' -MailEnabled:$false `
-MailNickname 'mfa-enforce-exclude' -SecurityEnabled:$true
# Always exclude break-glass (from Lab 03) and any service accounts.
foreach ($n in 1..2) {
$bg = Get-MgUser -Filter "userPrincipalName eq 'breakglass$n@$domain'" -ErrorAction SilentlyContinue
if ($bg) { New-MgGroupMember -GroupId $excl.Id -DirectoryObjectId $bg.Id }
}
Get-MgGroupMember -GroupId $excl.Id lists your break-glass accounts. This group is the automation's hard allowlist.
2Write the selection logic and run it dry
Purpose: identify who would be disabled, changing nothing.
Context: the policy is: member accounts that are not MFA-capable, older than the grace period, currently enabled, and not excluded. A dry-run prints the list and stops.
Disable-StaleNonMfaAccounts.ps1 (dry-run by default)
# ---- Policy parameters ---- $GraceDays = 14 # new joiners under this age are exempt $MaxDisablePerRun = 5 # circuit breaker: abort if more than this qualify $DryRun = $true # TRUE = report only. Do not change until validated. $ExcludeGroupName = 'MFA-Enforcement-Exclude' # ---- Gather exclusions ---- $excl = Get-MgGroup -Filter "displayName eq '$ExcludeGroupName'" $excludedIds = @() if ($excl) { $excludedIds = (Get-MgGroupMember -GroupId $excl.Id -All).Id } # ---- Find not-MFA-capable member accounts ---- $reg = Get-MgReportAuthenticationMethodUserRegistrationDetail -All $notCapable = $reg | Where-Object { -not $_.IsMfaCapable -and $_.UserType -eq 'member' } # ---- Apply grace, enabled and exclusion filters ---- $candidates = foreach ($r in $notCapable) { $u = Get-MgUser -UserId $r.Id -Property id,displayName,userPrincipalName,accountEnabled,createdDateTime -ErrorAction SilentlyContinue if (-not $u -or -not $u.AccountEnabled) { continue } if ($excludedIds -contains $u.Id) { continue } $ageDays = ((Get-Date) - $u.CreatedDateTime).TotalDays if ($ageDays -lt $GraceDays) { continue } [pscustomobject]@{ Id=$u.Id; Upn=$u.UserPrincipalName; AgeDays=[int]$ageDays } } "Candidates for disablement: $($candidates.Count)" $candidates | Format-Table -AutoSize
What just happened? You expressed Northgate's policy as code and proved it selects the right accounts without touching anything. In real deployments this dry-run output is circulated for sign-off before enforcement is ever switched on.
Phase B - Add the safety rails and the action
3Add the circuit breaker, the disable action and logging
Purpose: make the destructive step safe, capped and fully logged.
The guarded enforcement block
# ---- Circuit breaker: refuse to run if the count is implausible ---- if ($candidates.Count -gt $MaxDisablePerRun) { Write-Warning "ABORT: $($candidates.Count) candidates exceeds cap of $MaxDisablePerRun." Write-Warning "This may indicate stale report data. Human review required. No accounts changed." return } # ---- Act (or report), with a log line per account ---- $stamp = Get-Date -Format 'yyyyMMdd-HHmm' $log = foreach ($c in $candidates) { $action = if ($DryRun) { 'WOULD-DISABLE' } else { 'DISABLED' } if (-not $DryRun) { Update-MgUser -UserId $c.Id -AccountEnabled:$false } [pscustomobject]@{ TimeUtc = (Get-Date).ToUniversalTime().ToString('u') Action = $action Upn = $c.Upn AgeDays = $c.AgeDays } } $log | Export-Csv "mfa-enforcement-$stamp.csv" -NoTypeInformation $log | Format-Table -AutoSize "Dry run: $DryRun. Accounts processed: $($log.Count)."
$DryRun = $false to enact disablement. Keep the grace window and cap in place permanently. If the cap ever trips, investigate before overriding it, a tripped cap usually means the data, not the estate, is wrong.
What just happened? The destructive path is now bounded three ways: a grace window protects new joiners, an exclusion group protects critical accounts, and a per-run cap protects against bad data. Every action is written to a dated CSV for audit.
Phase C - Productionise as an unattended runbook
4Create the Automation account and grant its managed identity Graph permissions
Purpose: run the job on a schedule with no stored secret.
Context: a managed identity is an Azure-managed service principal with no credential you handle. You grant it the exact Graph application permissions it needs, nothing more.
Portal setup, then grant app roles to the managed identity
# PORTAL (once): # 1. Azure portal > create an Automation account (e.g. 'aa-identity-guardrails'). # 2. Automation account > Identity > System assigned > Status = On. Copy its Object ID. # 3. Automation account > Modules > import from gallery: # Microsoft.Graph.Authentication, Microsoft.Graph.Users, Microsoft.Graph.Reports, # Microsoft.Graph.Groups (runtime PowerShell 7.x). # GRANT GRAPH APP PERMISSIONS to the managed identity # (run from your workstation as a Privileged Role Administrator). $miObjectId = '<managed-identity-object-id>' $graphAppId = '00000003-0000-0000-c000-000000000000' # Microsoft Graph $graphSp = Get-MgServicePrincipal -Filter "appId eq '$graphAppId'" $needed = 'User.Read.All','AuditLog.Read.All','User.ReadWrite.All' foreach ($name in $needed) { $role = $graphSp.AppRoles | Where-Object { $_.Value -eq $name -and $_.AllowedMemberTypes -contains 'Application' } New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $miObjectId -BodyParameter @{ principalId = $miObjectId resourceId = $graphSp.Id appRoleId = $role.Id } Write-Host "Granted $name" }
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $miObjectId |
Select-Object AppRoleId, ResourceDisplayName
# Expected: three assignments against Microsoft Graph.
5Deploy the runbook and schedule it
Purpose: run the validated logic nightly, unattended.
Runbook authentication header, then publish and schedule
# At the top of the runbook, authenticate as the managed identity. # No secret, no certificate: the Automation runtime supplies the token. Connect-MgGraph -Identity -NoWelcome # ... then paste the Phase A + Phase B logic below this line ... # Keep $DryRun = $true for the first scheduled runs, review the logs, # then flip to $false once you trust the output. # PORTAL: # Automation account > Runbooks > Create a runbook (PowerShell 7.x) > # paste script > Save > Publish. # Runbook > Link to schedule > create a daily schedule (e.g. 02:00). # Write logs to an Automation output or, better, to Log Analytics for retention.
sendMail with the Mail.Send application permission, or a Logic App) so no one is disabled without notice. Fair warning is both good practice and an audit expectation.
Testing and Validation
- Dry-run correctness: run with
$DryRun = $true, confirm the candidate list is right and excludes break-glass, guests and new accounts. - Circuit breaker: temporarily set
$MaxDisablePerRun = 0, confirm the script aborts and changes nothing. - Grace window: create a fresh test user, confirm it is not selected until it passes the grace age.
- Enforcement: with a single safe test account, set
$DryRun = $false, confirm it is disabled and logged, then re-enable it. - Managed identity: run the runbook manually in Azure, confirm
Connect-MgGraph -Identitysucceeds with no stored secret.
| Symptom | Cause | Resolution |
|---|---|---|
| Runbook cannot authenticate | Managed identity off or missing app roles | Enable system-assigned identity and grant the three Graph app permissions |
| Everyone appears as a candidate | Registration report empty or partial | The cap should abort, investigate the report before overriding |
| Break-glass in candidate list | Not in the exclusion group | Add all recovery and service accounts to MFA-Enforcement-Exclude |
Update-MgUser access denied in runbook | Missing User.ReadWrite.All application role | Grant it to the managed identity and allow a few minutes to propagate |
Security Analysis
What makes this sound
- Secretless automation: a managed identity removes stored credentials, the commonest cause of automation compromise.
- Least privilege: the identity holds only the three Graph permissions the job needs, scoped to application use.
- Bounded blast radius: grace window, exclusion allowlist and a per-run cap make a runaway disablement structurally hard.
- Auditability: every action is logged with a timestamp, satisfying the evidence expectation.
Intentionally simplified for the lab
- The warning stage is described, not built. Production adds day-7 user and manager notifications before day-15 disablement.
- Logs go to CSV. Production ships them to Log Analytics or a SIEM for retention and alerting.
Production hardening
- Alert if the cap trips or if a run disables any account, so a human sees every enforcement action.
- Protect the Automation account itself, it holds an identity that can disable users, so treat it as privileged and review its access.
- Reconcile disablements weekly against joiners and leavers to catch policy or data errors early.
Cleanup Instructions
Re-enable test accounts, remove the group and Automation resources
# Re-enable any account you disabled during testing. $domain = (Get-MgOrganization).VerifiedDomains | Where-Object IsDefault | Select-Object -ExpandProperty Name $mreeves = Get-MgUser -Filter "userPrincipalName eq 'mreeves@$domain'" -ErrorAction SilentlyContinue if ($mreeves) { Update-MgUser -UserId $mreeves.Id -AccountEnabled:$true } # Remove the exclusion group if resetting. $g = Get-MgGroup -Filter "displayName eq 'MFA-Enforcement-Exclude'" if ($g) { Remove-MgGroup -GroupId $g.Id } # In Azure, delete the Automation account to stop scheduled runs and remove the # managed identity (which also removes its Graph app-role assignments). Disconnect-MgGraph
Recommended Learning Links
Key Takeaways and Next Lab
- Automated disablement turns MFA from a policy into an enforced operational control.
- For destructive automation, safety is the primary feature: dry-run, grace window, exclusion allowlist and a per-run cap.
- Managed identities give secretless, least-privilege authentication for scheduled jobs.
- Log every action, and warn users before you disable them.
Identity Bytes // IB-ENTRA-SEC Track // Lab 06 of 12. British English. For lab and training use against a disposable tenant only.