Engineer Entra Identity Lifecycle
Automate auditable Entra joiner, mover, and leaver access with Graph and PIM.
Introduction
30 Second Summary
A job change can happen in a single profile update. Old access can linger long after the employee record looks correct.
In this project, you will engineer an auditable joiner-mover-leaver lifecycle in Microsoft Entra. Alex Kim's journey from Engineering to Finance through offboarding produces sign-in records and audit evidence for every access change.
What You'll Build
When you finish, you can demo Alex joining Engineering, moving to Finance, activating approved privilege, and leaving the organization while an auditor traces every outcome back to evidence.
By the end of this project, you'll have:
- A reusable PowerShell and Microsoft Graph workflow that drives Alex through joiner, mover, and leaver events.
- A visible Engineering OIDC sign-in that exposes a stale-access finding after an attribute-only move, followed by a corrected least-privilege state.
- A Finance SAML sign-in protected by Conditional Access MFA and approved PIM activation, backed by offboarding evidence.
- Secret Mission: Perform a mini access certification that produces an auditor-ready decision record for every Finance assignment.
Are there any prerequisites?
You need a commercial, global nonproduction Microsoft Entra workforce tenant with qualifying Microsoft Entra ID P2 or Microsoft Entra ID Governance coverage.
Step 1 checks licensing, administrator permissions, separate lab identities, employee MFA readiness, PowerShell, and the Microsoft Graph PowerShell SDK before the lab begins.
Before We Start
This checkpoint connects every identity change to a security outcome before you touch the tenant. You will define the auditable lifecycle you intend to prove and why stale access makes an attribute-only move unsafe.
Prepare the Entra Engineering Lab
Your lifecycle workflow depends on licensed governance features inside a safe Microsoft Entra ID tenant. Separate lab identities keep administration separate from the employee access you will test.
This step verifies those safeguards before any lifecycle resources are created. You will also prepare PowerShell with the pinned Microsoft Graph PowerShell SDK.
In this step, get ready to:
- Verify the nonproduction tenant and prepare three separate lab identities.
- Install PowerShell 7.6.6 with Microsoft.Graph 2.41.2.
- Connect the lab administrator to Microsoft Graph with the required delegated scopes.
Verify the tenant and lab identities
A nonproduction workforce tenant gives you a controlled place to test identity changes. The lab also needs a separate administrator, employee, and approver so each action has a clear owner.
Confirm Coverage Before Continuing
This project assumes existing Microsoft Entra ID P2 or Microsoft Entra ID Governance coverage. This check does not create a purchase.
PIM features require qualifying coverage for the eligible employee and each approver. Agreement pricing can vary.
- Open Safari from your Mac search bar.
- Sign in to the Microsoft Entra admin center with the lab administrator identity.
- Use the account menu to switch to your nonproduction tenant.
- Select Entra ID from the left navigation.
- Select Overview.
- Select Properties.
- Confirm with the tenant owner that this directory is a commercial workforce tenant using global commercial-cloud endpoints.
- Record the value under Tenant ID as your tenant ID.
Why Separate the Identities?
The administrator configures the lab. The employee requests access during later tests.
The separate approver provides independent approval. This separation makes the resulting audit evidence defensible.
- Use the admin center search bar to open Users under Entra ID.
- Confirm that separate administrator, employee, and approver identities are listed.
- Select New user if the employee or approver identity is missing.
- Select Create new user.
- Create each missing identity as an internal member account.
- Keep administrative roles off the employee identity.
- Keep the approver identity separate from the employee identity.
Protect the Lab Credentials
Creating test identities touches credentials. Keep every password in your approved password manager.
Use these accounts only inside the nonproduction tenant. Never paste their passwords into a project validation.
- Use the admin center search bar to open All products under Billing.
- Open the product that provides Microsoft Entra ID P2 or Microsoft Entra ID Governance coverage.
- Confirm that the employee identity has qualifying coverage.
- Confirm that the approver identity has qualifying coverage.
- Assign existing qualifying coverage if either identity is missing it.
- Pause the project if no qualifying license or eligible trial is available.
- Return to the employee under Users.
- Open Authentication methods to confirm that the employee has a usable MFA method.
- Ask the employee to register a method through My Sign Ins under Security info if no usable method is listed.
- Return to Authentication methods to confirm that the registered method is usable.
Good progress. Your tenant now has the identity separation and license coverage needed for safe lifecycle testing.
Install PowerShell and Microsoft Graph
A pinned local toolchain makes the lifecycle workflow reproducible. PowerShell 7.6.6 runs the commands while Microsoft.Graph 2.41.2 provides the Graph cmdlets.
- Press Cmd+Space to open Spotlight on your Mac.
- Type Terminal and press Enter to open it.
- Check whether the required PowerShell release is available by running:
pwsh
What Does This Command Do?
The pwsh command starts PowerShell on macOS. Its startup banner identifies the installed release.
✔️ I see version 7.6.6
PowerShell 7.6.6 is running. Your Mac meets the pinned runtime requirement.
- Keep this pwsh session open for the SDK check.
ⓧ I see an older version
The installed release is below the project pin. The Microsoft-signed installer upgrades PowerShell in place.
- Open Microsoft's PowerShell installation guide.
- Download powershell-7.6.6-osx-arm64.pkg if your Mac uses Apple silicon.
- Download powershell-7.6.6-osx-x64.pkg if your Mac uses an Intel processor.
- Open the downloaded PKG from your Downloads folder.
- Complete the macOS installer with the default destination.
- Return to Terminal after the installation finishes.
- Repeat the pwsh check shown above.
The startup banner should now show PowerShell 7.6.6.
ⓧ Command not found
PowerShell is not installed yet. The official macOS PKG adds the pwsh command to your Mac.
- Open Microsoft's PowerShell installation guide.
- Download powershell-7.6.6-osx-arm64.pkg if your Mac uses Apple silicon.
- Download powershell-7.6.6-osx-x64.pkg if your Mac uses an Intel processor.
- Open the downloaded PKG from your Downloads folder.
- Complete the macOS installer with the default destination.
- Return to Terminal after the installation finishes.
- Repeat the pwsh check shown above.
The startup banner should show PowerShell 7.6.6.
PowerShell Still Not Starting?
Confirm that the downloaded PKG matches your Mac processor. Reopen Terminal after the installer completes so the new command is available.
If macOS blocks the package, review the security prompt before approving the Microsoft-signed installer.
Help me troubleshoot my PowerShell installation on macOS.
The Graph SDK is a separate module inside PowerShell. Checking first lets you keep an existing correct installation or repair a missing pin.
- Check the installed Microsoft.Graph module by running this inside pwsh:
Get-InstalledModule Microsoft.Graph
What Does This Command Check?
The command reads the Microsoft.Graph module installed through PowerShellGet. Its output includes the installed version.
✔️ I see version 2.41.2
Microsoft.Graph 2.41.2 is already installed for the current user. The SDK matches the project pin.
ⓧ I see a different version
PowerShell can install the required SDK release for your current user. The pinned version keeps every later cmdlet consistent with the project.
- Approve any provider or repository confirmation that PowerShell displays during this installation.
- Install the pinned SDK release by running:
Install-Module -Name Microsoft.Graph -RequiredVersion 2.41.2 -Scope CurrentUser
What Does This Command Do?
- The RequiredVersion parameter installs Microsoft.Graph 2.41.2.
- The CurrentUser scope keeps the module inside your user profile.
- Repeat the module check shown above after the command returns to the prompt.
The output should list Microsoft.Graph version 2.41.2.
ⓧ The module is not installed
The missing-module result confirms that the SDK still needs to be installed. PowerShell installs it only for your current user.
- Approve any provider or repository confirmation that PowerShell displays during this installation.
- Install the pinned SDK release by running:
Install-Module -Name Microsoft.Graph -RequiredVersion 2.41.2 -Scope CurrentUser
What Does This Command Do?
- The RequiredVersion parameter installs Microsoft.Graph 2.41.2.
- The CurrentUser scope avoids a machine-wide installation.
- Repeat the module check shown above after the command returns to the prompt.
The output should list Microsoft.Graph version 2.41.2.
Microsoft.Graph Installation Failing?
Confirm that the command is running inside PowerShell 7.6.6. A standard macOS shell cannot run Install-Module.
Check your network connection if PowerShell cannot reach the module repository. Re-run the pinned installation after connectivity returns.
Help me fix the Microsoft.Graph installation.
Connect the lab administrator to Microsoft Graph
Delegated permissions let the signed-in administrator perform Graph operations on behalf of their identity. This lab requests only the user, group, and session-revocation scopes used by the lifecycle script.
Before you connect, which of your three lab identities should appear in the browser sign-in flow?
- Return to the pwsh session from the SDK installation.
- Connect to the recorded tenant with the required delegated scopes by running:
$TenantId = "[[TENANT_ID="your tenant ID"]]"
$scopes = @(
"User.ReadWrite.All",
"Group.ReadWrite.All",
"User.RevokeSessions.All"
)
Connect-MgGraph -TenantId $TenantId -Scopes $scopes -ContextScope Process -NoWelcome
What Does This Connection Request?
- The $TenantId value targets the nonproduction tenant you verified.
- User.ReadWrite.All supports the user lifecycle operations used later.
- Group.ReadWrite.All supports security-group creation and direct membership changes.
- User.RevokeSessions.All supports session revocation during offboarding.
- ContextScope Process limits the connection context to this PowerShell process.
- Sign in through the browser with the lab administrator identity.
- Confirm that the browser shows the nonproduction tenant.
- Review the requested delegated permissions.
- Complete the consent flow with the administrator identity.
- Return to pwsh after the browser reports a completed sign-in.
The command returns to the PowerShell prompt without an authentication error. Your administrator is now connected to Microsoft Graph for this process.
Consent Flow Not Completing?
Confirm that the browser uses the lab administrator identity. Also confirm that the tenant ID matches the nonproduction directory.
A permission failure means the administrator lacks authority to approve one or more requested delegated scopes. Ask the tenant owner to verify the lab administrator's permissions.
Help me troubleshoot my Microsoft Graph consent flow.
Before the final check, what Microsoft.Graph version should the module table display?
- Verify the pinned module installation by running:
Get-InstalledModule Microsoft.Graph
What Confirms Success?
The module table should show Microsoft.Graph version 2.41.2. The completed browser consent flow confirms that the administrator can connect with the three delegated scopes.
That is the setup complete. Your pinned local SDK can now operate against the isolated lab tenant.
✔️ My lab matches the checkpoint
Your tenant, identities, PowerShell runtime, SDK, and Graph connection are ready for the lifecycle workflow.
ⓧ I'd like to double check the full setup
- Separate administrator, employee, and approver identities prepared. Employee MFA is available.
- Administrator connected to the nonproduction tenant with User.ReadWrite.All, Group.ReadWrite.All, and User.RevokeSessions.All delegated scopes.
- Installed for CurrentUser at required version 2.41.2.
- Installed on the Mac and available through pwsh at PowerShell 7.6.6.
Your engineering lab is ready. Next, you will automate the Engineering joiner flow and prove the employee can reach the OIDC application.
Join Engineering and Prove OIDC Access
Your Microsoft Entra ID tenant is ready. The lab administrator can already call Microsoft Graph.
A visible joiner gives every later lifecycle decision a baseline. In this step, you use PowerShell to automate Alex Kim's Engineering access.
You then prove the assignment by decoding an OIDC ID token at jwt.ms.
In this step, get ready to:
- Build an idempotent Joiner script for Alex Kim.
- Create three security groups with Engineering access assigned to Alex.
- Prove Alex can access the Engineering OIDC lab.
Build the reusable Joiner script
An idempotent workflow checks the tenant before it creates or changes anything. Repeating the Joiner action leaves Alex in the same intended state without duplicating the account or groups.
- Create a plain-text file named jml-lifecycle.ps1 on your Desktop using the code editor on your Mac.
- Switch back to the pwsh session from the previous step.
- Move PowerShell to your Desktop by running this command:
Set-Location -Path $HOME/Desktop
What does this command do?
PowerShell resolves $HOME to your Mac user folder. Setting the location to Desktop lets the documented ./jml-lifecycle.ps1 command find your script.
- Add the script requirements and parameters to the top of jml-lifecycle.ps1 by pasting this code:
#Requires -Version 7.6
#Requires -PSEdition Core
#Requires -Modules @{ ModuleName="Microsoft.Graph"; RequiredVersion="2.41.2" }
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet("Joiner", "MoveAttributeOnly", "Mover", "Leaver")]
[string]$Action,
[Parameter(Mandatory = $true)]
[string]$TenantId,
[Parameter(Mandatory = $true)]
[string]$UserPrincipalName,
[string]$DisplayName = "Alex Kim",
[string]$MailNickname = "alex.kim",
[string]$TemporaryPassword = "your-lab-only-temporary-password"
)
What does this code do?
- The requirements stop the script unless PowerShell 7.6 Core can load Microsoft.Graph 2.41.2.
- The mandatory parameters identify the lifecycle action and tenant.
- The remaining parameters provide Alex's account values without hard-coding a real password.
- Save jml-lifecycle.ps1.
- Confirm your editor shows jml-lifecycle.ps1 on the Desktop.
- Append the Graph connection configuration below the parameter block:
$ErrorActionPreference = "Stop"
$scopes = @(
"User.ReadWrite.All",
"Group.ReadWrite.All",
"User.RevokeSessions.All"
)
Connect-MgGraph -TenantId $TenantId -Scopes $scopes -ContextScope Process -NoWelcome
How does the connection work?
- The scope list contains the delegated permissions already approved for this lab.
- Process context keeps the connection inside the current PowerShell process.
- The stop preference prevents the workflow from continuing after a failed Graph operation.
- Save jml-lifecycle.ps1.
- Replace your-tenant-id in the command below with your nonproduction tenant ID.
- Replace alex.kim@yourtenant.onmicrosoft.com with Alex's lab user principal name.
Before you run the connection check, predict whether PowerShell returns to the prompt or stops during authentication:
- Check the script entry point by running this command:
./jml-lifecycle.ps1 -Action Joiner -TenantId "your-tenant-id" -UserPrincipalName "alex.kim@yourtenant.onmicrosoft.com" -DisplayName "Alex Kim" -MailNickname "alex.kim" -TemporaryPassword "your-lab-only-temporary-password"
What did this check prove?
At this stage, the script validates its requirements and connects with the requested scopes. Returning to the PowerShell prompt without an error proves the guarded entry point works.
Good progress. Your script now enforces the expected runtime before it touches any identity data.
Connection check stopped?
Confirm the command uses your verified nonproduction tenant ID. Make sure the administrator account completes any sign-in prompt that appears.
Check that the script is saved on your Desktop with the exact .ps1 extension.
Help me diagnose the Graph connection failure.
The next helper finds a group by its display name. It creates the group only when no match exists.
- Start the Get-OrCreateLabGroup helper by appending this first part:
function Get-OrCreateLabGroup {
param(
[Parameter(Mandatory = $true)]
[string]$DisplayName,
[Parameter(Mandatory = $true)]
[string]$MailNickname,
[Parameter(Mandatory = $true)]
[string]$Description
)
$matches = @(Get-MgGroup -Filter "DisplayName eq '$DisplayName'")
if ($matches.Count -gt 1) {
throw "More than one group named '$DisplayName' exists. Resolve the duplicate before continuing."
}
How does the duplicate guard work?
The helper stores every matching group in an array. More than one result stops the workflow because the script cannot safely choose between duplicate names.
- Complete Get-OrCreateLabGroup by appending this second part immediately below the duplicate check:
if ($matches.Count -eq 1) {
return $matches[0]
}
$group = New-MgGroup `
-DisplayName $DisplayName `
-Description $Description `
-MailEnabled:$false `
-MailNickName $MailNickname `
-SecurityEnabled
return $group
}
How does group creation stay idempotent?
One existing match is returned unchanged. No match causes the helper to create a mail-disabled security group with the supplied name and description.
- Save jml-lifecycle.ps1.
- Switch back to PowerShell.
- Press the Up Arrow key to recall the previous check.
- Press Enter to run it.
PowerShell should return to the prompt without a syntax or Graph error. That confirms the complete group helper can load.
Helper not loading?
Check that the first block ends with the duplicate-check brace. Check that the second block ends with the function brace.
Help me find the syntax problem in Get-OrCreateLabGroup.
Membership helpers make direct group changes safe to repeat. Each helper reads the current members before it adds or removes Alex.
- Append the direct membership helper to jml-lifecycle.ps1 by pasting this code:
function Add-LabGroupMember {
param(
[Parameter(Mandatory = $true)]
[object]$Group,
[Parameter(Mandatory = $true)]
[object]$User
)
$members = @(Get-MgGroupMember -GroupId $Group.Id -All)
if ($members.Id -notcontains $User.Id) {
$odataId = "https://graph.microsoft.com/v1.0/directoryObjects/$($User.Id)"
New-MgGroupMemberByRef -GroupId $Group.Id -OdataId $odataId
}
}
How does the add helper work?
The helper compares Alex's object ID with the direct member IDs. It creates a Graph entity reference only when Alex is absent.
- Append the matching removal helper below Add-LabGroupMember by pasting this code:
function Remove-LabGroupMember {
param(
[Parameter(Mandatory = $true)]
[object]$Group,
[Parameter(Mandatory = $true)]
[object]$User
)
$members = @(Get-MgGroupMember -GroupId $Group.Id -All)
if ($members.Id -contains $User.Id) {
Remove-MgGroupMemberByRef `
-GroupId $Group.Id `
-DirectoryObjectId $User.Id
}
}
How does the removal helper work?
The helper removes Alex only when the direct membership exists. Repeating a cleanup action therefore leaves the group in the same intended state.
- Save jml-lifecycle.ps1.
- Switch back to PowerShell.
- Press the Up Arrow key to recall the previous check.
- Press Enter to load both membership helpers.
You should return to the prompt without an error. The script can now add or remove direct group membership without duplicating work.
Membership helper stopped?
Check that both helper names match the code exactly. Confirm each function has its own closing brace.
Help me debug the membership helpers.
The workflow needs three stable entitlement containers. Creating all three now gives later lifecycle actions a known access model.
- Append the three group definitions below the membership helpers:
$engineeringGroup = Get-OrCreateLabGroup `
-DisplayName "JML-Engineering" `
-MailNickname "jmlengineering" `
-Description "Standard Engineering access for the JML lab"
$financeGroup = Get-OrCreateLabGroup `
-DisplayName "JML-Finance" `
-MailNickname "jmlfinance" `
-Description "Standard Finance access for the JML lab"
$privilegedFinanceGroup = Get-OrCreateLabGroup `
-DisplayName "JML-Finance-Privileged" `
-MailNickname "jmlfinanceprivileged" `
-Description "Time-bound privileged Finance application access for the JML lab"
What do these groups represent?
- JML-Engineering represents Alex's current standard access.
- JML-Finance represents standard access after a future department move.
- JML-Finance-Privileged represents time-bound application access.
- Append the user lookup below the group definitions:
$user = $null
try {
$user = Get-MgUser `
-UserId $UserPrincipalName `
-Property Id,DisplayName,UserPrincipalName,Department,JobTitle,AccountEnabled
}
catch {
if ($Action -ne "Joiner") {
throw "The user '$UserPrincipalName' does not exist. Run the Joiner action first."
}
}
Why can the Joiner continue?
The lookup loads Alex when the account already exists. A missing account is valid for the Joiner action because that action creates the employee.
- Save jml-lifecycle.ps1.
- Switch back to PowerShell.
- Press the Up Arrow key to recall the previous check.
- Press Enter to create or find the three groups.
- Return to the Microsoft Entra admin center from earlier.
- Search the tenant for JML-Engineering.
- Search the tenant for JML-Finance.
- Search the tenant for JML-Finance-Privileged.
You should find all three security groups with the descriptions from the script. This is the first tenant-side result from your workflow.
Missing a lab group?
Return to PowerShell and inspect the error above the prompt. A duplicate-name warning means the tenant contains more than one group with the same display name.
Help me resolve the missing or duplicate lab group.
The Joiner path creates Alex when the account is absent. Repeating it restores the Engineering attributes and removes access from both Finance groups.
- Start the Joiner action below the user lookup by appending this first part:
switch ($Action) {
"Joiner" {
if ($null -eq $user) {
if ($TemporaryPassword -eq "your-lab-only-temporary-password") {
throw "Supply a unique lab-only temporary password with -TemporaryPassword."
}
$passwordProfile = @{
Password = $TemporaryPassword
}
$user = New-MgUser `
-DisplayName $DisplayName `
-UserPrincipalName $UserPrincipalName `
-MailNickName $MailNickname `
-PasswordProfile $passwordProfile `
-AccountEnabled `
-Department "Engineering" `
-JobTitle "Software Engineer"
}
else {
$joinerUpdate = @{
accountEnabled = $true
department = "Engineering"
jobTitle = "Software Engineer"
How does the account branch work?
A missing account requires a unique temporary password before Graph creates Alex. An existing account enters the update branch so the same action can repair drift.
- Complete the Joiner action by appending this second part immediately below the attribute values:
}
Update-MgUser -UserId $user.Id -BodyParameter $joinerUpdate
}
Add-LabGroupMember -Group $engineeringGroup -User $user
Remove-LabGroupMember -Group $financeGroup -User $user
Remove-LabGroupMember -Group $privilegedFinanceGroup -User $user
}
}
$currentUser = Get-MgUser `
-UserId $UserPrincipalName `
-Property Id,DisplayName,UserPrincipalName,Department,JobTitle,AccountEnabled
$currentUser
foreach ($group in @($engineeringGroup, $financeGroup, $privilegedFinanceGroup)) {
$members = @(Get-MgGroupMember -GroupId $group.Id -All)
[PSCustomObject]@{
Group = $group.DisplayName
ActiveMember = $members.Id -contains $currentUser.Id
}
}
How does the final state stay clean?
- The Joiner adds Alex to JML-Engineering.
- The Joiner removes Alex from both Finance groups.
- The final queries print the account attributes and direct membership state for verification.
- Save jml-lifecycle.ps1.
This command contains a disposable password. Keep the password unique to this lab account. Keep the terminal out of screenshots while the command remains visible.
- Replace your-tenant-id with your nonproduction tenant ID.
- Replace alex.kim@yourtenant.onmicrosoft.com with Alex's user principal name in your tenant.
- Replace your-lab-only-temporary-password with a unique temporary password used only for Alex.
Before you run the completed Joiner, predict which group should show an active membership:
- Run the completed Joiner action with this command:
./jml-lifecycle.ps1 -Action Joiner -TenantId "your-tenant-id" -UserPrincipalName "alex.kim@yourtenant.onmicrosoft.com" -DisplayName "Alex Kim" -MailNickname "alex.kim" -TemporaryPassword "your-lab-only-temporary-password"
What did the Joiner do?
The workflow creates or updates Alex as an enabled Engineering employee. It then reconciles direct membership across all three lab groups.
Running the same command again preserves the same account and entitlement state. That repeatability is the foundation of reliable lifecycle automation.
You should see Department as Engineering. You should also see AccountEnabled as True.
The membership rows should show JML-Engineering as True. Both Finance groups should show False.
That is the joiner baseline working. Alex now has the intended profile and only the intended standard access.
Joiner action stopped?
Confirm the temporary password differs from the placeholder in the script. Check that the user principal name uses a verified domain from your nonproduction tenant.
If group membership is unexpected, compare the three helper calls at the end of the Joiner action. Engineering uses the add helper while both Finance groups use the removal helper.
Help me debug the Joiner output.
✔️ Awesome, I've got everything!
Great. Confirm jml-lifecycle.ps1 is saved before configuring the Engineering application.
ⓧ I'd like to double check the full code
Compare your saved jml-lifecycle.ps1 with this cumulative Joiner version:
#Requires -Version 7.6
#Requires -PSEdition Core
#Requires -Modules @{ ModuleName="Microsoft.Graph"; RequiredVersion="2.41.2" }
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet("Joiner", "MoveAttributeOnly", "Mover", "Leaver")]
[string]$Action,
[Parameter(Mandatory = $true)]
[string]$TenantId,
[Parameter(Mandatory = $true)]
[string]$UserPrincipalName,
[string]$DisplayName = "Alex Kim",
[string]$MailNickname = "alex.kim",
[string]$TemporaryPassword = "your-lab-only-temporary-password"
)
$ErrorActionPreference = "Stop"
$scopes = @(
"User.ReadWrite.All",
"Group.ReadWrite.All",
"User.RevokeSessions.All"
)
Connect-MgGraph -TenantId $TenantId -Scopes $scopes -ContextScope Process -NoWelcome
function Get-OrCreateLabGroup {
param(
[Parameter(Mandatory = $true)]
[string]$DisplayName,
[Parameter(Mandatory = $true)]
[string]$MailNickname,
[Parameter(Mandatory = $true)]
[string]$Description
)
$matches = @(Get-MgGroup -Filter "DisplayName eq '$DisplayName'")
if ($matches.Count -gt 1) {
throw "More than one group named '$DisplayName' exists. Resolve the duplicate before continuing."
}
if ($matches.Count -eq 1) {
return $matches[0]
}
$group = New-MgGroup `
-DisplayName $DisplayName `
-Description $Description `
-MailEnabled:$false `
-MailNickName $MailNickname `
-SecurityEnabled
return $group
}
function Add-LabGroupMember {
param(
[Parameter(Mandatory = $true)]
[object]$Group,
[Parameter(Mandatory = $true)]
[object]$User
)
$members = @(Get-MgGroupMember -GroupId $Group.Id -All)
if ($members.Id -notcontains $User.Id) {
$odataId = "https://graph.microsoft.com/v1.0/directoryObjects/$($User.Id)"
New-MgGroupMemberByRef -GroupId $Group.Id -OdataId $odataId
}
}
function Remove-LabGroupMember {
param(
[Parameter(Mandatory = $true)]
[object]$Group,
[Parameter(Mandatory = $true)]
[object]$User
)
$members = @(Get-MgGroupMember -GroupId $Group.Id -All)
if ($members.Id -contains $User.Id) {
Remove-MgGroupMemberByRef `
-GroupId $Group.Id `
-DirectoryObjectId $User.Id
}
}
$engineeringGroup = Get-OrCreateLabGroup `
-DisplayName "JML-Engineering" `
-MailNickname "jmlengineering" `
-Description "Standard Engineering access for the JML lab"
$financeGroup = Get-OrCreateLabGroup `
-DisplayName "JML-Finance" `
-MailNickname "jmlfinance" `
-Description "Standard Finance access for the JML lab"
$privilegedFinanceGroup = Get-OrCreateLabGroup `
-DisplayName "JML-Finance-Privileged" `
-MailNickname "jmlfinanceprivileged" `
-Description "Time-bound privileged Finance application access for the JML lab"
$user = $null
try {
$user = Get-MgUser `
-UserId $UserPrincipalName `
-Property Id,DisplayName,UserPrincipalName,Department,JobTitle,AccountEnabled
}
catch {
if ($Action -ne "Joiner") {
throw "The user '$UserPrincipalName' does not exist. Run the Joiner action first."
}
}
switch ($Action) {
"Joiner" {
if ($null -eq $user) {
if ($TemporaryPassword -eq "your-lab-only-temporary-password") {
throw "Supply a unique lab-only temporary password with -TemporaryPassword."
}
$passwordProfile = @{
Password = $TemporaryPassword
}
$user = New-MgUser `
-DisplayName $DisplayName `
-UserPrincipalName $UserPrincipalName `
-MailNickName $MailNickname `
-PasswordProfile $passwordProfile `
-AccountEnabled `
-Department "Engineering" `
-JobTitle "Software Engineer"
}
else {
$joinerUpdate = @{
accountEnabled = $true
department = "Engineering"
jobTitle = "Software Engineer"
}
Update-MgUser -UserId $user.Id -BodyParameter $joinerUpdate
}
Add-LabGroupMember -Group $engineeringGroup -User $user
Remove-LabGroupMember -Group $financeGroup -User $user
Remove-LabGroupMember -Group $privilegedFinanceGroup -User $user
}
}
$currentUser = Get-MgUser `
-UserId $UserPrincipalName `
-Property Id,DisplayName,UserPrincipalName,Department,JobTitle,AccountEnabled
$currentUser
foreach ($group in @($engineeringGroup, $financeGroup, $privilegedFinanceGroup)) {
$members = @(Get-MgGroupMember -GroupId $group.Id -All)
[PSCustomObject]@{
Group = $group.DisplayName
ActiveMember = $members.Id -contains $currentUser.Id
}
}
What should match?
Check the requirements and connection first. Then compare all three helpers before checking the group definitions and Joiner action.
Configure the Engineering OIDC application
An app registration defines the OIDC protocol settings. Its matching enterprise application controls which tenant identities can sign in.
- Return to the Microsoft Entra admin center from earlier.
- Select Entra ID.
- Select App registrations.
- Select New registration.
- Enter JML Engineering OIDC Lab as the application name.
- Choose the account option that limits sign-in to this organizational directory.
- Create the registration.
You should now see the overview for JML Engineering OIDC Lab. This registration is limited to your nonproduction tenant.
- Select Authentication in the app registration.
- Add a Web platform.
- Enter https://jwt.ms as the redirect URI.
- Select ID tokens (used for implicit and hybrid flows).
- Save the authentication configuration.
Why enable ID tokens here?
The implicit ID-token setting lets this protocol-inspection lab send a token directly to jwt.ms. The setting stays enabled only while you run the OIDC tests in this project.
Microsoft recommends Authorization Code flow with PKCE for new production applications. That flow protects production sign-in more effectively.
- Return to Entra ID.
- Select Enterprise apps.
- Select All applications.
- Open JML Engineering OIDC Lab.
- Set the application properties to require assignment before sign-in.
- Select Users and groups.
- Select Add user/group.
- Choose JML-Engineering as the assigned group.
You should see JML-Engineering listed on the application's assignment page. Alex receives access through direct membership in that group.
Group unavailable for assignment?
Confirm you opened the enterprise application for the OIDC lab. Check that the search uses the exact JML-Engineering display name.
Help me fix the Engineering app assignment.
Sign in as Alex and inspect the ID token
The application assignment becomes meaningful when Alex can complete a real sign-in. A separate Safari session keeps the employee test isolated from the administrator session.
- Return to the app registration overview for JML Engineering OIDC Lab.
- Record the tenant identifier here: your tenant ID.
- Record the application identifier here: your client ID.
- Copy the documented authorization URL pattern from this block:
https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize?client_id={client_id}&response_type=id_token&redirect_uri=https://jwt.ms&scope=openid&nonce={random_value}
How does this URL work?
- The tenant segment sends the request to your nonproduction directory.
- The client ID identifies the Engineering app registration.
- The response type requests an ID token.
- The redirect URI sends the result to jwt.ms for inspection.
- Replace {tenant} with your tenant ID.
- Replace {client_id} with your client ID.
- Replace {random_value} with a fresh nonce value for this browser session.
Before you submit the authorization URL, predict whether Alex's Engineering group assignment allows the request to reach jwt.ms:
- Press Cmd+Space to open your Mac search bar.
- Type Safari and press Enter to open it.
- Start a private browsing session for the employee test.
- Paste the completed authorization URL into Safari's address bar.
- Sign in as Alex with the unique lab-only temporary password.
- Complete Alex's MFA prompt if the tenant requests it.
You should reach jwt.ms after sign-in. The page should display a decoded ID token issued for Alex and the Engineering application.
You have now proved the complete joiner path. The script created Alex's entitlement while the OIDC sign-in showed that the entitlement grants real application access.
OIDC sign-in not reaching jwt.ms?
Confirm the app uses https://jwt.ms as its Web redirect URI. Check that ID tokens (used for implicit and hybrid flows) remains selected.
Confirm the enterprise application requires assignment. Verify JML-Engineering is assigned and Alex remains a direct member.
Help me troubleshoot the OIDC sign-in.
Alex now has a repeatable Engineering joiner path with a visible sign-in result. Next, you will change only the profile attributes and expose the stale access that remains behind.
Expose the Attribute-Only Mover Failure
Your Joiner baseline proves that Alex can reach the Engineering application through group assignment. The next question is whether a department change keeps the identity profile aligned with the access it controls.
A dependable mover updates profile data plus entitlements as one lifecycle event. In this step, you will run an attribute-only move and test the resulting access state.
In this step, get ready to:
- Add the MoveAttributeOnly branch to the lifecycle script.
- Compare the updated profile against the membership table.
- Retest Engineering OIDC access in a fresh Safari session.
Add the attribute-only mover action
The PowerShell switch selects a lifecycle action from the value passed to $Action. The new branch uses Microsoft Graph to update Alex's identity attributes.
- In jml-lifecycle.ps1, locate the switch ($Action) block.
- Insert this case immediately after the Joiner case:
"MoveAttributeOnly" {
$attributeOnlyUpdate = @{
department = "Finance"
jobTitle = "Financial Systems Analyst"
}
Update-MgUser -UserId $user.Id -BodyParameter $attributeOnlyUpdate
}
What does this action change?
- The $attributeOnlyUpdate hashtable holds Alex's new department.
- The same hashtable holds Alex's new job title.
- Update-MgUser writes those properties to the user selected through $user.Id.
- Save jml-lifecycle.ps1.
✔️ Awesome, I've got everything!
The new MoveAttributeOnly branch is saved. Your script is ready for the mover test.
ⓧ I'd like to double check the full code
Compare your saved jml-lifecycle.ps1 with this cumulative version.
#Requires -Version 7.6
#Requires -PSEdition Core
#Requires -Modules @{ ModuleName="Microsoft.Graph"; RequiredVersion="2.41.2" }
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet("Joiner", "MoveAttributeOnly", "Mover", "Leaver")]
[string]$Action,
[Parameter(Mandatory = $true)]
[string]$TenantId,
[Parameter(Mandatory = $true)]
[string]$UserPrincipalName,
[string]$DisplayName = "Alex Kim",
[string]$MailNickname = "alex.kim",
[string]$TemporaryPassword = "your-lab-only-temporary-password"
)
$ErrorActionPreference = "Stop"
$scopes = @(
"User.ReadWrite.All",
"Group.ReadWrite.All",
"User.RevokeSessions.All"
)
Connect-MgGraph -TenantId $TenantId -Scopes $scopes -ContextScope Process -NoWelcome
function Get-OrCreateLabGroup {
param(
[Parameter(Mandatory = $true)]
[string]$DisplayName,
[Parameter(Mandatory = $true)]
[string]$MailNickname,
[Parameter(Mandatory = $true)]
[string]$Description
)
$matches = @(Get-MgGroup -Filter "DisplayName eq '$DisplayName'")
if ($matches.Count -gt 1) {
throw "More than one group named '$DisplayName' exists. Resolve the duplicate before continuing."
}
if ($matches.Count -eq 1) {
return $matches[0]
}
$group = New-MgGroup `
-DisplayName $DisplayName `
-Description $Description `
-MailEnabled:$false `
-MailNickName $MailNickname `
-SecurityEnabled
return $group
}
function Add-LabGroupMember {
param(
[Parameter(Mandatory = $true)]
[object]$Group,
[Parameter(Mandatory = $true)]
[object]$User
)
$members = @(Get-MgGroupMember -GroupId $Group.Id -All)
if ($members.Id -notcontains $User.Id) {
$odataId = "https://graph.microsoft.com/v1.0/directoryObjects/$($User.Id)"
New-MgGroupMemberByRef -GroupId $Group.Id -OdataId $odataId
}
}
function Remove-LabGroupMember {
param(
[Parameter(Mandatory = $true)]
[object]$Group,
[Parameter(Mandatory = $true)]
[object]$User
)
$members = @(Get-MgGroupMember -GroupId $Group.Id -All)
if ($members.Id -contains $User.Id) {
Remove-MgGroupMemberByRef `
-GroupId $Group.Id `
-DirectoryObjectId $User.Id
}
}
$engineeringGroup = Get-OrCreateLabGroup `
-DisplayName "JML-Engineering" `
-MailNickname "jmlengineering" `
-Description "Standard Engineering access for the JML lab"
$financeGroup = Get-OrCreateLabGroup `
-DisplayName "JML-Finance" `
-MailNickname "jmlfinance" `
-Description "Standard Finance access for the JML lab"
$privilegedFinanceGroup = Get-OrCreateLabGroup `
-DisplayName "JML-Finance-Privileged" `
-MailNickname "jmlfinanceprivileged" `
-Description "Time-bound privileged Finance application access for the JML lab"
$user = $null
try {
$user = Get-MgUser `
-UserId $UserPrincipalName `
-Property Id,DisplayName,UserPrincipalName,Department,JobTitle,AccountEnabled
}
catch {
if ($Action -ne "Joiner") {
throw "The user '$UserPrincipalName' does not exist. Run the Joiner action first."
}
}
switch ($Action) {
"Joiner" {
if ($null -eq $user) {
if ($TemporaryPassword -eq "your-lab-only-temporary-password") {
throw "Supply a unique lab-only temporary password with -TemporaryPassword."
}
$passwordProfile = @{
Password = $TemporaryPassword
}
$user = New-MgUser `
-DisplayName $DisplayName `
-UserPrincipalName $UserPrincipalName `
-MailNickName $MailNickname `
-PasswordProfile $passwordProfile `
-AccountEnabled `
-Department "Engineering" `
-JobTitle "Software Engineer"
}
else {
$joinerUpdate = @{
accountEnabled = $true
department = "Engineering"
jobTitle = "Software Engineer"
}
Update-MgUser -UserId $user.Id -BodyParameter $joinerUpdate
}
Add-LabGroupMember -Group $engineeringGroup -User $user
Remove-LabGroupMember -Group $financeGroup -User $user
Remove-LabGroupMember -Group $privilegedFinanceGroup -User $user
}
"MoveAttributeOnly" {
$attributeOnlyUpdate = @{
department = "Finance"
jobTitle = "Financial Systems Analyst"
}
Update-MgUser -UserId $user.Id -BodyParameter $attributeOnlyUpdate
}
}
$currentUser = Get-MgUser `
-UserId $UserPrincipalName `
-Property Id,DisplayName,UserPrincipalName,Department,JobTitle,AccountEnabled
$currentUser
foreach ($group in @($engineeringGroup, $financeGroup, $privilegedFinanceGroup)) {
$members = @(Get-MgGroupMember -GroupId $group.Id -All)
[PSCustomObject]@{
Group = $group.DisplayName
ActiveMember = $members.Id -contains $currentUser.Id
}
}
How to compare this file
Check the new MoveAttributeOnly case against your local file. The Joiner branch should remain unchanged.
Run the incomplete move
The script prints Alex's current profile plus direct membership in all three lab groups. That output lets you compare identity data with entitlement data after one action.
- Replace your-tenant-id in the command below with the tenant ID you used earlier.
Before you run this, do you think the Engineering assignment will follow the new department value?
- Run the attribute-only move from the folder containing jml-lifecycle.ps1 by running:
./jml-lifecycle.ps1 -Action MoveAttributeOnly -TenantId "your-tenant-id" -UserPrincipalName "alex.kim@yourtenant.onmicrosoft.com"
What does this command test?
- -Action MoveAttributeOnly selects the new mover branch.
- -TenantId keeps the operation inside your verified nonproduction tenant.
- -UserPrincipalName identifies Alex as the employee being moved.
You'll see Department set to Finance. You'll also see JobTitle set to Financial Systems Analyst.
The membership table still shows JML-Engineering with ActiveMember set to True. Both Finance groups remain False.
This mismatch is the intended finding
Alex's profile now says Finance. The Engineering entitlement still remains active.
The attribute update succeeded without changing the authorization state. You have reproduced the stale-access risk this step was designed to expose.
Mover command not running?
- Confirm the terminal is in the folder containing jml-lifecycle.ps1.
- Confirm the action name is spelled MoveAttributeOnly.
- Confirm Alex's user principal name matches the account created during the Joiner action.
Help me diagnose the mover command output.
Prove the stale access still works
A fresh private session removes the earlier application session from this test. The next sign-in checks the access decision again using Alex's current tenant state.
- Switch back to Safari from the earlier token test.
- Start a fresh private-browsing session.
Before you sign in, do you think Alex's Finance profile will block the Engineering token?
- Paste the Engineering authorization URL from earlier into Safari's address bar.
- Press Enter to load the authorization request.
- Sign in with Alex's lab credentials.
- Complete MFA if prompted.
You'll reach jwt.ms again. The page still displays an ID token for Alex.
That fresh token proves the stale Engineering group assignment still grants application access. Changing the department attribute did not withdraw the old entitlement.
Engineering sign-in not reaching jwt.ms?
- Confirm the previous terminal output still shows Alex as an active member of JML-Engineering.
- Use the Engineering application's authorization URL from the earlier successful test.
- Confirm the private session uses Alex's lab identity.
Help me trace the Engineering sign-in result.
You have now captured the exact mismatch an auditor needs to see. Next, you'll align Alex's entitlements with Finance and protect the new access path.
Correct Access and Guard the Finance App
Your stale-access test exposed exactly what an attribute-only move misses. Alex's profile says Finance while the Engineering OIDC application still accepts the old entitlement.
A complete mover reconciles identity data with every affected entitlement. This closes the Engineering access path before standard Finance access is granted.
You will correct the Microsoft Graph workflow. You will then guard the Finance SAML application with Conditional Access and MFA.
In this step, get ready to:
- Replace stale Engineering membership with standard Finance membership through the Mover action.
- Configure Microsoft Entra SAML Toolkit for privileged Finance assignment.
- Validate Finance MFA in report-only mode before enabling the policy.
Correct Alex's access with the Mover action
The complete mover keeps Alex's Finance profile attributes. It also reconciles all three lifecycle groups with the access Alex should hold now.
- Return to the code editor containing jml-lifecycle.ps1.
- Locate the MoveAttributeOnly branch inside the switch block.
- Add the following Mover branch immediately below it:
"Mover" {
$moverUpdate = @{
department = "Finance"
jobTitle = "Financial Systems Analyst"
}
Update-MgUser -UserId $user.Id -BodyParameter $moverUpdate
Remove-LabGroupMember -Group $engineeringGroup -User $user
Add-LabGroupMember -Group $financeGroup -User $user
Remove-LabGroupMember -Group $privilegedFinanceGroup -User $user
}
What Does This Code Do?
- The $moverUpdate table keeps Alex's department aligned with the Finance role.
- Remove-LabGroupMember removes the stale direct membership from JML-Engineering.
- Add-LabGroupMember grants standard access through JML-Finance.
- The final removal keeps JML-Finance-Privileged empty until approved activation occurs in the next step.
- Save jml-lifecycle.ps1.
Before you run this, predict which memberships will change.
- Return to the PowerShell session from earlier.
- Replace your-tenant-id with the nonproduction tenant ID from earlier.
- Run the corrected Mover action by using this command:
./jml-lifecycle.ps1 -Action Mover -TenantId "your-tenant-id" -UserPrincipalName "alex.kim@yourtenant.onmicrosoft.com"
You should see Alex's department remain Finance. The job title should remain Financial Systems Analyst.
The membership table should show JML-Engineering as False. It should show JML-Finance as True.
You should also see JML-Finance-Privileged remain False.
Memberships Not Changing?
Confirm that the Mover branch sits inside the existing switch block. Check that its capitalization matches the action in the command.
A duplicate group name also stops the helper before it changes membership. Resolve any duplicate named by the script before running the action again.
Help me debug the Mover action in my JML lifecycle script.
✔️ Awesome, I've got everything!
Great. Your Mover action now reconciles Alex's standard group access with the Finance role.
ⓧ I'd like to double check the full code
Compare your current jml-lifecycle.ps1 with this cumulative version:
#Requires -Version 7.6
#Requires -PSEdition Core
#Requires -Modules @{ ModuleName="Microsoft.Graph"; RequiredVersion="2.41.2" }
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet("Joiner", "MoveAttributeOnly", "Mover", "Leaver")]
[string]$Action,
[Parameter(Mandatory = $true)]
[string]$TenantId,
[Parameter(Mandatory = $true)]
[string]$UserPrincipalName,
[string]$DisplayName = "Alex Kim",
[string]$MailNickname = "alex.kim",
[string]$TemporaryPassword = "your-lab-only-temporary-password"
)
$ErrorActionPreference = "Stop"
$scopes = @(
"User.ReadWrite.All",
"Group.ReadWrite.All",
"User.RevokeSessions.All"
)
Connect-MgGraph -TenantId $TenantId -Scopes $scopes -ContextScope Process -NoWelcome
function Get-OrCreateLabGroup {
param(
[Parameter(Mandatory = $true)]
[string]$DisplayName,
[Parameter(Mandatory = $true)]
[string]$MailNickname,
[Parameter(Mandatory = $true)]
[string]$Description
)
$matches = @(Get-MgGroup -Filter "DisplayName eq '$DisplayName'")
if ($matches.Count -gt 1) {
throw "More than one group named '$DisplayName' exists. Resolve the duplicate before continuing."
}
if ($matches.Count -eq 1) {
return $matches[0]
}
$group = New-MgGroup `
-DisplayName $DisplayName `
-Description $Description `
-MailEnabled:$false `
-MailNickName $MailNickname `
-SecurityEnabled
return $group
}
function Add-LabGroupMember {
param(
[Parameter(Mandatory = $true)]
[object]$Group,
[Parameter(Mandatory = $true)]
[object]$User
)
$members = @(Get-MgGroupMember -GroupId $Group.Id -All)
if ($members.Id -notcontains $User.Id) {
$odataId = "https://graph.microsoft.com/v1.0/directoryObjects/$($User.Id)"
New-MgGroupMemberByRef -GroupId $Group.Id -OdataId $odataId
}
}
function Remove-LabGroupMember {
param(
[Parameter(Mandatory = $true)]
[object]$Group,
[Parameter(Mandatory = $true)]
[object]$User
)
$members = @(Get-MgGroupMember -GroupId $Group.Id -All)
if ($members.Id -contains $User.Id) {
Remove-MgGroupMemberByRef `
-GroupId $Group.Id `
-DirectoryObjectId $User.Id
}
}
$engineeringGroup = Get-OrCreateLabGroup `
-DisplayName "JML-Engineering" `
-MailNickname "jmlengineering" `
-Description "Standard Engineering access for the JML lab"
$financeGroup = Get-OrCreateLabGroup `
-DisplayName "JML-Finance" `
-MailNickname "jmlfinance" `
-Description "Standard Finance access for the JML lab"
$privilegedFinanceGroup = Get-OrCreateLabGroup `
-DisplayName "JML-Finance-Privileged" `
-MailNickname "jmlfinanceprivileged" `
-Description "Time-bound privileged Finance application access for the JML lab"
$user = $null
try {
$user = Get-MgUser `
-UserId $UserPrincipalName `
-Property Id,DisplayName,UserPrincipalName,Department,JobTitle,AccountEnabled
}
catch {
if ($Action -ne "Joiner") {
throw "The user '$UserPrincipalName' does not exist. Run the Joiner action first."
}
}
switch ($Action) {
"Joiner" {
if ($null -eq $user) {
if ($TemporaryPassword -eq "your-lab-only-temporary-password") {
throw "Supply a unique lab-only temporary password with -TemporaryPassword."
}
$passwordProfile = @{
Password = $TemporaryPassword
}
$user = New-MgUser `
-DisplayName $DisplayName `
-UserPrincipalName $UserPrincipalName `
-MailNickName $MailNickname `
-PasswordProfile $passwordProfile `
-AccountEnabled `
-Department "Engineering" `
-JobTitle "Software Engineer"
}
else {
$joinerUpdate = @{
accountEnabled = $true
department = "Engineering"
jobTitle = "Software Engineer"
}
Update-MgUser -UserId $user.Id -BodyParameter $joinerUpdate
}
Add-LabGroupMember -Group $engineeringGroup -User $user
Remove-LabGroupMember -Group $financeGroup -User $user
Remove-LabGroupMember -Group $privilegedFinanceGroup -User $user
}
"MoveAttributeOnly" {
$attributeOnlyUpdate = @{
department = "Finance"
jobTitle = "Financial Systems Analyst"
}
Update-MgUser -UserId $user.Id -BodyParameter $attributeOnlyUpdate
}
"Mover" {
$moverUpdate = @{
department = "Finance"
jobTitle = "Financial Systems Analyst"
}
Update-MgUser -UserId $user.Id -BodyParameter $moverUpdate
Remove-LabGroupMember -Group $engineeringGroup -User $user
Add-LabGroupMember -Group $financeGroup -User $user
Remove-LabGroupMember -Group $privilegedFinanceGroup -User $user
}
}
$currentUser = Get-MgUser `
-UserId $UserPrincipalName `
-Property Id,DisplayName,UserPrincipalName,Department,JobTitle,AccountEnabled
$currentUser
foreach ($group in @($engineeringGroup, $financeGroup, $privilegedFinanceGroup)) {
$members = @(Get-MgGroupMember -GroupId $group.Id -All)
[PSCustomObject]@{
Group = $group.DisplayName
ActiveMember = $members.Id -contains $currentUser.Id
}
}
The Engineering application provides the next proof. A fresh sign-in should now fail because its assigned group no longer contains Alex.
Before you test it, predict whether Alex will still reach jwt.ms.
- Start a fresh private browsing session in Safari.
- Paste the Engineering authorization URL from the earlier OIDC test into the address bar.
- Sign in with the Alex Kim lab identity.
You should be denied before reaching jwt.ms. This proves that removing JML-Engineering membership removed Alex's assigned application entitlement.
Still Reaching jwt.ms?
Confirm that you started a fresh private session. A previous browser session can retain sign-in state from the stale-access test.
Run the Mover action again if the membership table still shows Alex in JML-Engineering.
Help me investigate why Alex still reaches the Engineering OIDC application.
The implicit ID-token option supported the protocol inspection tests. That lab-only setting has now served its purpose.
- Return to the Microsoft Entra admin center.
- Select Entra ID from the left menu.
- Select App registrations.
- Select JML Engineering OIDC Lab.
- Select Authentication.
- Clear ID tokens (used for implicit and hybrid flows).
- Select Save.
You should see the ID-token option remain cleared after the page saves. The https://jwt.ms redirect URI should remain configured.
Configure the Finance SAML application
The Finance application uses privileged group assignment as its access boundary. Standard membership in JML-Finance does not grant access to this sensitive application.
Application access stays reserved for temporary membership in JML-Finance-Privileged. This prepares the application for approved activation in the next step.
- Return to Entra ID in the Microsoft Entra admin center.
- Select Enterprise apps.
- Select New application.
- Search the gallery for Microsoft Entra SAML Toolkit.
- Select Microsoft Entra SAML Toolkit from the results.
- Complete the enterprise application creation.
You should reach the new enterprise application's overview page. If this toolkit already exists in the tenant, return to the existing instance instead.
Why Use the Existing Toolkit Instance?
Microsoft Entra SAML Toolkit has a fixed identifier. A tenant can contain only one configured instance of this gallery application.
- Select Single sign-on for the toolkit enterprise application.
- Select SAML as the sign-on method.
- Edit Basic SAML Configuration.
- Enter https://samltoolkit.azurewebsites.net/SAML/Consume as the Reply URL.
- Enter https://samltoolkit.azurewebsites.net/ as the Sign on URL.
- Save the Basic SAML Configuration.
You should see both toolkit URLs in Basic SAML Configuration. These values establish the initial service-provider endpoints for the certificate exchange.
The certificate exchange lets the toolkit validate assertions issued by your tenant. It also gives Entra the final service-provider values.
- Download Certificate (Raw) from the SAML configuration page.
- Record the listed Login URL here: your Login URL.
- Record the listed Microsoft Entra Identifier here: your Microsoft Entra Identifier.
- Record the listed Logout URL here: your Logout URL.
Keep Both Sides Distinct
The Entra values describe the identity provider. The toolkit values describe the service provider.
Each side needs the other side's values before it can validate the SAML exchange.
- Follow the toolkit's Sign on URL to its configuration page.
- Enter your Login URL as the identity-provider login value.
- Enter your Microsoft Entra Identifier as the identity-provider identifier.
- Enter your Logout URL as the identity-provider logout value.
- Upload the raw certificate downloaded from Entra.
- Save the toolkit configuration.
The toolkit should now display its service-provider values. These are the final values that Entra uses for the SAML trust.
- Record the toolkit Sign-on URL here: your toolkit Sign-on URL.
- Record the toolkit Identifier here: your toolkit Identifier.
- Record the toolkit ACS URL here: your toolkit ACS URL.
- Return to Basic SAML Configuration in the toolkit enterprise application.
- Set the Sign on URL to your toolkit Sign-on URL.
- Set the Identifier to your toolkit Identifier.
- Set the Reply URL to your toolkit ACS URL.
- Save the updated Basic SAML Configuration.
You should see the toolkit's Sign-on URL, Identifier, and ACS URL saved in the enterprise application. The certificate exchange now connects both sides of the SAML trust.
- Select Properties for the Microsoft Entra SAML Toolkit enterprise application.
- Set Assignment required? to Yes.
- Save the application properties.
- Select Users and groups.
- Remove any previous lab assignments from the toolkit application.
- Select Add user/group.
- Choose JML-Finance-Privileged.
- Complete the group assignment.
You should see JML-Finance-Privileged as the only assigned access group. Alex remains outside that group until an approved activation occurs.
SAML Configuration Not Saving?
Confirm that the toolkit Identifier is in the identifier field. Confirm that the toolkit ACS URL is in the reply field.
If the gallery application already existed, verify that you updated the same instance. The toolkit's fixed identifier prevents a second configured instance in one tenant.
Help me troubleshoot the Microsoft Entra SAML Toolkit configuration.
Validate and enable Finance MFA
The privileged group controls who can reach the Finance application. Conditional Access controls how an allowed identity must authenticate.
Report-only mode records how the MFA policy evaluates a sign-in without enforcing its grant control. This gives you evidence before the policy changes the sign-in path.
Why Start in Report-Only?
Report-only results appear in the Conditional Access and Report-only tabs of sign-in details. You can verify the policy scope before turning enforcement on.
- Return to the Microsoft Entra admin center.
- Select Entra ID.
- Select Conditional Access.
- Select Policies.
- Select New policy.
- Enter Finance SAML MFA as the policy name.
- Include the Alex Kim lab identity under Users.
- Select Microsoft Entra SAML Toolkit under Target resources.
- Select Grant under Access controls.
- Select Grant access.
- Select Require authentication strength.
- Choose Multifactor authentication strength.
- Set Enable policy to Report-only.
- Create the policy.
You should see Finance SAML MFA in the policy list with a report-only state.
Before you test the Finance sign-in, predict whether report-only mode will enforce MFA.
- Start a fresh private browsing session in Safari.
- Enter your toolkit Sign-on URL in the address bar.
- Sign in with the Alex Kim lab identity.
The report-only policy should not enforce its MFA grant control. The application should still deny Alex because assignment is limited to JML-Finance-Privileged.
- Return to the Microsoft Entra admin center.
- Open the sign-in logs for Entra ID.
- Select the latest Microsoft Entra SAML Toolkit event for Alex.
- Select the Conditional Access tab in the sign-in details.
- Select the Report-only tab.
- Confirm that Finance SAML MFA evaluated the built-in MFA strength for this sign-in.
That sign-in record is your safe-rollout evidence. It proves the policy targets Alex's Finance application sign-in before enforcement.
Enabling a Conditional Access policy can change a user's sign-in path. This lab policy stays narrowly scoped to Alex and Microsoft Entra SAML Toolkit.
- Return to the Finance SAML MFA policy.
- Set Enable policy to On.
- Save the policy.
You should see the policy state change to On in the policy list.
Before you repeat the Finance sign-in, predict how the active MFA policy will change the attempt.
- Start another fresh private browsing session in Safari.
- Enter your toolkit Sign-on URL in the address bar.
- Sign in with the Alex Kim lab identity.
- Complete Alex's registered MFA method if Entra requests it.
The active policy should now enforce its MFA requirement during the sign-in path. Application access should remain denied because Alex has no active JML-Finance-Privileged membership.
- Return to Alex's latest Microsoft Entra SAML Toolkit sign-in event.
- Select the Conditional Access tab.
- Confirm that Finance SAML MFA appears as an enforced policy result.
- Return to the Conditional Access policy list.
- Confirm that Finance SAML MFA still shows the state On.
Policy Missing From Sign-In Details?
Confirm that the policy targets Alex directly. Confirm that Microsoft Entra SAML Toolkit is the selected target resource.
Generate a fresh sign-in after each policy-state change. An older event preserves the evaluation from the time of that sign-in.
Help me troubleshoot the Finance SAML Conditional Access result.
Strong work. Alex now has only standard Finance access while the sensitive SAML application waits behind MFA and privileged group assignment.
Next, you will make that privileged access time-bound through independent approval. You will then offboard Alex and capture the final audit evidence.
Activate Privilege, Offboard, and Prove It
Alex now has standard Finance access. The sensitive Finance application still has no standing employee access.
Privileged access must be time-bound. Independent approval makes each activation accountable.
You will use PIM for Groups to prove approved access through MFA. You will then extend the Microsoft Graph workflow in jml-lifecycle.ps1 so offboarding removes access and disables the employee.
In this step, get ready to:
- Configure approved PIM eligibility for Alex.
- Prove time-bound access to the Finance SAML application.
- Complete Alex's offboarding with audit evidence.
Put the privileged group under PIM control
PIM makes Alex eligible for privileged group membership without granting permanent access. Activation rules add a time limit and an independent approval checkpoint.
PIM management is persistent
Once PIM manages a group, the group cannot be taken out of management. This lab uses the dedicated JML-Finance-Privileged group so the lasting change stays isolated from production access.
- Switch back to the Microsoft Entra admin center in the administrator session from earlier.
- Select ID Governance in the sidebar.
- Select Privileged Identity Management.
- Select Groups.
- Select Discover groups.
- Select JML-Finance-Privileged.
- Select Manage groups.
- Select OK to bring the group under PIM management.
The group now appears among the groups managed by PIM. Its activation rules still need the controls that make temporary access defensible.
- Return to the managed JML-Finance-Privileged group.
- Select Settings.
- Select Member.
- Select Edit.
- Require multifactor authentication for activation.
- Require a justification for activation.
- Require approval for activation.
- Select the separate approver identity as the approver.
- Select Update to save the Member activation rules.
Why independent approval?
An approver cannot approve their own activation request. A separate approver creates evidence that another person reviewed Alex's business reason before access became active.
- Select Assignments for the managed group.
- Select Add assignments.
- Choose Member as the role.
- Select Alex Kim as the member.
- Select Next.
- Choose Eligible as the assignment type.
- Select Assign.
- Select Eligible assignments to verify Alex is listed as a Member.
Good work. Alex can now request privileged Finance membership without holding it permanently.
Can't create the eligible assignment?
Confirm the employee and approver have qualifying Microsoft Entra ID P2 or Microsoft Entra ID Governance coverage. PIM for Groups depends on that licensing.
Confirm the administrator has permission to manage membership for this security group.
Ask for help with the PIM assignment error.
Activate access and prove the SAML sign-in
Eligibility creates potential access. An approved activation converts it into temporary membership that the application's assignment can recognize.
- Return to the separate Safari session you used for Alex earlier.
- Select ID Governance in the Microsoft Entra admin center.
- Select Privileged Identity Management.
- Select My roles.
- Select Groups.
- Select Eligible assignments.
- Select Activate for JML-Finance-Privileged.
- Enter Validate time-bound Finance application access for this JML lab as the business justification.
- Submit the activation request.
The request should show that approval is pending. Alex still lacks privileged group membership at this point.
- Switch to a separate browser session for the approver identity.
- Sign in to the Microsoft Entra admin center as the separate approver.
- Select ID Governance.
- Select Privileged Identity Management.
- Select Approve requests.
- Select Groups.
- Select Alex's request for JML-Finance-Privileged.
The request links Alex's identity to the business justification you entered. The approver can now make an independent decision.
- Select Approve.
- Enter a short approval justification.
- Select Confirm.
- Return to Alex's Safari session.
- Refresh the activation request status.
A brief delay is expected while PIM adds the temporary group membership. Activation normally updates membership within seconds.
Before you open the Finance application, do you expect its assignment check to accept Alex now?
- Enter https://samltoolkit.azurewebsites.net/ in Safari's address bar.
- Complete Alex's MFA challenge when prompted.
- Finish the Microsoft Entra SAML Toolkit sign-in.
You should reach the toolkit through the temporary privileged-group assignment. That successful SAML sign-in proves the approved activation produced usable access.
Finance sign-in still denied?
Confirm the activation request shows as approved before retrying the application. Refresh the eligible assignment view if the request still appears pending.
Confirm JML-Finance-Privileged remains the only group assigned to the toolkit enterprise application.
Ask for help tracing the activation and application assignment.
- Return to the administrator session in the Microsoft Entra admin center.
- Select Resource audit for the PIM-managed group.
- Confirm the audit records show Alex's eligible assignment.
- Confirm the audit records show Alex's activation request.
- Confirm the audit records show the approval.
- Confirm the audit records show the activation.
- Return to the application sign-in details view from the previous step.
- Open Alex's successful Finance SAML sign-in event.
- Select the Conditional Access tab.
- Confirm the Finance MFA policy applied to the sign-in.
That's the privileged-access path proven. The request, approval, MFA result, group activation, and application sign-in now form one evidence chain.
Remove access and run the leaver workflow
PIM assignments cannot be removed within five minutes of creation. The activation and SAML test should have consumed most or all of that minimum window.
If the interval is still running, the short wait is expected. Removal becomes available once the five-minute minimum has elapsed.
- Return to JML-Finance-Privileged in the administrator's PIM session.
- Select Assignments.
- Select Active assignments.
- Select Alex's active Member assignment.
- Select Update or Remove.
- Remove the active assignment.
Alex no longer has active privileged group membership. The eligible assignment still needs removal so the employee cannot request access again.
- Select Eligible assignments.
- Select Alex's eligible Member assignment.
- Select Update or Remove.
- Remove the eligible assignment.
- Check Active assignments to confirm Alex is absent.
- Check Eligible assignments to confirm Alex is absent.
The privileged path is now closed. The remaining leaver work removes standard group access and disables the account through PowerShell.
- Return to the local jml-lifecycle.ps1 file from earlier.
- Locate the ValidateSet attribute above the $Action parameter.
- Add the leaver option so the line looks like this:
[ValidateSet("Joiner", "MoveAttributeOnly", "Mover", "Leaver")]
What does this change do?
The ValidateSet attribute now accepts Leaver as a valid action. PowerShell can route the command to the new switch branch instead of rejecting the value.
- Scroll to the switch ($Action) block.
- Find the closing brace for the Mover branch after Remove-LabGroupMember -Group $privilegedFinanceGroup -User $user.
- Add the following branch below the Mover branch before the switch block closes:
"Leaver" {
Remove-LabGroupMember -Group $engineeringGroup -User $user
Remove-LabGroupMember -Group $financeGroup -User $user
Remove-LabGroupMember -Group $privilegedFinanceGroup -User $user
$null = Revoke-MgUserSignInSession -UserId $user.Id
$leaverUpdate = @{
accountEnabled = $false
department = "Former Employee"
jobTitle = "Offboarded"
}
Update-MgUser -UserId $user.Id -BodyParameter $leaverUpdate
}
What does the Leaver action do?
- The three Remove-LabGroupMember calls remove any remaining direct Engineering or Finance access.
- Revoke-MgUserSignInSession invalidates Alex's refresh tokens and browser session cookies.
- $leaverUpdate disables the account.
- $leaverUpdate records the employee as a Former Employee with an Offboarded job title.
- Update-MgUser applies the final account state through Microsoft Graph.
What session revocation means
Session revocation invalidates refresh tokens and browser session cookies. An already-issued access token can remain valid until it expires, so disabling the account adds the stronger offboarding boundary.
- Save jml-lifecycle.ps1.
Before you run the leaver action, do you expect any direct group membership to remain true?
- Run the final leaver workflow from the directory containing jml-lifecycle.ps1 with this command:
./jml-lifecycle.ps1 -Action Leaver -TenantId "your-tenant-id" -UserPrincipalName "alex.kim@yourtenant.onmicrosoft.com"
What does this command prove?
The command runs every offboarding operation under the script's stop-on-error behavior. Reaching the final output proves the group removals, session revocation, and account update completed without an unhandled failure.
You should see AccountEnabled as False. You should also see Department as Former Employee and JobTitle as Offboarded.
The membership output should show ActiveMember as False for JML-Engineering, JML-Finance, and JML-Finance-Privileged.
Leaver action failing?
If PowerShell rejects the action value, confirm Leaver appears in the ValidateSet line.
If session revocation fails, confirm the administrator remains connected with the User.RevokeSessions.All delegated scope.
Ask for help with the leaver output.
✔️ Awesome, I've got everything!
Great. The final output proves Alex's account is disabled and all three direct group memberships are removed.
ⓧ I'd like to double check the full code
Compare your saved jml-lifecycle.ps1 with the final lifecycle script below.
#Requires -Version 7.6
#Requires -PSEdition Core
#Requires -Modules @{ ModuleName="Microsoft.Graph"; RequiredVersion="2.41.2" }
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet("Joiner", "MoveAttributeOnly", "Mover", "Leaver")]
[string]$Action,
[Parameter(Mandatory = $true)]
[string]$TenantId,
[Parameter(Mandatory = $true)]
[string]$UserPrincipalName,
[string]$DisplayName = "Alex Kim",
[string]$MailNickname = "alex.kim",
[string]$TemporaryPassword = "your-lab-only-temporary-password"
)
$ErrorActionPreference = "Stop"
$scopes = @(
"User.ReadWrite.All",
"Group.ReadWrite.All",
"User.RevokeSessions.All"
)
Connect-MgGraph -TenantId $TenantId -Scopes $scopes -ContextScope Process -NoWelcome
function Get-OrCreateLabGroup {
param(
[Parameter(Mandatory = $true)]
[string]$DisplayName,
[Parameter(Mandatory = $true)]
[string]$MailNickname,
[Parameter(Mandatory = $true)]
[string]$Description
)
$matches = @(Get-MgGroup -Filter "DisplayName eq '$DisplayName'")
if ($matches.Count -gt 1) {
throw "More than one group named '$DisplayName' exists. Resolve the duplicate before continuing."
}
if ($matches.Count -eq 1) {
return $matches[0]
}
$group = New-MgGroup `
-DisplayName $DisplayName `
-Description $Description `
-MailEnabled:$false `
-MailNickName $MailNickname `
-SecurityEnabled
return $group
}
function Add-LabGroupMember {
param(
[Parameter(Mandatory = $true)]
[object]$Group,
[Parameter(Mandatory = $true)]
[object]$User
)
$members = @(Get-MgGroupMember -GroupId $Group.Id -All)
if ($members.Id -notcontains $User.Id) {
$odataId = "https://graph.microsoft.com/v1.0/directoryObjects/$($User.Id)"
New-MgGroupMemberByRef -GroupId $Group.Id -OdataId $odataId
}
}
function Remove-LabGroupMember {
param(
[Parameter(Mandatory = $true)]
[object]$Group,
[Parameter(Mandatory = $true)]
[object]$User
)
$members = @(Get-MgGroupMember -GroupId $Group.Id -All)
if ($members.Id -contains $User.Id) {
Remove-MgGroupMemberByRef `
-GroupId $Group.Id `
-DirectoryObjectId $User.Id
}
}
$engineeringGroup = Get-OrCreateLabGroup `
-DisplayName "JML-Engineering" `
-MailNickname "jmlengineering" `
-Description "Standard Engineering access for the JML lab"
$financeGroup = Get-OrCreateLabGroup `
-DisplayName "JML-Finance" `
-MailNickname "jmlfinance" `
-Description "Standard Finance access for the JML lab"
$privilegedFinanceGroup = Get-OrCreateLabGroup `
-DisplayName "JML-Finance-Privileged" `
-MailNickname "jmlfinanceprivileged" `
-Description "Time-bound privileged Finance application access for the JML lab"
$user = $null
try {
$user = Get-MgUser `
-UserId $UserPrincipalName `
-Property Id,DisplayName,UserPrincipalName,Department,JobTitle,AccountEnabled
}
catch {
if ($Action -ne "Joiner") {
throw "The user '$UserPrincipalName' does not exist. Run the Joiner action first."
}
}
switch ($Action) {
"Joiner" {
if ($null -eq $user) {
if ($TemporaryPassword -eq "your-lab-only-temporary-password") {
throw "Supply a unique lab-only temporary password with -TemporaryPassword."
}
$passwordProfile = @{
Password = $TemporaryPassword
}
$user = New-MgUser `
-DisplayName $DisplayName `
-UserPrincipalName $UserPrincipalName `
-MailNickName $MailNickname `
-PasswordProfile $passwordProfile `
-AccountEnabled `
-Department "Engineering" `
-JobTitle "Software Engineer"
}
else {
$joinerUpdate = @{
accountEnabled = $true
department = "Engineering"
jobTitle = "Software Engineer"
}
Update-MgUser -UserId $user.Id -BodyParameter $joinerUpdate
}
Add-LabGroupMember -Group $engineeringGroup -User $user
Remove-LabGroupMember -Group $financeGroup -User $user
Remove-LabGroupMember -Group $privilegedFinanceGroup -User $user
}
"MoveAttributeOnly" {
$attributeOnlyUpdate = @{
department = "Finance"
jobTitle = "Financial Systems Analyst"
}
Update-MgUser -UserId $user.Id -BodyParameter $attributeOnlyUpdate
}
"Mover" {
$moverUpdate = @{
department = "Finance"
jobTitle = "Financial Systems Analyst"
}
Update-MgUser -UserId $user.Id -BodyParameter $moverUpdate
Remove-LabGroupMember -Group $engineeringGroup -User $user
Add-LabGroupMember -Group $financeGroup -User $user
Remove-LabGroupMember -Group $privilegedFinanceGroup -User $user
}
"Leaver" {
Remove-LabGroupMember -Group $engineeringGroup -User $user
Remove-LabGroupMember -Group $financeGroup -User $user
Remove-LabGroupMember -Group $privilegedFinanceGroup -User $user
$null = Revoke-MgUserSignInSession -UserId $user.Id
$leaverUpdate = @{
accountEnabled = $false
department = "Former Employee"
jobTitle = "Offboarded"
}
Update-MgUser -UserId $user.Id -BodyParameter $leaverUpdate
}
}
$currentUser = Get-MgUser `
-UserId $UserPrincipalName `
-Property Id,DisplayName,UserPrincipalName,Department,JobTitle,AccountEnabled
$currentUser
foreach ($group in @($engineeringGroup, $financeGroup, $privilegedFinanceGroup)) {
$members = @(Get-MgGroupMember -GroupId $group.Id -All)
[PSCustomObject]@{
Group = $group.DisplayName
ActiveMember = $members.Id -contains $currentUser.Id
}
}
- Capture the final terminal output showing Alex's disabled account state.
- Capture the three false direct-membership results from the terminal.
- Capture the empty active assignment view for Alex in JML-Finance-Privileged.
- Capture the empty eligible assignment view for Alex in JML-Finance-Privileged.
- Capture the PIM Resource audit records for eligibility, request, approval, activation, active-access removal, and eligibility removal.
- Keep the successful Finance sign-in details with the applied MFA policy as application-access evidence.
You have now proved the full privileged-access lifecycle. Alex received independently approved access for a limited window before every direct and eligible entitlement was removed.
Secret mission
Run an Access Certification Drill
Turn your final identity evidence into an auditor-ready access-certification record. Use Alex as a negative control to prove that no Finance access survived offboarding.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to retain the lab for more testing, pause its access controls, or remove its resources. The resources do not add usage-based cloud charges, but PIM for Groups requires qualifying license coverage.
Cost warning
The employee and approver require Microsoft Entra ID P2 or Microsoft Entra ID Governance coverage for PIM for Groups. Microsoft does not publish one universal P2 price because agreement pricing can vary.
Resources you used:
- Disabled employee account for Alex Kim, plus any administrator or approver accounts created only for this lab.
- Security groups named JML-Engineering, JML-Finance, and JML-Finance-Privileged.
- PIM-managed configuration attached to JML-Finance-Privileged.
- Engineering OIDC app registration and assignment-required enterprise application named JML Engineering OIDC Lab.
- Assignment-required Finance enterprise application named Microsoft Entra SAML Toolkit, plus its toolkit-side test account.
- Finance SAML MFA Conditional Access policy.
- Local PowerShell script named jml-lifecycle.ps1 and its Microsoft Graph command history.
Keep everything running
No action needed. Choose this if you want to demonstrate the completed lifecycle or review its evidence again.
- Keep Alex Kim disabled with no direct group access or PIM Finance access.
- Keep the Finance SAML MFA Conditional Access policy narrowly scoped to Alex and the Finance application.
- Keep JML-Engineering, JML-Finance, and JML-Finance-Privileged without Alex as a member.
- Keep the PIM Resource audit evidence and access-certification decision record available for review.
- Keep qualifying license coverage for any future PIM for Groups testing.
Pause - I'll come back to this later
Reduce the lab's active controls while preserving its disabled employee, applications, groups, and audit evidence for later review.
- In the Microsoft Entra admin center, select Entra ID.
- Select Conditional Access.
- Select Policies.
- Open the Finance SAML MFA Conditional Access policy.
- Set Enable policy to Report-only.
- Confirm the policy now shows Report-only in the policy list.
The policy can now record evaluation results without enforcing MFA while the lab is paused.
- Select ID Governance in the Microsoft Entra admin center.
- Select Privileged Identity Management.
- Select Groups.
- Open JML-Finance-Privileged.
- Confirm the Eligible assignments view has no Alex Kim assignment.
- Confirm the Active assignments view has no Alex Kim assignment.
Your lab is now paused with Alex still disabled and all Finance access still removed.
Delete - I don't want to use this again
Remove the lab resources completely when you no longer need its applications, identities, or groups.
Deletion is final, so preserve the access-certification decision record and required audit evidence before you continue.
- In the Microsoft Entra admin center, select Entra ID.
- Select Conditional Access.
- Select Policies.
- Open the Finance SAML MFA Conditional Access policy.
- Select Delete.
- Confirm the policy no longer appears under Policies.
The enforced MFA policy no longer targets Alex or the Finance SAML application.
- Select App registrations in Entra ID.
- Open JML Engineering OIDC Lab.
- Select Delete.
- Confirm JML Engineering OIDC Lab no longer appears under App registrations.
- Select Enterprise apps in Entra ID.
- Select All applications.
- Open the JML Engineering OIDC Lab enterprise application.
- Select Delete.
- Open Microsoft Entra SAML Toolkit from All applications.
- Select Delete.
- Confirm neither enterprise application remains under All applications.
- Return to Microsoft Entra SAML Toolkit.
- Open the toolkit-side test account created for this lab.
- Select its Delete control.
- Confirm the toolkit-side test account is no longer listed.
PIM Group Deletion Can Linger
A PIM-managed group cannot be taken out of PIM management. After deletion, it can take up to 24 hours to disappear from the PIM list.
- Open the JML-Engineering group record in Entra ID.
- Select Delete.
- Open the JML-Finance group record in Entra ID.
- Select Delete.
- Open the JML-Finance-Privileged group record in Entra ID.
- Select Delete.
- Confirm all three groups no longer appear in the Entra ID group list.
Deleting identities is also final, so remove the administrator or approver only when each account was created solely for this lab. Keep another tenant administrator available while deleting lab accounts.
- Open the Alex Kim user record in Entra ID.
- Select Delete.
- Delete the approver identity if it was created only for this lab.
- Delete the administrator identity if it was created only for this lab.
- Confirm the disposable identities no longer appear in the Entra ID user list.
- In Finder, locate jml-lifecycle.ps1 in the directory you used for this project.
- Move jml-lifecycle.ps1 to Trash.
- Review your local PowerShell command history for the lab-only temporary password.
- Remove every history entry that contains the temporary password.
- Empty Trash to remove the script from the Mac.
- Confirm Finder no longer finds jml-lifecycle.ps1.
- Confirm the PowerShell history no longer exposes the temporary password.
That completes the active teardown. The deleted PIM-managed group can remain visible in the PIM list for up to 24 hours.
Nice Work!
Nice Work!
Mission accomplished! Your auditable Microsoft Entra lifecycle now carries Alex from onboarding through verified offboarding.
You've learned how to:
- Automate Alex's complete identity lifecycle with the reusable jml-lifecycle.ps1 script. Use the Microsoft Graph PowerShell SDK to make each lifecycle action idempotent.
- Diagnose stale Engineering access through an OIDC sign-in after the attribute-only move. Correct the entitlement mismatch by removing the old group membership.
- Protect Finance SAML access with Conditional Access MFA. Grant time-bound privileged access through PIM approval. Prove offboarding with a retained audit trail.
- Secret Mission: Produce an auditor-ready access-certification decision record that reconciles every Finance assignment against Alex's disabled state.
Ready to quiz yourself?