Automate Identity Lifecycle with Entra ID
Build a PowerShell IAM toolkit with JML provisioning and RBAC drift detection.
Introduction
30 Second Summary
Every time someone joins a company, switches teams, or leaves, their access to systems needs to change instantly. In large organizations, handling this manually for hundreds of employees creates security gaps and governance nightmares.
In this project, you will build a PowerShell-based identity automation toolkit that reads an HR data feed and automatically provisions, transfers, and offboards users in Microsoft Entra ID via the Microsoft Graph API.
What You'll Build
A complete IAM automation toolkit that simulates how enterprise IAM engineers handle identity lifecycle at scale, processing Joiner, Mover, and Leaver records entirely through code.
By the end of this project, you'll have:
- An infrastructure-as-code setup script that creates the full RBAC structure (security groups, app registration with roles) via Graph API.
- A JML processing engine that reads an HR feed and executes provisioning actions against Entra ID.
- An RBAC drift detection script that audits user access against an authoritative source and generates a governance report.
- Secret Mission: Add a dry-run mode that previews all changes without executing them, plus a rollback function that can undo the last batch of provisioning actions.
Are there any prerequisites?
This project assumes SC-300 certification-level knowledge of Microsoft Entra ID and an existing Azure account with an Entra ID tenant. You should be comfortable navigating the Entra admin center and understand concepts like groups, app registrations, and role assignments.
Install the Microsoft Graph PowerShell SDK and Connect
The Microsoft Graph PowerShell SDK is the automation layer that lets PowerShell talk directly to Microsoft Entra ID. Every provisioning action in this project (creating users, managing groups, assigning roles) goes through this SDK.
In this step, you will install the SDK, confirm its sub-modules are available, and connect to your Entra ID tenant with the permissions needed for identity automation.
In this step, get ready to:
- Install the Microsoft Graph PowerShell SDK.
- Verify the sub-modules are present.
- Connect to Microsoft Graph with the required scopes.
Install the SDK
The SDK is published on the PowerShell Gallery and installs dozens of sub-modules that map to different Microsoft Graph API surfaces. You only need one command to pull everything down.
- Press the Windows key to open your search bar.
- Type PowerShell 7 and press Enter to open it.
- Install the Microsoft Graph PowerShell SDK by running this command:
Install-Module Microsoft.Graph -Scope CurrentUser -Repository PSGallery -Force
What does this command do?
- Install-Module pulls the module and all its sub-modules from the PowerShell Gallery.
- -Scope CurrentUser installs it for your account only, so you do not need administrator privileges.
- -Repository PSGallery explicitly targets the PowerShell Gallery as the source.
- -Force suppresses most confirmation prompts and makes the command safe to re-run.
Seeing an untrusted repository prompt?
PowerShell Gallery is not configured as a trusted repository by default. If you see a prompt asking whether you want to install modules from an untrusted repository, type Y and press Enter to continue.
- Wait for the installation to complete. This may take a few minutes as it downloads all sub-modules.
Install command failing?
- Make sure you are running PowerShell 7, not Windows PowerShell 5.1. Check with $PSVersionTable and confirm the PSVersion starts with 7.
- If you see a network or SSL error, you may be behind a corporate firewall or proxy that intercepts HTTPS traffic. Try from a different network connection.
- If PowerShell Gallery is not registered as a source, run Register-PSRepository -Default first, then retry the install command.
Help me troubleshoot the Microsoft Graph PowerShell SDK installation
Verify the installation
The install command pulls dozens of sub-modules. Confirming they are present ensures your environment is ready for the rest of the project.
- Verify the sub-modules are available by running this command:
Get-Module Microsoft.Graph -ListAvailable
✔️ I see a list of sub-modules
You should see entries like Microsoft.Graph.Users, Microsoft.Graph.Groups, and Microsoft.Graph.Applications in the output. The SDK is installed and ready.
ⓧ I see nothing or an error
- Run the install command again without -Force to see if any errors appear in the output.
- Confirm you are in the same PowerShell 7 session where you ran the install. If you opened a new window, the module should still be available because it installed to disk.
- Try Get-InstalledModule Microsoft.Graph as an alternative verification command.
Connect with the required scopes
The SDK needs an authenticated session before it can call any Graph API. You will connect with delegated access, which means you sign in as yourself and consent to a set of permission scopes.
- Connect to Microsoft Graph with the scopes this project requires by running this command:
Connect-MgGraph -Scopes "User.ReadWrite.All","Group.ReadWrite.All","Application.ReadWrite.All","AppRoleAssignment.ReadWrite.All","Directory.ReadWrite.All"
What happens when you run this?
A browser window or a Windows sign-in prompt opens asking you to authenticate. Sign in with the admin account that owns your Entra ID tenant.
After signing in, you will see a permissions consent screen listing the scopes you requested. Click Accept to grant all of them. Once consent is complete, your terminal displays a confirmation message.
Why these scopes?
Each scope unlocks a category of Graph API operations. User.ReadWrite.All lets you create and disable users. Group.ReadWrite.All manages security group memberships. Application.ReadWrite.All registers apps and defines app roles.
AppRoleAssignment.ReadWrite.All assigns those app roles to users. Directory.ReadWrite.All covers built-in directory role assignments. Together they provide the minimum access needed for the full IAM automation toolkit.
- Confirm you are connected by running:
Get-MgContext
✔️ I see my account details and scopes listed
The output shows your TenantId, Account, and a Scopes field listing the permissions you consented to. You are ready to proceed.
ⓧ Browser never opened or I see an error
- If the browser never opened, recent SDK versions use Windows Web Account Manager (WAM) by default, which can cause issues on some configurations. Add the -UseDeviceCode flag to the Connect-MgGraph command to bypass WAM and authenticate through a device code flow instead.
- If you see a permissions error, confirm you signed in with a Global Administrator or Privileged Role Administrator account on your tenant.
- If the consent screen did not appear, your tenant admin may have pre-consented or blocked consent. Contact your tenant administrator.
Help me troubleshoot my Microsoft Graph connection
ⓧ Seeing a redirect URI mismatch error
Recent versions of the Graph SDK enable Windows Web Account Manager (WAM) by default, which can cause redirect URI mismatch errors on some tenant configurations.
- Re-run the connect command with the -UseDeviceCode flag to bypass WAM and authenticate through your browser instead:
Connect-MgGraph -Scopes "User.ReadWrite.All","Group.ReadWrite.All","Application.ReadWrite.All","AppRoleAssignment.ReadWrite.All","Directory.ReadWrite.All" -UseDeviceCode
- Follow the on-screen instructions to visit the device login page, enter the code shown in your terminal, and sign in with your admin account.
Your SDK is installed and your session is authenticated. Next up, you will build the entire RBAC structure as code so that your IAM environment is fully reproducible.
Build the RBAC Structure as Code
In Step 1, you connected to Microsoft Graph with the permissions needed to manage your tenant. Now it is time to build the actual RBAC infrastructure your provisioning engine will depend on.
Enterprise IAM engineers never create groups, apps, or role assignments by hand in the portal. They script everything so the environment is reproducible, auditable, and safe to rebuild after a failure. In this step, you will write a single PowerShell script that creates the entire RBAC structure programmatically.
In this step, get ready to:
- Create an idempotent setup script that builds security groups and an app registration with roles.
- Execute the script and verify the resources exist in your Entra ID tenant.
- Assign built-in directory roles to groups for least-privilege delegation.
Create the environment setup script
Production IAM tooling must be idempotent. If a script crashes mid-execution, you need to restart it safely without duplicating resources. Your setup script will check whether each resource already exists before creating it.
Why idempotent scripts?
An idempotent script produces the same result no matter how many times you run it. If a group already exists, the script skips creation instead of throwing an error or making a duplicate.
This is a core infrastructure-as-code principle. It means you can safely re-run the script after a partial failure, network timeout, or when onboarding a new team member to the same environment.
- Create a new file called Setup-IAMEnvironment.ps1 in your working folder (the same folder where your PowerShell session is running).
- Add the file header and configuration section by pasting the following code:
#Requires -Modules Microsoft.Graph.Groups, Microsoft.Graph.Applications, Microsoft.Graph.Identity.DirectoryManagement
<#
.SYNOPSIS
Creates the RBAC structure for the IAM automation lab.
Idempotent: safe to run multiple times.
#>
# --- Configuration ---
$GroupDefinitions = @(
@{ DisplayName = "SG-Engineering"; Description = "Engineering department members"; MailNickname = "sg-engineering" }
@{ DisplayName = "SG-Finance"; Description = "Finance department members"; MailNickname = "sg-finance" }
@{ DisplayName = "SG-HR"; Description = "HR department members"; MailNickname = "sg-hr" }
)
$AppName = "Contoso Expense Portal"
$AppRoleDefinitions = @(
@{ DisplayName = "Submitter"; Value = "Expense.Submit"; Description = "Can submit expense reports"; Id = [Guid]::NewGuid().ToString() }
@{ DisplayName = "Approver"; Value = "Expense.Approve"; Description = "Can approve expense reports"; Id = [Guid]::NewGuid().ToString() }
@{ DisplayName = "Admin"; Value = "Expense.Admin"; Description = "Full administration of expense system"; Id = [Guid]::NewGuid().ToString() }
)
# Built-in role template IDs
$UserAdminRoleId = "fe930be7-5e62-47db-91af-98c3a49a38b1"
$GroupsAdminRoleId = "fdd7a751-b60b-444a-984c-02652fe8fa1c"
What does this configuration do?
- $GroupDefinitions defines three security groups that map to departments (Engineering, Finance, HR). Each group has a display name, description, and mail nickname.
- $AppRoleDefinitions defines three application roles (Submitter, Approver, Admin) that will be attached to the app registration. Each role gets a unique GUID generated at runtime.
- $UserAdminRoleId and $GroupsAdminRoleId store the stable GUIDs for Microsoft Entra ID built-in roles. You will use these in substep 3 to delegate admin capabilities to groups.
- Below the configuration section, add the functions section header and the New-IdempotentGroup function:
# --- Functions ---
function New-IdempotentGroup {
param([hashtable]$GroupDef)
$existing = Get-MgGroup -Filter "displayName eq '$($GroupDef.DisplayName)'" -ErrorAction SilentlyContinue
if ($existing) {
Write-Host " EXISTS: $($GroupDef.DisplayName)" -ForegroundColor DarkGray
return $existing
}
$group = New-MgGroup -DisplayName $GroupDef.DisplayName `
-Description $GroupDef.Description `
-MailEnabled:$false `
-MailNickname $GroupDef.MailNickname `
-SecurityEnabled:$true
Write-Host " CREATED: $($GroupDef.DisplayName)" -ForegroundColor Green
return $group
}
What does this function do?
- Get-MgGroup -Filter queries Entra ID to check if a group with the same display name already exists.
- If the group exists, the function prints an "EXISTS" message and returns early. No duplicate is created.
- If the group does not exist, New-MgGroup creates a mail-disabled, security-enabled group. This is the standard pattern for department-based access groups.
- Below the New-IdempotentGroup function, add the New-IdempotentAppRegistration function:
function New-IdempotentAppRegistration {
$existing = Get-MgApplication -Filter "displayName eq '$AppName'" -ErrorAction SilentlyContinue
if ($existing) {
Write-Host " EXISTS: $AppName (AppId: $($existing.AppId))" -ForegroundColor DarkGray
return $existing
}
$appRoles = $AppRoleDefinitions | ForEach-Object {
@{
AllowedMemberTypes = @("User")
DisplayName = $_.DisplayName
Description = $_.Description
Value = $_.Value
Id = $_.Id
IsEnabled = $true
}
}
$app = New-MgApplication -DisplayName $AppName -AppRoles $appRoles -SignInAudience "AzureADMyOrg"
Write-Host " CREATED: $AppName (AppId: $($app.AppId))" -ForegroundColor Green
# Create service principal for the app
$sp = New-MgServicePrincipal -AppId $app.AppId
Write-Host " CREATED: Service Principal for $AppName" -ForegroundColor Green
return $app
}
What does this function do?
- It follows the same idempotency pattern: check first, create only if needed.
- New-MgApplication registers the app in Entra ID with three app roles (Submitter, Approver, Admin). The SignInAudience is set to your own tenant only.
- New-MgServicePrincipal creates the service principal that makes the app assignable to users. Without this, you cannot assign app roles to anyone.
App roles vs groups
App roles are specific to an application and defined in the app registration. They move with the application. Groups are not specific to an app but to the Entra ID tenant.
Your toolkit uses both: groups for department membership (who belongs where) and app roles for application-level permissions (what they can do inside the expense portal).
- Below the functions, add the execution section that ties everything together:
# --- Execution ---
Write-Host "`n=== IAM Environment Setup ===" -ForegroundColor White
Write-Host "`n[1/3] Creating Security Groups..." -ForegroundColor Cyan
foreach ($groupDef in $GroupDefinitions) {
New-IdempotentGroup -GroupDef $groupDef | Out-Null
}
Write-Host "`n[2/3] Creating App Registration with Roles..." -ForegroundColor Cyan
New-IdempotentAppRegistration | Out-Null
Write-Host "`n=== Setup Complete ===" -ForegroundColor White
Write-Host "Run Get-MgGroup -Filter `"startswith(displayName,'SG-')`" to verify groups." -ForegroundColor DarkGray
Write-Host "Run Get-MgApplication -Filter `"displayName eq 'Contoso Expense Portal'`" to verify app.`n" -ForegroundColor DarkGray
What does the execution section do?
- It loops through each group definition and calls the idempotent creation function. The | Out-Null suppresses the return object from cluttering the console.
- It then creates the app registration with all three roles attached.
- The final messages give you verification commands to run afterwards.
- Save Setup-IAMEnvironment.ps1.
Execute and verify the setup
- Run the setup script by executing this command in your PowerShell terminal:
.\Setup-IAMEnvironment.ps1
You should see green "CREATED" messages for each of the three security groups, the app registration, and its service principal.
Seeing a permissions error?
- Make sure you connected with the full set of scopes in Step 1. Run Get-MgContext and confirm Application.ReadWrite.All is listed in Scopes.
- If scopes are missing, disconnect with Disconnect-MgGraph and reconnect using the full scopes command from Step 1.
- Confirm your account has Global Administrator or the necessary admin roles in your Entra ID tenant.
Why is my setup script failing with a permissions error?
- Verify the groups exist by running this command:
Get-MgGroup -Filter "startswith(displayName,'SG-')" | Select-Object DisplayName, Id
You should see three groups listed: SG-Engineering, SG-Finance, and SG-HR.
- Verify the app registration exists by running:
Get-MgApplication -Filter "displayName eq 'Contoso Expense Portal'" | Select-Object DisplayName, AppId
You should see the Contoso Expense Portal listed with its unique AppId.
- Run the script a second time to confirm idempotency:
.\Setup-IAMEnvironment.ps1
This time you should see gray "EXISTS" messages instead of green "CREATED" messages. No duplicates are created.
Assign built-in roles to groups
Assigning built-in directory roles to groups implements least-privilege delegation. Instead of granting admin rights to individual users, you grant them to a group. Anyone added to that group automatically inherits the role.
Why assign roles to groups instead of users?
When an HR team member joins, your JML engine just adds them to SG-HR. They automatically inherit User Administrator capabilities. When they leave, removing them from the group revokes the role instantly.
This is the group-based RBAC pattern that scales. Managing individual role assignments across hundreds of users leads to drift and audit nightmares.
- In Setup-IAMEnvironment.ps1, add the Set-RoleAssignmentToGroup function below the New-IdempotentAppRegistration function (before the # --- Execution --- comment):
function Set-RoleAssignmentToGroup {
param(
[string]$GroupDisplayName,
[string]$RoleDefinitionId
)
$group = Get-MgGroup -Filter "displayName eq '$GroupDisplayName'"
if (-not $group) {
Write-Host " ERROR: Group '$GroupDisplayName' not found" -ForegroundColor Red
return
}
# Check if assignment already exists
$existing = Get-MgRoleManagementDirectoryRoleAssignment -Filter "principalId eq '$($group.Id)' and roleDefinitionId eq '$RoleDefinitionId'" -ErrorAction SilentlyContinue
if ($existing) {
Write-Host " EXISTS: Role assignment for $GroupDisplayName" -ForegroundColor DarkGray
return
}
$params = @{
PrincipalId = $group.Id
RoleDefinitionId = $RoleDefinitionId
DirectoryScopeId = "/"
}
New-MgRoleManagementDirectoryRoleAssignment -BodyParameter $params
Write-Host " ASSIGNED: Role to $GroupDisplayName" -ForegroundColor Green
}
What does this function do?
- It looks up the group by display name and retrieves its Id.
- It checks whether the role assignment already exists (idempotency again).
- New-MgRoleManagementDirectoryRoleAssignment creates the actual role assignment. The DirectoryScopeId of "/" means the assignment applies to the entire tenant.
- In the execution section, find the line that says Write-Host "`n=== Setup Complete ===" -ForegroundColor White.
- Add the following lines directly above it:
Write-Host "`n[3/3] Assigning Built-in Roles to Groups..." -ForegroundColor Cyan
Set-RoleAssignmentToGroup -GroupDisplayName "SG-HR" -RoleDefinitionId $UserAdminRoleId
Set-RoleAssignmentToGroup -GroupDisplayName "SG-Engineering" -RoleDefinitionId $GroupsAdminRoleId
What are these role assignments?
- SG-HR receives the User Administrator built-in role. This lets HR staff manage user accounts (create, disable, reset passwords) without needing Global Admin.
- SG-Engineering receives the Groups Administrator built-in role. This lets engineering leads manage group memberships without broader privileges.
- Save Setup-IAMEnvironment.ps1 and run the script again:
.\Setup-IAMEnvironment.ps1
You should see "EXISTS" for the groups and app registration (already created), followed by green "ASSIGNED" messages for the two role assignments.
Seeing an error on role assignment?
- If you see a "Forbidden" or "Insufficient privileges" error, your account may not have permission to assign directory roles. You need Global Administrator or Privileged Role Administrator.
- If you see "Group not found", make sure the group names in your execution section match exactly: SG-HR and SG-Engineering. Case and hyphens matter.
Why is my role assignment failing?
✔️ Awesome, I've got everything!
Great. Double check you have saved Setup-IAMEnvironment.ps1 with all three functions and the complete execution section.
ⓧ I'd like to double check the full code
#Requires -Modules Microsoft.Graph.Groups, Microsoft.Graph.Applications, Microsoft.Graph.Identity.DirectoryManagement
<#
.SYNOPSIS
Creates the RBAC structure for the IAM automation lab.
Idempotent: safe to run multiple times.
#>
# --- Configuration ---
$GroupDefinitions = @(
@{ DisplayName = "SG-Engineering"; Description = "Engineering department members"; MailNickname = "sg-engineering" }
@{ DisplayName = "SG-Finance"; Description = "Finance department members"; MailNickname = "sg-finance" }
@{ DisplayName = "SG-HR"; Description = "HR department members"; MailNickname = "sg-hr" }
)
$AppName = "Contoso Expense Portal"
$AppRoleDefinitions = @(
@{ DisplayName = "Submitter"; Value = "Expense.Submit"; Description = "Can submit expense reports"; Id = [Guid]::NewGuid().ToString() }
@{ DisplayName = "Approver"; Value = "Expense.Approve"; Description = "Can approve expense reports"; Id = [Guid]::NewGuid().ToString() }
@{ DisplayName = "Admin"; Value = "Expense.Admin"; Description = "Full administration of expense system"; Id = [Guid]::NewGuid().ToString() }
)
# Built-in role template IDs
$UserAdminRoleId = "fe930be7-5e62-47db-91af-98c3a49a38b1"
$GroupsAdminRoleId = "fdd7a751-b60b-444a-984c-02652fe8fa1c"
# --- Functions ---
function New-IdempotentGroup {
param([hashtable]$GroupDef)
$existing = Get-MgGroup -Filter "displayName eq '$($GroupDef.DisplayName)'" -ErrorAction SilentlyContinue
if ($existing) {
Write-Host " EXISTS: $($GroupDef.DisplayName)" -ForegroundColor DarkGray
return $existing
}
$group = New-MgGroup -DisplayName $GroupDef.DisplayName `
-Description $GroupDef.Description `
-MailEnabled:$false `
-MailNickname $GroupDef.MailNickname `
-SecurityEnabled:$true
Write-Host " CREATED: $($GroupDef.DisplayName)" -ForegroundColor Green
return $group
}
function New-IdempotentAppRegistration {
$existing = Get-MgApplication -Filter "displayName eq '$AppName'" -ErrorAction SilentlyContinue
if ($existing) {
Write-Host " EXISTS: $AppName (AppId: $($existing.AppId))" -ForegroundColor DarkGray
return $existing
}
$appRoles = $AppRoleDefinitions | ForEach-Object {
@{
AllowedMemberTypes = @("User")
DisplayName = $_.DisplayName
Description = $_.Description
Value = $_.Value
Id = $_.Id
IsEnabled = $true
}
}
$app = New-MgApplication -DisplayName $AppName -AppRoles $appRoles -SignInAudience "AzureADMyOrg"
Write-Host " CREATED: $AppName (AppId: $($app.AppId))" -ForegroundColor Green
# Create service principal for the app
$sp = New-MgServicePrincipal -AppId $app.AppId
Write-Host " CREATED: Service Principal for $AppName" -ForegroundColor Green
return $app
}
function Set-RoleAssignmentToGroup {
param(
[string]$GroupDisplayName,
[string]$RoleDefinitionId
)
$group = Get-MgGroup -Filter "displayName eq '$GroupDisplayName'"
if (-not $group) {
Write-Host " ERROR: Group '$GroupDisplayName' not found" -ForegroundColor Red
return
}
# Check if assignment already exists
$existing = Get-MgRoleManagementDirectoryRoleAssignment -Filter "principalId eq '$($group.Id)' and roleDefinitionId eq '$RoleDefinitionId'" -ErrorAction SilentlyContinue
if ($existing) {
Write-Host " EXISTS: Role assignment for $GroupDisplayName" -ForegroundColor DarkGray
return
}
$params = @{
PrincipalId = $group.Id
RoleDefinitionId = $RoleDefinitionId
DirectoryScopeId = "/"
}
New-MgRoleManagementDirectoryRoleAssignment -BodyParameter $params
Write-Host " ASSIGNED: Role to $GroupDisplayName" -ForegroundColor Green
}
# --- Execution ---
Write-Host "`n=== IAM Environment Setup ===" -ForegroundColor White
Write-Host "`n[1/3] Creating Security Groups..." -ForegroundColor Cyan
foreach ($groupDef in $GroupDefinitions) {
New-IdempotentGroup -GroupDef $groupDef | Out-Null
}
Write-Host "`n[2/3] Creating App Registration with Roles..." -ForegroundColor Cyan
New-IdempotentAppRegistration | Out-Null
Write-Host "`n[3/3] Assigning Built-in Roles to Groups..." -ForegroundColor Cyan
Set-RoleAssignmentToGroup -GroupDisplayName "SG-HR" -RoleDefinitionId $UserAdminRoleId
Set-RoleAssignmentToGroup -GroupDisplayName "SG-Engineering" -RoleDefinitionId $GroupsAdminRoleId
Write-Host "`n=== Setup Complete ===" -ForegroundColor White
Write-Host "Run Get-MgGroup -Filter `"startswith(displayName,'SG-')`" to verify groups." -ForegroundColor DarkGray
Write-Host "Run Get-MgApplication -Filter `"displayName eq 'Contoso Expense Portal'`" to verify app.`n" -ForegroundColor DarkGray
Your entire RBAC structure now exists as code. If you ever need to rebuild this environment from scratch, a single script execution gets you back to a known state. Next up, you will create the HR data feed and build the JML engine that uses this infrastructure to provision users automatically.
Create the HR Data Feed and JML Engine
Your RBAC structure is live in Entra ID. Groups, app roles, and built-in role assignments are all in place, created entirely through code.
Now you need something to drive provisioning actions against that structure. In enterprise environments, identity changes flow from an HR system (like Workday or BambooHR) as a data feed. Your engine will read that feed and execute the correct provisioning workflow for each record.
In this step, get ready to:
- Create a simulated HR data feed as a CSV file with Joiner, Mover, and Leaver records.
- Write a JML processing engine that dispatches each record to the correct provisioning workflow.
- Execute the engine and produce a structured provisioning log.
Build the HR data feed
Every identity lifecycle system needs an authoritative source of truth. In production, that source is the HR system. Your CSV file simulates that feed with three action types: Joiner (new hire), Mover (department transfer), and Leaver (offboarding).
- Create a file called hr-feed.csv in the same folder as your Setup-IAMEnvironment.ps1 script.
- Paste the following data into the file:
Action,DisplayName,Department,AppRole
Joiner,Taylor Morgan,Engineering,Submitter
Joiner,Casey Rivera,Finance,Submitter
Joiner,Jordan Patel,HR,Approver
Mover,Casey Rivera,Engineering,Submitter
Leaver,Jordan Patel,,
What does this data represent?
- Three Joiners create brand new users and assign them to their department group with an app role.
- The Mover record transfers Casey Rivera from Finance to Engineering. The engine must remove them from one group and add them to another.
- The Leaver record offboards Jordan Patel. The engine disables the account, strips all group memberships, and revokes app roles.
- Notice the Leaver row has empty Department and AppRole columns. A departing employee has no target state.
- Save hr-feed.csv.
- Confirm the file exists by running:
Get-Content .\hr-feed.csv
You should see all five rows of data printed to the terminal, including the header row.
Not seeing your data?
- Make sure you saved the file in the same directory where your PowerShell session is running. Run Get-Location to check your current path.
- If you saved to a different folder, use cd to navigate to the folder containing hr-feed.csv.
help me find my hr-feed.csv file
Write the JML processing engine
The JML engine is the core of your automation toolkit. It reads the CSV, identifies the action type for each record, and dispatches to the correct function. This is the same pattern used in production IAM platforms.
- Create a new file called Invoke-JMLProcessor.ps1 in the same folder as your other scripts.
- Start by adding the script parameters and configuration block:
#Requires -Modules Microsoft.Graph.Users, Microsoft.Graph.Groups, Microsoft.Graph.Applications
<#
.SYNOPSIS
Processes an HR data feed CSV and executes Joiner/Mover/Leaver actions.
.PARAMETER HRFeedPath
Path to the HR feed CSV file.
.PARAMETER LogPath
Path for the provisioning log output. Defaults to provisioning-log.csv.
#>
param(
[Parameter(Mandatory = $true)]
[string]$HRFeedPath,
[string]$LogPath = "provisioning-log.csv"
)
# --- Configuration ---
$Domain = (Get-MgOrganization).VerifiedDomains | Where-Object { $_.IsDefault -eq $true } | Select-Object -ExpandProperty Name
$DepartmentGroupMap = @{
"Engineering" = "SG-Engineering"
"Finance" = "SG-Finance"
"HR" = "SG-HR"
}
$BatchId = Get-Date -Format "yyyyMMdd-HHmmss"
$ProvisioningLog = @()
What does this code do?
- The param() block declares two parameters: $HRFeedPath (required) and $LogPath (defaults to provisioning-log.csv).
- $Domain dynamically fetches your tenant's default domain so the script works in any Entra ID tenant.
- $DepartmentGroupMap maps department names from the HR feed to the security groups you created in Step 2.
- $BatchId timestamps each run so the provisioning log can track which actions belong to which execution.
- Below the configuration block, add the helper functions:
# --- Helper Functions ---
function Write-Log {
param(
[string]$Action,
[string]$ResourceType,
[string]$ResourceId,
[string]$Details,
[bool]$Reversible = $true
)
$script:ProvisioningLog += [PSCustomObject]@{
BatchId = $BatchId
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Action = $Action
ResourceType = $ResourceType
ResourceId = $ResourceId
Details = $Details
Reversible = $Reversible
}
}
function Get-GroupId {
param([string]$GroupName)
$group = Get-MgGroup -Filter "displayName eq '$GroupName'"
return $group.Id
}
function Get-AppRoleInfo {
param([string]$RoleName)
$sp = Get-MgServicePrincipal -Filter "displayName eq 'Contoso Expense Portal'"
$role = $sp.AppRoles | Where-Object { $_.DisplayName -eq $RoleName }
return @{ ServicePrincipalId = $sp.Id; RoleId = $role.Id }
}
What do these helpers do?
- Write-Log appends a structured record to the provisioning log array. Using $script:ProvisioningLog ensures the function modifies the script-level variable, not a local copy.
- Get-GroupId resolves a group display name to its Object ID via Microsoft Graph.
- Get-AppRoleInfo looks up the Contoso Expense Portal service principal and returns the role ID for a given role name.
- Below the helpers, add the Invoke-Joiner function that handles new hire provisioning:
# --- JML Functions ---
function Invoke-Joiner {
param([PSCustomObject]$Record)
$mailNickname = ($Record.DisplayName -replace '\s', '.').ToLower()
$upn = "$mailNickname@$Domain"
Write-Host " [JOINER] $($Record.DisplayName) -> $($Record.Department)" -ForegroundColor Green
# Create user
$password = @{
Password = "Temp!" + (Get-Random -Minimum 10000 -Maximum 99999)
ForceChangePasswordNextSignIn = $true
}
$user = New-MgUser -DisplayName $Record.DisplayName `
-UserPrincipalName $upn `
-MailNickname $mailNickname `
-Department $Record.Department `
-AccountEnabled:$true `
-PasswordProfile $password
Write-Log -Action "CreateUser" -ResourceType "User" -ResourceId $user.Id -Details $upn
# Add to department group
$groupName = $DepartmentGroupMap[$Record.Department]
$groupId = Get-GroupId -GroupName $groupName
New-MgGroupMember -GroupId $groupId -DirectoryObjectId $user.Id
Write-Log -Action "AddGroupMember" -ResourceType "Group" -ResourceId $groupId -Details "User:$($user.Id)"
# Assign app role
if ($Record.AppRole) {
$roleInfo = Get-AppRoleInfo -RoleName $Record.AppRole
$params = @{
PrincipalId = $user.Id
ResourceId = $roleInfo.ServicePrincipalId
AppRoleId = $roleInfo.RoleId
}
$assignment = New-MgUserAppRoleAssignment -UserId $user.Id -BodyParameter $params
Write-Log -Action "AssignAppRole" -ResourceType "AppRoleAssignment" -ResourceId $assignment.Id -Details "Role:$($Record.AppRole)"
}
Write-Host " Created: $upn | Group: $groupName | Role: $($Record.AppRole)" -ForegroundColor DarkGreen
}
What does Invoke-Joiner do?
This function handles the full onboarding workflow for a new hire:
- Generates a UserPrincipalName from the display name (e.g., Taylor Morgan becomes taylor.morgan@yourdomain.com).
- Creates the user in Entra ID with a temporary password that must be changed at first sign-in.
- Adds the user to their department security group (e.g., Engineering maps to SG-Engineering).
- Assigns the specified app role on the Contoso Expense Portal service principal.
- Logs every action for audit purposes.
- Below Invoke-Joiner, add the Invoke-Mover function that handles department transfers:
function Invoke-Mover {
param([PSCustomObject]$Record)
$mailNickname = ($Record.DisplayName -replace '\s', '.').ToLower()
$upn = "$mailNickname@$Domain"
Write-Host " [MOVER] $($Record.DisplayName) -> $($Record.Department)" -ForegroundColor Yellow
$user = Get-MgUser -Filter "userPrincipalName eq '$upn'"
if (-not $user) {
Write-Host " ERROR: User $upn not found. Skipping." -ForegroundColor Red
return
}
# Remove from all department groups
foreach ($groupName in $DepartmentGroupMap.Values) {
$groupId = Get-GroupId -GroupName $groupName
$members = Get-MgGroupMember -GroupId $groupId -All
if ($members.Id -contains $user.Id) {
Remove-MgGroupMemberByRef -GroupId $groupId -DirectoryObjectId $user.Id
Write-Log -Action "RemoveGroupMember" -ResourceType "Group" -ResourceId $groupId -Details "User:$($user.Id)"
Write-Host " Removed from: $groupName" -ForegroundColor DarkYellow
}
}
# Add to new department group
$newGroupName = $DepartmentGroupMap[$Record.Department]
$newGroupId = Get-GroupId -GroupName $newGroupName
New-MgGroupMember -GroupId $newGroupId -DirectoryObjectId $user.Id
Write-Log -Action "AddGroupMember" -ResourceType "Group" -ResourceId $newGroupId -Details "User:$($user.Id)"
# Update department property
Update-MgUser -UserId $user.Id -Department $Record.Department
Write-Log -Action "UpdateUser" -ResourceType "User" -ResourceId $user.Id -Details "Department:$($Record.Department)"
Write-Host " Moved to: $newGroupName" -ForegroundColor DarkYellow
}
What does Invoke-Mover do?
Department transfers are one of the biggest sources of access drift in enterprise IAM. This function prevents drift by:
- Removing the user from ALL department groups first. This ensures no residual access from the old department lingers.
- Adding the user to only the new department group.
- Updating the user's Department property so the drift detector can use it as the source of truth later.
- Below Invoke-Mover, add the Invoke-Leaver function that handles offboarding:
function Invoke-Leaver {
param([PSCustomObject]$Record)
$mailNickname = ($Record.DisplayName -replace '\s', '.').ToLower()
$upn = "$mailNickname@$Domain"
Write-Host " [LEAVER] $($Record.DisplayName)" -ForegroundColor Red
$user = Get-MgUser -Filter "userPrincipalName eq '$upn'"
if (-not $user) {
Write-Host " ERROR: User $upn not found. Skipping." -ForegroundColor Red
return
}
# Disable account
Update-MgUser -UserId $user.Id -AccountEnabled:$false
Write-Log -Action "DisableUser" -ResourceType "User" -ResourceId $user.Id -Details $upn -Reversible $true
# Remove all group memberships
$memberships = Get-MgUserMemberOf -UserId $user.Id -All
foreach ($membership in $memberships) {
$type = $membership.AdditionalProperties.'@odata.type'
if ($type -eq '#microsoft.graph.group') {
Remove-MgGroupMemberByRef -GroupId $membership.Id -DirectoryObjectId $user.Id
Write-Log -Action "RemoveGroupMember" -ResourceType "Group" -ResourceId $membership.Id -Details "User:$($user.Id)"
}
}
# Revoke all app role assignments
$assignments = Get-MgUserAppRoleAssignment -UserId $user.Id -All
foreach ($assignment in $assignments) {
Remove-MgUserAppRoleAssignment -UserId $user.Id -AppRoleAssignmentId $assignment.Id
Write-Log -Action "RemoveAppRole" -ResourceType "AppRoleAssignment" -ResourceId $assignment.Id -Details "User:$($user.Id)"
}
Write-Host " Disabled and access revoked" -ForegroundColor DarkRed
}
What does Invoke-Leaver do?
Offboarding is the highest-risk workflow in identity management. An incomplete offboard leaves active credentials in a departed employee's hands. This function enforces complete removal:
- Disables the account immediately so the user cannot sign in.
- Iterates through ALL group memberships (not just department groups) and removes each one.
- Revokes every app role assignment so the user has zero application access.
- Finally, add the main execution block at the bottom of the file:
# --- Main Execution ---
Write-Host "`n=== JML Processor - Batch $BatchId ===" -ForegroundColor White
if (-not (Test-Path $HRFeedPath)) {
Write-Host "ERROR: HR feed not found at $HRFeedPath" -ForegroundColor Red
exit 1
}
$records = Import-Csv -Path $HRFeedPath
Write-Host "Processing $($records.Count) records...`n" -ForegroundColor Cyan
foreach ($record in $records) {
switch ($record.Action) {
"Joiner" { Invoke-Joiner -Record $record }
"Mover" { Invoke-Mover -Record $record }
"Leaver" { Invoke-Leaver -Record $record }
default { Write-Host " [UNKNOWN] Skipping: $($record.Action)" -ForegroundColor Gray }
}
}
# Export provisioning log
$ProvisioningLog | Export-Csv -Path $LogPath -NoTypeInformation
Write-Host "`n=== Batch Complete ===" -ForegroundColor White
Write-Host "Actions logged: $($ProvisioningLog.Count)" -ForegroundColor Cyan
Write-Host "Log saved to: $LogPath`n" -ForegroundColor DarkGray
What does the main execution block do?
- Validates that the HR feed file exists before processing. If missing, it exits with error code 1.
- Uses Import-Csv to parse the CSV into objects with properties matching the column headers.
- A switch statement dispatches each record to the correct function based on the Action column.
- After processing all records, exports the accumulated provisioning log to CSV for audit purposes.
- Save Invoke-JMLProcessor.ps1.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete Invoke-JMLProcessor.ps1 for comparison:
#Requires -Modules Microsoft.Graph.Users, Microsoft.Graph.Groups, Microsoft.Graph.Applications
<#
.SYNOPSIS
Processes an HR data feed CSV and executes Joiner/Mover/Leaver actions.
.PARAMETER HRFeedPath
Path to the HR feed CSV file.
.PARAMETER LogPath
Path for the provisioning log output. Defaults to provisioning-log.csv.
#>
param(
[Parameter(Mandatory = $true)]
[string]$HRFeedPath,
[string]$LogPath = "provisioning-log.csv"
)
# --- Configuration ---
$Domain = (Get-MgOrganization).VerifiedDomains | Where-Object { $_.IsDefault -eq $true } | Select-Object -ExpandProperty Name
$DepartmentGroupMap = @{
"Engineering" = "SG-Engineering"
"Finance" = "SG-Finance"
"HR" = "SG-HR"
}
$BatchId = Get-Date -Format "yyyyMMdd-HHmmss"
$ProvisioningLog = @()
# --- Helper Functions ---
function Write-Log {
param(
[string]$Action,
[string]$ResourceType,
[string]$ResourceId,
[string]$Details,
[bool]$Reversible = $true
)
$script:ProvisioningLog += [PSCustomObject]@{
BatchId = $BatchId
Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Action = $Action
ResourceType = $ResourceType
ResourceId = $ResourceId
Details = $Details
Reversible = $Reversible
}
}
function Get-GroupId {
param([string]$GroupName)
$group = Get-MgGroup -Filter "displayName eq '$GroupName'"
return $group.Id
}
function Get-AppRoleInfo {
param([string]$RoleName)
$sp = Get-MgServicePrincipal -Filter "displayName eq 'Contoso Expense Portal'"
$role = $sp.AppRoles | Where-Object { $_.DisplayName -eq $RoleName }
return @{ ServicePrincipalId = $sp.Id; RoleId = $role.Id }
}
# --- JML Functions ---
function Invoke-Joiner {
param([PSCustomObject]$Record)
$mailNickname = ($Record.DisplayName -replace '\s', '.').ToLower()
$upn = "$mailNickname@$Domain"
Write-Host " [JOINER] $($Record.DisplayName) -> $($Record.Department)" -ForegroundColor Green
# Create user
$password = @{
Password = "Temp!" + (Get-Random -Minimum 10000 -Maximum 99999)
ForceChangePasswordNextSignIn = $true
}
$user = New-MgUser -DisplayName $Record.DisplayName `
-UserPrincipalName $upn `
-MailNickname $mailNickname `
-Department $Record.Department `
-AccountEnabled:$true `
-PasswordProfile $password
Write-Log -Action "CreateUser" -ResourceType "User" -ResourceId $user.Id -Details $upn
# Add to department group
$groupName = $DepartmentGroupMap[$Record.Department]
$groupId = Get-GroupId -GroupName $groupName
New-MgGroupMember -GroupId $groupId -DirectoryObjectId $user.Id
Write-Log -Action "AddGroupMember" -ResourceType "Group" -ResourceId $groupId -Details "User:$($user.Id)"
# Assign app role
if ($Record.AppRole) {
$roleInfo = Get-AppRoleInfo -RoleName $Record.AppRole
$params = @{
PrincipalId = $user.Id
ResourceId = $roleInfo.ServicePrincipalId
AppRoleId = $roleInfo.RoleId
}
$assignment = New-MgUserAppRoleAssignment -UserId $user.Id -BodyParameter $params
Write-Log -Action "AssignAppRole" -ResourceType "AppRoleAssignment" -ResourceId $assignment.Id -Details "Role:$($Record.AppRole)"
}
Write-Host " Created: $upn | Group: $groupName | Role: $($Record.AppRole)" -ForegroundColor DarkGreen
}
function Invoke-Mover {
param([PSCustomObject]$Record)
$mailNickname = ($Record.DisplayName -replace '\s', '.').ToLower()
$upn = "$mailNickname@$Domain"
Write-Host " [MOVER] $($Record.DisplayName) -> $($Record.Department)" -ForegroundColor Yellow
$user = Get-MgUser -Filter "userPrincipalName eq '$upn'"
if (-not $user) {
Write-Host " ERROR: User $upn not found. Skipping." -ForegroundColor Red
return
}
# Remove from all department groups
foreach ($groupName in $DepartmentGroupMap.Values) {
$groupId = Get-GroupId -GroupName $groupName
$members = Get-MgGroupMember -GroupId $groupId -All
if ($members.Id -contains $user.Id) {
Remove-MgGroupMemberByRef -GroupId $groupId -DirectoryObjectId $user.Id
Write-Log -Action "RemoveGroupMember" -ResourceType "Group" -ResourceId $groupId -Details "User:$($user.Id)"
Write-Host " Removed from: $groupName" -ForegroundColor DarkYellow
}
}
# Add to new department group
$newGroupName = $DepartmentGroupMap[$Record.Department]
$newGroupId = Get-GroupId -GroupName $newGroupName
New-MgGroupMember -GroupId $newGroupId -DirectoryObjectId $user.Id
Write-Log -Action "AddGroupMember" -ResourceType "Group" -ResourceId $newGroupId -Details "User:$($user.Id)"
# Update department property
Update-MgUser -UserId $user.Id -Department $Record.Department
Write-Log -Action "UpdateUser" -ResourceType "User" -ResourceId $user.Id -Details "Department:$($Record.Department)"
Write-Host " Moved to: $newGroupName" -ForegroundColor DarkYellow
}
function Invoke-Leaver {
param([PSCustomObject]$Record)
$mailNickname = ($Record.DisplayName -replace '\s', '.').ToLower()
$upn = "$mailNickname@$Domain"
Write-Host " [LEAVER] $($Record.DisplayName)" -ForegroundColor Red
$user = Get-MgUser -Filter "userPrincipalName eq '$upn'"
if (-not $user) {
Write-Host " ERROR: User $upn not found. Skipping." -ForegroundColor Red
return
}
# Disable account
Update-MgUser -UserId $user.Id -AccountEnabled:$false
Write-Log -Action "DisableUser" -ResourceType "User" -ResourceId $user.Id -Details $upn -Reversible $true
# Remove all group memberships
$memberships = Get-MgUserMemberOf -UserId $user.Id -All
foreach ($membership in $memberships) {
$type = $membership.AdditionalProperties.'@odata.type'
if ($type -eq '#microsoft.graph.group') {
Remove-MgGroupMemberByRef -GroupId $membership.Id -DirectoryObjectId $user.Id
Write-Log -Action "RemoveGroupMember" -ResourceType "Group" -ResourceId $membership.Id -Details "User:$($user.Id)"
}
}
# Revoke all app role assignments
$assignments = Get-MgUserAppRoleAssignment -UserId $user.Id -All
foreach ($assignment in $assignments) {
Remove-MgUserAppRoleAssignment -UserId $user.Id -AppRoleAssignmentId $assignment.Id
Write-Log -Action "RemoveAppRole" -ResourceType "AppRoleAssignment" -ResourceId $assignment.Id -Details "User:$($user.Id)"
}
Write-Host " Disabled and access revoked" -ForegroundColor DarkRed
}
# --- Main Execution ---
Write-Host "`n=== JML Processor - Batch $BatchId ===" -ForegroundColor White
if (-not (Test-Path $HRFeedPath)) {
Write-Host "ERROR: HR feed not found at $HRFeedPath" -ForegroundColor Red
exit 1
}
$records = Import-Csv -Path $HRFeedPath
Write-Host "Processing $($records.Count) records...`n" -ForegroundColor Cyan
foreach ($record in $records) {
switch ($record.Action) {
"Joiner" { Invoke-Joiner -Record $record }
"Mover" { Invoke-Mover -Record $record }
"Leaver" { Invoke-Leaver -Record $record }
default { Write-Host " [UNKNOWN] Skipping: $($record.Action)" -ForegroundColor Gray }
}
}
# Export provisioning log
$ProvisioningLog | Export-Csv -Path $LogPath -NoTypeInformation
Write-Host "`n=== Batch Complete ===" -ForegroundColor White
Write-Host "Actions logged: $($ProvisioningLog.Count)" -ForegroundColor Cyan
Write-Host "Log saved to: $LogPath`n" -ForegroundColor DarkGray
Seeing a syntax error when you save?
- Check that every opening brace { has a matching closing brace }. Missing braces on foreach or if blocks are the most common cause.
- Verify that backtick line-continuation characters (`) in New-MgUser are immediately before the line break with no trailing spaces.
- Make sure all string interpolation uses double quotes, not single quotes. Variables like $upn inside single quotes will not expand.
help me fix a syntax error in my Invoke-JMLProcessor.ps1 script
Execute the full batch
Your engine is ready. Running it against the HR feed will create users, assign them to groups and roles, process the department transfer, and offboard the leaver. All in a single batch execution.
- Run the JML processor against your HR feed by executing this command:
.\Invoke-JMLProcessor.ps1 -HRFeedPath .\hr-feed.csv
What should I see?
The script outputs a colour-coded log as it processes each record. You should see:
- Three green [JOINER] entries for Taylor Morgan, Casey Rivera, and Jordan Patel.
- One yellow [MOVER] entry for Casey Rivera showing removal from SG-Finance and addition to SG-Engineering.
- One red [LEAVER] entry for Jordan Patel showing the account was disabled and access revoked.
- A final summary showing the total actions logged and the log file path.
- Verify the provisioning log was created by running:
Import-Csv .\provisioning-log.csv | Format-Table -AutoSize
You should see a table with columns for BatchId, Timestamp, Action, ResourceType, ResourceId, Details, and Reversible. Each row represents one Graph API action the engine performed.
- Confirm the users exist in Entra ID by running:
Get-MgUser -Filter "department eq 'Engineering' or department eq 'Finance' or department eq 'HR'" | Select-Object DisplayName, Department, AccountEnabled | Format-Table
You should see Taylor Morgan (Engineering, enabled), Casey Rivera (Engineering, enabled), and Jordan Patel (HR, disabled).
Seeing permission errors?
- Run Get-MgContext and confirm your scopes include User.ReadWrite.All and Group.ReadWrite.All. If missing, disconnect and reconnect with the full scope list from Step 1.
- If you see an error about the user already existing, you may have run the script previously. Delete the test users first with Remove-MgUser and re-run.
- If the app role assignment fails, verify the Contoso Expense Portal service principal exists by running Get-MgServicePrincipal -Filter "displayName eq 'Contoso Expense Portal'".
help me fix permission errors when running my JML processor
Your JML engine just processed an entire batch of identity changes in seconds. Next, you will build a drift detection script that audits whether the actual state in Entra ID matches what the HR feed says it should be.
Build the RBAC Drift Detection Script
Your JML processor can now onboard, transfer, and offboard users automatically based on an HR feed. The next challenge is verifying that reality matches intent.
Access drift is the number one governance problem in enterprise IAM. Over time, users accumulate permissions they should not have, or lose access they need without anyone noticing. In this step, you will build a script that audits your entire Entra ID tenant and flags discrepancies between what the HR source says and what actually exists.
In this step, get ready to:
- Write the drift detection script with department-to-group validation.
- Cross-reference app role assignments against the HR feed.
- Generate a structured drift report CSV with severity levels.
Write the drift detection script scaffold
The drift detector compares each user's department property (the authoritative source set by the JML processor) against their actual group memberships. If a user's department says Engineering but they are not in SG-Engineering, that is drift.
- Create a new file called Find-RBACDrift.ps1 in the same folder as your other scripts.
- Add the script parameters, configuration maps, and the Add-Finding helper function by pasting this code:
#Requires -Modules Microsoft.Graph.Users, Microsoft.Graph.Groups, Microsoft.Graph.Applications
<#
.SYNOPSIS
Detects RBAC drift by comparing actual Entra ID state against expected state.
.PARAMETER ReportPath
Path for the drift report CSV output. Defaults to drift-report.csv.
.PARAMETER HRFeedPath
Optional path to HR feed CSV to cross-reference app role expectations.
#>
param(
[string]$ReportPath = "drift-report.csv",
[string]$HRFeedPath = ""
)
# --- Configuration ---
$DepartmentGroupMap = @{
"Engineering" = "SG-Engineering"
"Finance" = "SG-Finance"
"HR" = "SG-HR"
}
# Reverse map: group name -> department
$GroupDepartmentMap = @{}
foreach ($entry in $DepartmentGroupMap.GetEnumerator()) {
$GroupDepartmentMap[$entry.Value] = $entry.Key
}
$DriftFindings = @()
# --- Functions ---
function Add-Finding {
param(
[string]$UserPrincipalName,
[string]$Issue,
[string]$Expected,
[string]$Actual,
[string]$Severity
)
$script:DriftFindings += [PSCustomObject]@{
UserPrincipalName = $UserPrincipalName
Issue = $Issue
Expected = $Expected
Actual = $Actual
Severity = $Severity
}
}
What does this code do?
- $DepartmentGroupMap maps each department name to its expected security group. This is the same mapping your JML processor used when provisioning users.
- $GroupDepartmentMap is the reverse lookup. Given a group name, it returns the department that group belongs to.
- Add-Finding appends a structured finding to the $DriftFindings array. Each finding records the user, the issue type, what was expected, what was found, and the severity.
Add the group membership detection logic
The core detection loop loads all group memberships up front, then iterates over every enabled user to check whether their department property matches their actual group placement.
- In Find-RBACDrift.ps1, paste the following code below the Add-Finding function:
# --- Main Detection Logic ---
Write-Host "`n=== RBAC Drift Detection ===" -ForegroundColor White
# Get all groups and their members
Write-Host "`n[1/3] Loading group memberships..." -ForegroundColor Cyan
$groupMembers = @{}
foreach ($groupName in $DepartmentGroupMap.Values) {
$group = Get-MgGroup -Filter "displayName eq '$groupName'"
if ($group) {
$members = Get-MgGroupMember -GroupId $group.Id -All
$groupMembers[$groupName] = $members
}
}
# Get all users with department property
Write-Host "[2/3] Analyzing users..." -ForegroundColor Cyan
$allUsers = Get-MgUser -Filter "accountEnabled eq true" -Property "id,displayName,userPrincipalName,department" -All
foreach ($user in $allUsers) {
# Skip users without a department (likely admin accounts)
if ([string]::IsNullOrEmpty($user.Department)) {
continue
}
$expectedGroupName = $DepartmentGroupMap[$user.Department]
if (-not $expectedGroupName) {
# User has a department that is not in our map
Add-Finding -UserPrincipalName $user.UserPrincipalName `
-Issue "UnmappedDepartment" `
-Expected "Mapped department" `
-Actual $user.Department `
-Severity "Medium"
continue
}
# Check if user is in their expected group
$isInExpectedGroup = $false
if ($groupMembers[$expectedGroupName]) {
$isInExpectedGroup = $groupMembers[$expectedGroupName].Id -contains $user.Id
}
if (-not $isInExpectedGroup) {
Add-Finding -UserPrincipalName $user.UserPrincipalName `
-Issue "GroupDrift" `
-Expected $expectedGroupName `
-Actual "Not a member" `
-Severity "High"
}
# Check if user is in any group they should NOT be in
foreach ($entry in $DepartmentGroupMap.GetEnumerator()) {
if ($entry.Key -ne $user.Department) {
$wrongGroupName = $entry.Value
if ($groupMembers[$wrongGroupName] -and ($groupMembers[$wrongGroupName].Id -contains $user.Id)) {
Add-Finding -UserPrincipalName $user.UserPrincipalName `
-Issue "OverProvisioned" `
-Expected "Not in $wrongGroupName" `
-Actual "Member of $wrongGroupName" `
-Severity "High"
}
}
}
}
What does this detection logic do?
- First, it loads all members of each department security group into a hashtable for fast lookups.
- Then it queries every enabled user with a department property set. Users without a department (like your admin account) are skipped.
- For each user, it checks three things: is their department in the map (UnmappedDepartment), are they in their expected group (GroupDrift), and are they in any group they should not be in (OverProvisioned).
- GroupDrift and OverProvisioned are both flagged as High severity because they represent active access misalignment.
- Save Find-RBACDrift.ps1.
Seeing errors about missing modules?
Make sure you are still connected to Microsoft Graph. Run Get-MgContext to check. If it returns nothing, reconnect with Connect-MgGraph using the same scopes from Step 1.
help me reconnect to Microsoft Graph with the correct scopes
Cross-reference app role assignments
Group membership is only half the picture. Users also have app role assignments on the Contoso Expense Portal. This section reads the HR feed to determine what role each active user should have, then flags anyone who is missing their expected role.
- In Find-RBACDrift.ps1, paste the following code below the closing } of the foreach ($user in $allUsers) loop:
# Check app role assignments against HR feed if provided
Write-Host "[3/3] Checking app role assignments..." -ForegroundColor Cyan
if ($HRFeedPath -and (Test-Path $HRFeedPath)) {
$hrRecords = Import-Csv -Path $HRFeedPath | Where-Object { $_.Action -ne "Leaver" }
$sp = Get-MgServicePrincipal -Filter "displayName eq 'Contoso Expense Portal'" -ErrorAction SilentlyContinue
if ($sp) {
foreach ($record in $hrRecords) {
$mailNickname = ($record.DisplayName -replace '\s', '.').ToLower()
$domain = (Get-MgOrganization).VerifiedDomains | Where-Object { $_.IsDefault } | Select-Object -ExpandProperty Name
$upn = "$mailNickname@$domain"
$user = Get-MgUser -Filter "userPrincipalName eq '$upn'" -ErrorAction SilentlyContinue
if (-not $user) { continue }
$assignments = Get-MgUserAppRoleAssignment -UserId $user.Id -All
$appAssignment = $assignments | Where-Object { $_.ResourceId -eq $sp.Id }
$expectedRole = $sp.AppRoles | Where-Object { $_.DisplayName -eq $record.AppRole }
if ($expectedRole -and (-not $appAssignment -or $appAssignment.AppRoleId -ne $expectedRole.Id)) {
Add-Finding -UserPrincipalName $upn `
-Issue "UnderProvisioned" `
-Expected "AppRole: $($record.AppRole)" `
-Actual "Missing or incorrect role" `
-Severity "Medium"
}
}
}
}
What does this code do?
- It only runs when you pass the -HRFeedPath parameter. Without it, the script only checks group membership drift.
- It filters out Leaver records (those users should have no access at all).
- For each remaining HR record, it looks up the user's actual app role assignment on the Contoso Expense Portal service principal and compares it to what the HR feed says they should have.
- UnderProvisioned findings have Medium severity because the user is missing access they need, but is not a security risk.
- Save Find-RBACDrift.ps1.
Script not finding users?
The app role check builds each UPN from the display name (e.g. Taylor Morgan becomes taylor.morgan@yourdomain). If your JML processor created users with a different naming pattern, the filter will not match. Verify your test users exist by running Get-MgUser -All | Select DisplayName, UserPrincipalName.
help me check why my drift detector cannot find users created by the JML processor
Generate the drift report and run the script
The final section outputs findings to a CSV and displays a colour-coded summary in the terminal. When no drift exists, it confirms the environment is clean.
- In Find-RBACDrift.ps1, paste the following code at the bottom of the file (after the app role check block):
# --- Output Results ---
Write-Host "`n=== Drift Report ===" -ForegroundColor White
if ($DriftFindings.Count -eq 0) {
Write-Host "No drift detected. Environment is clean." -ForegroundColor Green
} else {
$DriftFindings | Export-Csv -Path $ReportPath -NoTypeInformation
$summary = $DriftFindings | Group-Object -Property Issue
foreach ($group in $summary) {
$color = switch ($group.Name) {
"GroupDrift" { "Red" }
"OverProvisioned" { "Red" }
"UnderProvisioned" { "Yellow" }
"UnmappedDepartment" { "Yellow" }
default { "White" }
}
Write-Host " $($group.Name): $($group.Count)" -ForegroundColor $color
}
Write-Host "`nTotal findings: $($DriftFindings.Count)" -ForegroundColor White
Write-Host "Report saved to: $ReportPath`n" -ForegroundColor DarkGray
}
What does this code do?
- If no drift is found, it prints a green confirmation message and does not create a CSV file.
- If findings exist, it exports them to drift-report.csv and groups them by issue type.
- High-severity issues (GroupDrift, OverProvisioned) display in red. Medium-severity issues (UnderProvisioned, UnmappedDepartment) display in yellow.
- Save Find-RBACDrift.ps1.
- Run the drift detector with your HR feed by running this command:
.\Find-RBACDrift.ps1 -HRFeedPath .\hr-feed.csv
You should see the three-phase output (Loading group memberships, Analyzing users, Checking app role assignments) followed by either a clean report or a list of findings.
What should I see?
If your JML processor ran correctly, you should see "No drift detected. Environment is clean." in green. This means every user's group membership and app role matches what the HR feed says.
If you see findings, review each one. Your admin account should be skipped (no department property). Any findings on your test users indicate the JML processor left the environment in an unexpected state.
Getting permission errors?
The Get-MgUser -Filter "accountEnabled eq true" call requires User.ReadWrite.All or User.Read.All scope. If you see a 403 error, your Graph session may have expired. Reconnect with the full set of scopes from Step 1.
help me fix permission errors when running Find-RBACDrift.ps1
✔️ Awesome, I've got everything!
Great. Double check you have saved Find-RBACDrift.ps1 and confirmed it runs without errors.
ⓧ I'd like to double check the full code
#Requires -Modules Microsoft.Graph.Users, Microsoft.Graph.Groups, Microsoft.Graph.Applications
<#
.SYNOPSIS
Detects RBAC drift by comparing actual Entra ID state against expected state.
.PARAMETER ReportPath
Path for the drift report CSV output. Defaults to drift-report.csv.
.PARAMETER HRFeedPath
Optional path to HR feed CSV to cross-reference app role expectations.
#>
param(
[string]$ReportPath = "drift-report.csv",
[string]$HRFeedPath = ""
)
# --- Configuration ---
$DepartmentGroupMap = @{
"Engineering" = "SG-Engineering"
"Finance" = "SG-Finance"
"HR" = "SG-HR"
}
# Reverse map: group name -> department
$GroupDepartmentMap = @{}
foreach ($entry in $DepartmentGroupMap.GetEnumerator()) {
$GroupDepartmentMap[$entry.Value] = $entry.Key
}
$DriftFindings = @()
# --- Functions ---
function Add-Finding {
param(
[string]$UserPrincipalName,
[string]$Issue,
[string]$Expected,
[string]$Actual,
[string]$Severity
)
$script:DriftFindings += [PSCustomObject]@{
UserPrincipalName = $UserPrincipalName
Issue = $Issue
Expected = $Expected
Actual = $Actual
Severity = $Severity
}
}
# --- Main Detection Logic ---
Write-Host "`n=== RBAC Drift Detection ===" -ForegroundColor White
# Get all groups and their members
Write-Host "`n[1/3] Loading group memberships..." -ForegroundColor Cyan
$groupMembers = @{}
foreach ($groupName in $DepartmentGroupMap.Values) {
$group = Get-MgGroup -Filter "displayName eq '$groupName'"
if ($group) {
$members = Get-MgGroupMember -GroupId $group.Id -All
$groupMembers[$groupName] = $members
}
}
# Get all users with department property
Write-Host "[2/3] Analyzing users..." -ForegroundColor Cyan
$allUsers = Get-MgUser -Filter "accountEnabled eq true" -Property "id,displayName,userPrincipalName,department" -All
foreach ($user in $allUsers) {
# Skip users without a department (likely admin accounts)
if ([string]::IsNullOrEmpty($user.Department)) {
continue
}
$expectedGroupName = $DepartmentGroupMap[$user.Department]
if (-not $expectedGroupName) {
# User has a department that is not in our map
Add-Finding -UserPrincipalName $user.UserPrincipalName `
-Issue "UnmappedDepartment" `
-Expected "Mapped department" `
-Actual $user.Department `
-Severity "Medium"
continue
}
# Check if user is in their expected group
$isInExpectedGroup = $false
if ($groupMembers[$expectedGroupName]) {
$isInExpectedGroup = $groupMembers[$expectedGroupName].Id -contains $user.Id
}
if (-not $isInExpectedGroup) {
Add-Finding -UserPrincipalName $user.UserPrincipalName `
-Issue "GroupDrift" `
-Expected $expectedGroupName `
-Actual "Not a member" `
-Severity "High"
}
# Check if user is in any group they should NOT be in
foreach ($entry in $DepartmentGroupMap.GetEnumerator()) {
if ($entry.Key -ne $user.Department) {
$wrongGroupName = $entry.Value
if ($groupMembers[$wrongGroupName] -and ($groupMembers[$wrongGroupName].Id -contains $user.Id)) {
Add-Finding -UserPrincipalName $user.UserPrincipalName `
-Issue "OverProvisioned" `
-Expected "Not in $wrongGroupName" `
-Actual "Member of $wrongGroupName" `
-Severity "High"
}
}
}
}
# Check app role assignments against HR feed if provided
Write-Host "[3/3] Checking app role assignments..." -ForegroundColor Cyan
if ($HRFeedPath -and (Test-Path $HRFeedPath)) {
$hrRecords = Import-Csv -Path $HRFeedPath | Where-Object { $_.Action -ne "Leaver" }
$sp = Get-MgServicePrincipal -Filter "displayName eq 'Contoso Expense Portal'" -ErrorAction SilentlyContinue
if ($sp) {
foreach ($record in $hrRecords) {
$mailNickname = ($record.DisplayName -replace '\s', '.').ToLower()
$domain = (Get-MgOrganization).VerifiedDomains | Where-Object { $_.IsDefault } | Select-Object -ExpandProperty Name
$upn = "$mailNickname@$domain"
$user = Get-MgUser -Filter "userPrincipalName eq '$upn'" -ErrorAction SilentlyContinue
if (-not $user) { continue }
$assignments = Get-MgUserAppRoleAssignment -UserId $user.Id -All
$appAssignment = $assignments | Where-Object { $_.ResourceId -eq $sp.Id }
$expectedRole = $sp.AppRoles | Where-Object { $_.DisplayName -eq $record.AppRole }
if ($expectedRole -and (-not $appAssignment -or $appAssignment.AppRoleId -ne $expectedRole.Id)) {
Add-Finding -UserPrincipalName $upn `
-Issue "UnderProvisioned" `
-Expected "AppRole: $($record.AppRole)" `
-Actual "Missing or incorrect role" `
-Severity "Medium"
}
}
}
}
# --- Output Results ---
Write-Host "`n=== Drift Report ===" -ForegroundColor White
if ($DriftFindings.Count -eq 0) {
Write-Host "No drift detected. Environment is clean." -ForegroundColor Green
} else {
$DriftFindings | Export-Csv -Path $ReportPath -NoTypeInformation
$summary = $DriftFindings | Group-Object -Property Issue
foreach ($group in $summary) {
$color = switch ($group.Name) {
"GroupDrift" { "Red" }
"OverProvisioned" { "Red" }
"UnderProvisioned" { "Yellow" }
"UnmappedDepartment" { "Yellow" }
default { "White" }
}
Write-Host " $($group.Name): $($group.Count)" -ForegroundColor $color
}
Write-Host "`nTotal findings: $($DriftFindings.Count)" -ForegroundColor White
Write-Host "Report saved to: $ReportPath`n" -ForegroundColor DarkGray
}
Your drift detection script is ready. Next up, you will intentionally introduce drift into your environment and confirm the detector catches it.
Introduce Drift and Validate Detection
Your drift detection script is built, but you have not yet proven it works against real discrepancies.
In production environments, RBAC drift accumulates silently. Someone manually adds a user to a group for a quick fix, an app role gets assigned outside the normal workflow, or a transfer is incomplete. In this step, you will deliberately introduce three types of drift, confirm your detector catches them, and then remediate to prove the full audit-to-fix cycle.
In this step, get ready to:
- Manually introduce three categories of RBAC drift using direct Graph commands.
- Run the drift detector and confirm it flags all three discrepancies.
- Remediate the drift and re-run to confirm a clean report.
Manually introduce drift
You will simulate three common drift scenarios that happen in real tenants. Each one represents a different failure mode that your detector must catch.
- Add Taylor Morgan to the wrong group by running these commands in your PowerShell terminal:
# Get the user and the wrong group
$taylor = Get-MgUser -Filter "displayName eq 'Taylor Morgan'"
$financeGroup = Get-MgGroup -Filter "displayName eq 'SG-Finance'"
# Add Taylor to SG-Finance (she belongs in SG-Engineering only)
New-MgGroupMember -GroupId $financeGroup.Id -DirectoryObjectId $taylor.Id
What kind of drift is this?
This simulates an OverProvisioned scenario. Taylor's department is Engineering, so she should only be in SG-Engineering. Being in SG-Finance gives her access she should not have.
- Assign the Admin app role to Taylor Morgan (she should only have Submitter) by running:
# Get the service principal and the Admin role
$sp = Get-MgServicePrincipal -Filter "displayName eq 'Contoso Expense Portal'"
$adminRole = $sp.AppRoles | Where-Object { $_.DisplayName -eq "Admin" }
# Assign Admin role to Taylor (she should only have Submitter)
$params = @{
PrincipalId = $taylor.Id
ResourceId = $sp.Id
AppRoleId = $adminRole.Id
}
New-MgUserAppRoleAssignment -UserId $taylor.Id -BodyParameter $params
What kind of drift is this?
This is privilege escalation drift. Taylor now has the Admin role in addition to her legitimate Submitter role. In a real environment, this could mean someone granted themselves elevated access without going through proper channels.
- Remove Casey Rivera from her correct group by running:
# Get Casey and her correct group
$casey = Get-MgUser -Filter "displayName eq 'Casey Rivera'"
$engGroup = Get-MgGroup -Filter "displayName eq 'SG-Engineering'"
# Remove Casey from SG-Engineering (she should be in it)
Remove-MgGroupMemberByRef -GroupId $engGroup.Id -DirectoryObjectId $casey.Id
What kind of drift is this?
This is a GroupDrift scenario. Casey's department is Engineering, but she is no longer a member of SG-Engineering. This simulates an incomplete transfer or accidental removal that leaves a user under-provisioned.
- Verify the drift is in place by checking SG-Finance membership:
Get-MgGroupMember -GroupId $financeGroup.Id | ForEach-Object { (Get-MgUser -UserId $_.Id).DisplayName }
You should see Taylor Morgan listed as a member of SG-Finance.
Getting a 'member already exists' error?
If any of the commands above fail, the user may already be in that state from a previous attempt. Run Get-MgGroupMember on the relevant group to confirm the current membership before proceeding.
help me troubleshoot errors when introducing drift to my Entra ID groups
Run the drift detector
With three distinct drift scenarios planted, your detector should flag each one. The HR feed provides the authoritative source for what app roles each user should have.
- Execute the drift detection script with the HR feed for app role cross-referencing by running:
.\Find-RBACDrift.ps1 -HRFeedPath .\hr-feed.csv
You should see output showing at least three findings with issue types like GroupDrift and OverProvisioned displayed in red.
- Review the drift report CSV by running:
Import-Csv .\drift-report.csv | Format-Table -AutoSize
You should see rows with columns UserPrincipalName, Issue, Expected, Actual, and Severity. Confirm that the report includes findings for Taylor Morgan (OverProvisioned in SG-Finance) and Casey Rivera (GroupDrift, not in SG-Engineering).
Why are severity levels important?
The script marks GroupDrift and OverProvisioned as High severity because they represent immediate access control failures. A user in the wrong group has access to resources they should not touch. In production, High severity findings trigger immediate remediation.
Drift detector showing zero findings?
Make sure you ran the drift introduction commands in the same PowerShell session where you are connected to Microsoft Graph. If your session expired, reconnect with Connect-MgGraph and re-run the drift introduction commands before testing again.
Also verify that your test users are still enabled. The script filters on accountEnabled eq true, so disabled users will not appear in the results.
help me figure out why my RBAC drift detector is not finding the discrepancies I introduced
Remediate and re-run
A drift detector is only useful if you can act on its findings. You will now fix all three discrepancies and prove the environment returns to a clean state.
- Remove Taylor Morgan from SG-Finance (fix the OverProvisioned finding) by running:
# Remove Taylor from the wrong group
$taylor = Get-MgUser -Filter "displayName eq 'Taylor Morgan'"
$financeGroup = Get-MgGroup -Filter "displayName eq 'SG-Finance'"
Remove-MgGroupMemberByRef -GroupId $financeGroup.Id -DirectoryObjectId $taylor.Id
- Revoke the incorrect Admin app role from Taylor by running:
# Find and remove the Admin role assignment
$sp = Get-MgServicePrincipal -Filter "displayName eq 'Contoso Expense Portal'"
$adminRole = $sp.AppRoles | Where-Object { $_.DisplayName -eq "Admin" }
$assignments = Get-MgUserAppRoleAssignment -UserId $taylor.Id -All
$wrongAssignment = $assignments | Where-Object { $_.AppRoleId -eq $adminRole.Id }
Remove-MgUserAppRoleAssignment -UserId $taylor.Id -AppRoleAssignmentId $wrongAssignment.Id
- Add Casey Rivera back to SG-Engineering (fix the GroupDrift finding) by running:
# Restore Casey to her correct group
$casey = Get-MgUser -Filter "displayName eq 'Casey Rivera'"
$engGroup = Get-MgGroup -Filter "displayName eq 'SG-Engineering'"
New-MgGroupMember -GroupId $engGroup.Id -DirectoryObjectId $casey.Id
Why manual remediation here?
You could also remediate by creating a corrective HR feed CSV with Mover records and running Invoke-JMLProcessor.ps1 against it. In production, most teams automate remediation by feeding drift findings back into the provisioning engine. For now, direct Graph commands let you see exactly what each fix does.
- Re-run the drift detector to confirm a clean environment by running:
.\Find-RBACDrift.ps1 -HRFeedPath .\hr-feed.csv
You should see the message No drift detected. Environment is clean. displayed in green. This confirms all three discrepancies have been resolved.
Still seeing findings after remediation?
Double-check that all three remediation commands completed without errors. If the app role removal failed, verify the assignment ID by running Get-MgUserAppRoleAssignment -UserId $taylor.Id -All and confirming only the Submitter role remains.
help me troubleshoot remaining drift findings after I attempted remediation
You have now completed the full governance cycle: detect, report, remediate, and verify. Your toolkit can identify access discrepancies and confirm when the environment is back in compliance. Next up, you will tackle the Secret Mission and add production-grade safety features to your provisioning engine.
Secret mission
Add Dry-Run Mode and Rollback Capability
Your JML processor works, but would you run it against a production tenant without a safety net? In this secret mission, add a -WhatIf dry-run mode that previews every change without touching Entra ID, and build a rollback script that can undo an entire batch of provisioning actions.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely within the Microsoft Entra ID Free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Test users in your Entra ID tenant (created by the JML processor).
- Security groups (SG-Engineering, SG-Finance, SG-HR).
- App registration (Contoso Expense Portal) with app roles and service principal.
- Built-in role assignments to groups (User Administrator, Groups Administrator).
- Local script files and CSV outputs.
Keep everything running
No action needed. Choose this if you plan to continue experimenting with the IAM automation lab.
- Your Entra ID Free tenant incurs no charges regardless of how many users, groups, or app registrations exist.
- The PowerShell scripts on your local machine are your portfolio artifacts. Keep them versioned in a Git repository.
- The Microsoft Graph PowerShell SDK remains installed on your machine. It does not consume resources when idle.
Pause - I'll come back to this later
Remove the test users to keep your tenant tidy while preserving the RBAC structure for future experimentation.
- Remove test users created by the JML processor by running:
# Get all test users created by the processor
$testUsers = Get-MgUser -Filter "startswith(mailNickname,'taylor.morgan') or startswith(mailNickname,'casey.rivera') or startswith(mailNickname,'jordan.patel')" -All
# Remove each test user
foreach ($user in $testUsers) {
Remove-MgUser -UserId $user.Id
Write-Host "Removed: $($user.UserPrincipalName)" -ForegroundColor Yellow
}
- Your security groups, app registration, and role assignments remain intact for when you return.
- Delete the generated CSV logs if you want to start fresh next time:
Remove-Item provisioning-log.csv, drift-report.csv -ErrorAction SilentlyContinue
Delete - I don't want to use this again
Remove all project resources from your tenant and local machine. Your scripts are the portfolio artifact, not the tenant state.
- Remove all test users by running:
# Remove test users
$testUsers = Get-MgUser -Filter "startswith(mailNickname,'taylor.morgan') or startswith(mailNickname,'casey.rivera') or startswith(mailNickname,'jordan.patel')" -All
foreach ($user in $testUsers) {
Remove-MgUser -UserId $user.Id
Write-Host "Removed user: $($user.UserPrincipalName)" -ForegroundColor Yellow
}
- Remove the built-in role assignments from groups by running:
# Remove role assignments from groups
$assignments = Get-MgRoleManagementDirectoryRoleAssignment -All
$sgGroups = Get-MgGroup -Filter "startswith(displayName,'SG-')" -All
foreach ($assignment in $assignments) {
if ($sgGroups.Id -contains $assignment.PrincipalId) {
Remove-MgRoleManagementDirectoryRoleAssignment -UnifiedRoleAssignmentId $assignment.Id
Write-Host "Removed role assignment: $($assignment.Id)" -ForegroundColor Yellow
}
}
- Remove the security groups by running:
# Remove security groups
$groups = Get-MgGroup -Filter "startswith(displayName,'SG-')" -All
foreach ($group in $groups) {
Remove-MgGroup -GroupId $group.Id
Write-Host "Removed group: $($group.DisplayName)" -ForegroundColor Yellow
}
- Remove the app registration by running:
# Remove the Contoso Expense Portal app registration
$app = Get-MgApplication -Filter "displayName eq 'Contoso Expense Portal'"
if ($app) {
Remove-MgApplication -ApplicationId $app.Id
Write-Host "Removed app registration: Contoso Expense Portal" -ForegroundColor Yellow
}
- Disconnect from Microsoft Graph:
Disconnect-MgGraph
- Delete local project files (scripts, CSVs, and logs) by running:
Remove-Item Setup-IAMEnvironment.ps1, Invoke-JMLProcessor.ps1, Find-RBACDrift.ps1, hr-feed.csv, provisioning-log.csv, drift-report.csv -ErrorAction SilentlyContinue
Nice Work!
Nice Work!
You just built a production-style IAM automation toolkit from scratch. This is what separates an IAM admin from an IAM engineer.
You've learned how to:
- Built an infrastructure-as-code setup script that creates a complete RBAC environment programmatically via Microsoft Graph.
- Engineered an HR-driven Joiner-Mover-Leaver provisioning engine that processes batch identity changes and generates structured governance reports.
- Created an RBAC drift detection system that audits user access against an authoritative source and flags over-provisioned or under-provisioned accounts.
- Added dry-run preview mode and rollback capability for safe, auditable identity operations.
Ready to quiz yourself?