Lab Metadata
Core technologies
Scenario and Description
Everything so far has tightened how people prove who they are and how privilege is activated. But access accumulates. People change teams and keep the old group. Projects end and the external partners keep their guest accounts. Someone is granted an admin role for a one-off task and never loses it. Left alone, a tenant silently drifts back toward over-permission, and the careful controls from earlier labs guard doors that no longer need to be open.
Identity hygiene is the discipline of continuously pruning access back to need. The brief lists its components precisely: excessive privileges, dormant accounts, legacy access models, and segregation-of-duties issues. Entra provides the governance tooling to address each: access reviews recertify who should keep what, and the same Graph queries that found risk in Lab 01 now drive remediation.
You will produce a hygiene baseline, set up an access review that recertifies the User Administrator eligibility from Lab 07, create a guest access review as code that automatically removes access nobody confirms, run a segregation-of-duties conflict detector against your directory roles, and remediate dormant accounts and stale guests, with a note on how Lifecycle Workflows automate leaver processing at scale.
Prerequisites
Prior labs
Labs 01 and 07 required. You reuse the dormant-account query and privileged inventory from Lab 01, and you recertify the jpatel eligibility created in Lab 07. Access reviews and Lifecycle Workflows are part of Entra ID Governance and require Entra ID P2 (or the Governance add-on), which your Lab 01 trial provides.
Graph scopes introduced
| Scope | Why it is needed |
|---|---|
AccessReview.ReadWrite.All | Create and read access review definitions |
User.ReadWrite.All | Disable dormant accounts and remove stale guests |
RoleManagement.Read.Directory, AuditLog.Read.All | Read role assignments and sign-in activity for the hygiene baseline |
Real-World Problem Statement
Access that is never reviewed is access that only ever grows. The risk is not a single bad grant, it is the slow accumulation of thousands of small, forgotten ones, each an unnecessary path an attacker can travel. Hygiene turns access from a one-way ratchet into something that is periodically justified or removed.
| Dimension | Why this matters |
|---|---|
| Risk | Dormant accounts, stale guests and lingering admin rights are unmonitored footholds. Removing them shrinks the attack surface directly. |
| Compliance | Periodic access recertification and segregation of duties are explicit control expectations in financial-sector audit and ISO 27001. |
| Productivity | Automated reviews and lifecycle workflows replace manual entitlement spreadsheets and chasing. |
| Security posture | Continuous pruning keeps the gains from every earlier lab from eroding over time. |
Concrete scenario: Northgate's last audit flagged 300 dormant enabled accounts, 120 external guests with no sign-in in a year, and several staff who can both create accounts and grant them privileged roles. The CISO wants recurring reviews that remove unconfirmed access automatically, and a standing report of segregation-of-duties conflicts.
Skills Mapped to Production Solutions
| Skill learned in this lab | Real-world enterprise application |
|---|---|
| Creating recurring access reviews | Periodic recertification of group, role and guest access |
| Auto-applying deny decisions | Removing access nobody confirms, with no manual cleanup |
| Recertifying privileged eligibility | Ensuring "eligible" does not quietly become permanent |
| Detecting segregation-of-duties conflicts | Preventing toxic role combinations and self-approval paths |
| Remediating dormant accounts and stale guests | Joiner-mover-leaver hygiene and external access lifecycle |
Architecture Overview
Hygiene runs on two rhythms. Continuous queries surface dormant, stale and conflicting access on demand. Scheduled access reviews put a human decision on the record for group, guest and privileged access, and can remove what is not confirmed automatically.
Step-by-Step Implementation
Phase A - Hygiene baseline
1Surface dormant accounts, stale guests and privileged sprawl
Purpose: quantify the hygiene backlog before you act.
Build the hygiene baseline
Connect-MgGraph -Scopes 'AccessReview.ReadWrite.All','User.ReadWrite.All',
'RoleManagement.Read.Directory','AuditLog.Read.All','Directory.Read.All' -NoWelcome
$props = 'id','displayName','userPrincipalName','accountEnabled',
'userType','createdDateTime','signInActivity','externalUserState'
$users = Get-MgUser -All -Property $props
$cutoff = (Get-Date).AddDays(-90)
$dormant = $users | Where-Object {
$_.AccountEnabled -and $_.UserType -eq 'Member' -and
($null -eq $_.SignInActivity.LastSignInDateTime -or $_.SignInActivity.LastSignInDateTime -lt $cutoff)
}
$staleGuests = $users | Where-Object {
$_.UserType -eq 'Guest' -and
($null -eq $_.SignInActivity.LastSignInDateTime -or $_.SignInActivity.LastSignInDateTime -lt $cutoff)
}
$pendingGuests = $users | Where-Object { $_.ExternalUserState -eq 'PendingAcceptance' }
"Dormant members (90d+): $($dormant.Count)"
"Stale guests (90d+) : $($staleGuests.Count)"
"Guests never accepted : $($pendingGuests.Count)"
Phase B - Recertify privileged eligibility
2Create an access review of the User Administrator eligibility
Purpose: make sure the jpatel eligibility from Lab 07 is still justified, on a recurring basis.
Context: reviewing privileged Entra role assignments is set up cleanly in the portal. This recertifies eligibility so that "eligible" is periodically re-earned rather than permanent by another name.
Portal steps for a recurring privileged-role review
# entra.microsoft.com > ID Governance > Access reviews > New access review. # # Select what to review = Microsoft Entra roles # Role = User Administrator # Assignment type = Eligible assignments (recertify the PIM eligibility) # Reviewers = Selected reviewers (a security manager), or 'Self-review' for attestation # Duration = 7 days, Recurrence = Quarterly # Upon completion: # Auto apply results = Enable # If reviewers don't respond = Remove access # Require justification = Yes # Show recommendations (based on sign-in) = Yes # # Read your review definitions back via Graph: Get-MgIdentityGovernanceAccessReviewDefinition | Select-Object Id, DisplayName, Status | Format-Table -AutoSize
Phase C - Guest access review as code
3Create a recurring guest review that auto-removes unconfirmed access
Purpose: keep external access to a collaboration group justified, and remove guests nobody vouches for.
Create the access review definition via Graph
# Target group holding external collaborators (create or reuse one). $grp = New-MgGroup -DisplayName 'Northgate-External-Collab' -MailEnabled:$false ` -MailNickname 'northgate-ext-collab' -SecurityEnabled:$true $reviewer = Get-MgUser -Filter "userPrincipalName eq 'lokafor@$((Get-MgOrganization).VerifiedDomains | Where-Object IsDefault | Select-Object -ExpandProperty Name)'" $review = @{ displayName = 'Quarterly guest access review - external collaboration' descriptionForAdmins = 'Recertify external guest membership of the collaboration group' descriptionForReviewers = 'Confirm each external guest still needs access. Deny to remove.' scope = @{ '@odata.type' = '#microsoft.graph.accessReviewQueryScope' query = "/groups/$($grp.Id)/transitiveMembers/microsoft.graph.user/?`$filter=(userType eq 'Guest')" queryType = 'MicrosoftGraph' } reviewers = @(@{ query = "/users/$($reviewer.Id)"; queryType = 'MicrosoftGraph' }) settings = @{ mailNotificationsEnabled = $true reminderNotificationsEnabled = $true justificationRequiredOnApproval = $true recommendationsEnabled = $true defaultDecisionEnabled = $true defaultDecision = 'Deny' # no response = remove access instanceDurationInDays = 7 autoApplyDecisionsEnabled = $true # actually remove denied access recurrence = @{ pattern = @{ type = 'absoluteMonthly'; interval = 3; dayOfMonth = 1 } range = @{ type = 'noEnd'; startDate = (Get-Date).ToString('yyyy-MM-dd') } } } } New-MgIdentityGovernanceAccessReviewDefinition -BodyParameter $review
NotStarted or InProgress. Confirm with Get-MgIdentityGovernanceAccessReviewDefinition. When an instance runs, the reviewer receives an email, and any guest they deny (or fail to confirm) is removed automatically.
What just happened? External access is now self-cleaning. Every quarter someone must actively confirm each guest, and silence removes access rather than preserving it. That single default flips guest sprawl from inevitable to impossible.
Phase D - Segregation-of-duties conflicts
4Detect toxic role combinations
Purpose: find users who hold role pairs that, together, break a control, such as being able to create accounts and grant them privilege.
Run the SoD conflict detector against directory roles
# Define conflicting role pairs and why each pair is toxic. $conflicts = @( @{ A='User Administrator'; B='Privileged Role Administrator'; Why='Create accounts AND grant them privileged roles' }, @{ A='Application Administrator'; B='Privileged Role Administrator'; Why='Add app credentials AND escalate app privilege' }, @{ A='Helpdesk Administrator'; B='Privileged Role Administrator'; Why='Reset admin passwords AND grant roles' } ) # Build a map of user -> set of role names from active assignments. $userRoles = @{} foreach ($role in (Get-MgDirectoryRole -All)) { foreach ($m in (Get-MgDirectoryRoleMember -DirectoryRoleId $role.Id -All)) { $upn = $m.AdditionalProperties['userPrincipalName'] if (-not $upn) { continue } if (-not $userRoles.ContainsKey($upn)) { $userRoles[$upn] = @() } $userRoles[$upn] += $role.DisplayName } } # Flag anyone holding both roles of any conflict pair. $findings = foreach ($upn in $userRoles.Keys) { foreach ($c in $conflicts) { if (($userRoles[$upn] -contains $c.A) -and ($userRoles[$upn] -contains $c.B)) { [pscustomobject]@{ User=$upn; Conflict="$($c.A) + $($c.B)"; Why=$c.Why } } } } $findings | Format-Table -AutoSize $findings | Export-Csv "sod-conflicts-$(Get-Date -f yyyyMMdd).csv" -NoTypeInformation
Phase E - Remediate and automate the lifecycle
5Clear dormant accounts and stale guests
Purpose: act on the baseline from Phase A, safely and reversibly.
Disable dormant members, remove long-stale guests
# Disable (not delete) dormant members first: reversible, with a retention window. foreach ($u in $dormant) { Update-MgUser -UserId $u.Id -AccountEnabled:$false Write-Host "Disabled dormant member: $($u.UserPrincipalName)" } # Remove guests with no sign-in in 90 days and no pending invite. # For guests, removal is usually appropriate as they are external. foreach ($g in $staleGuests) { Remove-MgUser -UserId $g.Id Write-Host "Removed stale guest: $($g.UserPrincipalName)" }
Automate leavers with Lifecycle Workflows (productionised)
# Lifecycle Workflows (Entra ID Governance) automate joiner-mover-leaver. # For leavers, a scheduled workflow triggered on the employee leave date can: # - disable the account # - remove it from all groups and Teams # - remove licences # - revoke sessions # # Configure at: entra.microsoft.com > ID Governance > Lifecycle Workflows > # Create a workflow > 'Real-time employee termination' or 'Leaver' template. # This removes the manual dormancy sweep for staff who leave properly, # leaving your Phase A query to catch only the exceptions.
What just happened? The hygiene backlog is cleared reversibly, and you have the pattern to prevent it recurring: reviews recertify what stays, and Lifecycle Workflows process leavers automatically so dormant accounts rarely accumulate in the first place.
Testing and Validation
- Baseline: run Phase A, note the three counts.
- Guest review: confirm the access review definition exists and its first instance notifies the reviewer.
- SoD detector: temporarily give a test user two conflicting roles, run the detector, confirm it flags the pair, then remove the role.
- Remediation: disable a single dormant test account, confirm it is disabled, then re-enable it.
- Re-baseline: re-run Phase A and confirm the counts have moved.
| Symptom | Cause | Resolution |
|---|---|---|
| Access review create fails | Malformed scope query or missing Governance licence | Confirm P2/Governance and the exact scope query syntax |
| Review never removes access | Auto-apply disabled, or default decision not set to remove | Enable auto-apply and set the non-response default to remove for that scope |
| SoD detector misses a user | Role held via group or PIM eligibility, not active membership | Extend the query to eligible assignments and group-nested roles |
| Cannot remove a guest | Guest owns objects or is a group owner | Reassign ownership first, then remove |
Security Analysis
What makes this sound
- Access re-earned, not assumed: recurring reviews force periodic justification, so entitlements shrink back to need.
- Silence removes access: a deny-by-default on non-response stops sprawl from surviving inattention.
- Toxic combinations caught: SoD detection prevents single individuals from holding end-to-end abusable power.
- Reversible remediation: disable-before-delete and retention windows protect against false positives.
Intentionally simplified for the lab
- The SoD detector covers active role membership. Production extends it to eligible assignments, nested groups and access packages.
- Lifecycle Workflows are described rather than fully built, they are a governance workstream of their own.
Production hardening
- Match the non-response default decision to the scope's risk, remove for low risk, retain and escalate for critical access.
- Use Entitlement Management access packages with built-in separation of duties for governed access, reserving detectors for the residue.
- Report review outcomes and SoD findings to the programme board as a recurring hygiene metric.
Cleanup Instructions
Remove the review, group and re-enable test accounts
# Remove the guest access review definition (stops future instances). Get-MgIdentityGovernanceAccessReviewDefinition -Filter "displayName eq 'Quarterly guest access review - external collaboration'" | ForEach-Object { Remove-MgIdentityGovernanceAccessReviewDefinition -AccessReviewScheduleDefinitionId $_.Id } # Remove the collaboration group. $g = Get-MgGroup -Filter "displayName eq 'Northgate-External-Collab'" if ($g) { Remove-MgGroup -GroupId $g.Id } # Re-enable any dormant test account you disabled. # Update-MgUser -UserId <id> -AccountEnabled:$true Disconnect-MgGraph
Recommended Learning Links
Key Takeaways and Next Lab
- Access accumulates by default, hygiene is the discipline of pruning it back to need.
- Recurring access reviews with deny-by-default on non-response stop entitlement sprawl.
- Segregation-of-duties conflicts must be detected for direct assignments and enforced natively for governed access packages.
- Remediate reversibly: disable before delete, retention windows, and Lifecycle Workflows for leavers.
Identity Bytes // IB-ENTRA-SEC Track // Lab 08 of 12. British English. For lab and training use against a disposable tenant only.