Identity Bytes // IB-ENTRA-SEC Track
Advanced Lab 06 of 12 Est. 120 minutes

Automated Account Disablement on Missing MFA Registration

This is the automation the specialist brief names outright: disable accounts that have not configured MFA within a defined period. You build the logic safely, in dry-run first, wrap it in circuit breakers and grace windows, then productionise it as an unattended Azure Automation runbook that authenticates with a managed identity, no stored secrets.

Section 1

Lab Metadata

Lab ID
IB-ENTRA-SEC-06
Difficulty
Advanced
Scenario Org
Northgate Financial
Estimated Time
120 minutes

Core technologies

Microsoft Graph PowerShell Registration report Azure Automation runbook Managed identity Graph application permissions Circuit breakers
Section 2

Scenario and Description

IN PLAIN TERMS A landlord's rule might be that new tenants have two weeks to collect and register their door fob, and until they do, the flat stays sealed. Chasing hundreds of tenants by hand never works, so you automate it, but you fit three safety catches first. New arrivals get their grace period, certain flats such as the caretaker and the fire brigade are never sealed, and the system refuses to seal more than a handful in one night without a human checking, in case the tenant list was misread. This lab is that automation, built safety-first.

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.

Section 3

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

PermissionTypeWhy
AuditLog.Read.AllDelegated then ApplicationRead the MFA registration report
User.Read.AllDelegated then ApplicationRead account age and enabled state
User.ReadWrite.AllDelegated then ApplicationDisable accounts that fail the policy
STOP - DRY-RUN DISCIPLINE Do not set the script's $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.
Section 4

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.

DimensionWhy this matters
RiskAn enabled account with no MFA is a standing takeover target. Automated disablement removes the exposure the moment the grace period lapses.
ComplianceAuditors want evidence that MFA is not merely required but enforced with consequences, and a dated log of every action.
ProductivityAutomation removes a recurring manual chase and applies the rule consistently, without favour or oversight gaps.
Security postureThis 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.

Section 5

Skills Mapped to Production Solutions

Skill learned in this labReal-world enterprise application
Building selection logic with grace and exclusionsFair, defensible enforcement that does not catch new joiners or service accounts
Dry-run and circuit-breaker designSafe automation of destructive actions at enterprise scale
Managed-identity authentication for runbooksSecretless unattended automation, eliminating stored credentials
Granting Graph application permissions to a managed identityLeast-privilege service authorisation for scheduled jobs
Action logging to durable storageAudit evidence for every automated change
Section 6

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.

Schedule nightly trigger Automation runbook managed identity auth grace + exclude + cap dry-run switch Microsoft Graph registration + users Disable action accountEnabled = false Excluded, always break-glass + service Action log (CSV) audit evidence
PRODUCTION CONSIDERATION The single most important design choice here is the per-run cap. If the registration report is briefly empty or an API returns partial data, a naive script could conclude "everyone is non-compliant" and disable the estate. A cap that aborts and alerts when the candidate count is implausibly high turns a catastrophe into a page.
Section 7

Step-by-Step Implementation

Phase A - Build and prove the logic in dry-run

REAL WORLD ANALOGYA dry run is printing all the eviction notices but posting none of them. You circulate the pile for sign-off: is every name on it genuinely overdue, and is the caretaker definitely not among them? Only when the pile has been read and approved does anything get posted. Enterprises circulate exactly this dry-run output to management before any enforcement automation goes live.

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 }
}
VERIFICATION 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
VERIFICATION The output lists your older, non-MFA-capable test accounts (for example mreeves from Lab 01) and does not list break-glass, guests or brand-new accounts. Read this list carefully, it is exactly what would be disabled.

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

REAL WORLD ANALOGYThe per-run cap is a fuse box. If the current surges past a safe level, perhaps because a meter misread and reported every flat as overdue, the whole circuit trips and a human is called, rather than the building burning down. In automation terms: if the data source glitches and suddenly everyone looks non-compliant, the job refuses to act and raises an alarm instead of disabling the company.

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)."
SECURITY WARNING Only after the dry-run list is confirmed correct, set $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

REAL WORLD ANALOGYA managed identity is a robot staff member whose badge is built into its chassis. There is no key card in a drawer to steal, no password on a sticky note, because there is no password at all; the building itself vouches for the robot. This removes the single most common way scheduled automation gets compromised in enterprises: a stored credential someone found.

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"
}
VERIFICATION
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.
PRODUCTION CONSIDERATION Run the scheduled job in dry-run for a week and review the nightly logs before enabling enforcement. Add a day-7 warning stage that emails the user and manager (via Graph 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.
Section 8

Testing and Validation

  1. Dry-run correctness: run with $DryRun = $true, confirm the candidate list is right and excludes break-glass, guests and new accounts.
  2. Circuit breaker: temporarily set $MaxDisablePerRun = 0, confirm the script aborts and changes nothing.
  3. Grace window: create a fresh test user, confirm it is not selected until it passes the grace age.
  4. Enforcement: with a single safe test account, set $DryRun = $false, confirm it is disabled and logged, then re-enable it.
  5. Managed identity: run the runbook manually in Azure, confirm Connect-MgGraph -Identity succeeds with no stored secret.
SymptomCauseResolution
Runbook cannot authenticateManaged identity off or missing app rolesEnable system-assigned identity and grant the three Graph app permissions
Everyone appears as a candidateRegistration report empty or partialThe cap should abort, investigate the report before overriding
Break-glass in candidate listNot in the exclusion groupAdd all recovery and service accounts to MFA-Enforcement-Exclude
Update-MgUser access denied in runbookMissing User.ReadWrite.All application roleGrant it to the managed identity and allow a few minutes to propagate
Section 9

Security Analysis

What makes this sound

Intentionally simplified for the lab

Production hardening

Section 10

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
INFO: preserve for the track Keep the exclusion group pattern, later governance labs reuse the idea of a protected allowlist for critical accounts.
Section 12

Key Takeaways and Next Lab

Identity Bytes // IB-ENTRA-SEC Track // Lab 06 of 12. British English. For lab and training use against a disposable tenant only.