The Inactive
User Flow
An automated Azure Function pipeline that scans every Entra ID account for 180+ days of inactivity, flags ghost accounts, exports a CSV to blob storage, and sends localised Teams notifications to their managers – deployed entirely via Bicep with zero portal clicks.
IT had been manually flagging 200–300 inactive users to HR every cycle – pulling exports, cross-referencing data, emailing spreadsheets. This project replaced that with a fully automated pipeline that queries every enabled account in Entra ID, classifies inactivity against a 180-day threshold, exports a dated CSV with a 7-day download link, and sends two types of Teams notification: an Adaptive Card summary to a shared channel and localised, personalised DMs to each affected manager with clear action items.
The pipeline is five modular PowerShell scripts running on a timer-triggered Azure Function, backed by seven Azure resources deployed via Bicep in a single command. A custom HRIS-to-Entra sync flags users on long-term leave so the pipeline can skip them automatically. Manager DMs use an ROPC OAuth flow through a Logic App so messages arrive from a real person, not a bot. Messages are automatically localised based on the user’s Entra ID country property. The first production run led to ~50 accounts being disabled – reclaiming licences, reducing attack surface, and cleaning downstream systems. Shipping it meant coordinating across five teams – HR, HRIS, medical operations, platform, and IT – and the pipeline now runs twice a year as part of HR’s salary and budget review cycles.
Every organisation accumulates ghost accounts. People leave, change roles, go on extended leave – and their Microsoft account stays enabled, holding a licence, accessible to anyone who guesses the password, quietly syncing to downstream systems. In a large enough tenant, hundreds of these can accumulate before anyone notices.
That’s exactly what was happening. IT had been manually flagging 200–300 inactive users to HR every cycle – pulling exports, cross-referencing sign-in data, emailing spreadsheets. HR would review the list, but there was no shared definition of “inactive,” no consistent threshold, and no way to tell whether someone was genuinely gone or just on long-term leave. It was slow, error-prone, and neither side enjoyed it.
The ask was straightforward: build something that finds these accounts automatically, tells the right people, and gives HR the data they need to act. Not a one-off audit – a repeatable pipeline that could run unattended and feed into HR’s existing review cycles.
Getting there meant working across multiple teams. HR owned the process and defined what “inactive” meant in practice. The HRIS team built a custom sync from the HR system into Entra ID – a boolean attribute flagging whether a user was on long-term leave (parental, sick, gardening leave) without exposing the reason. The medical operations team helped validate that clinician accounts had different patterns from office staff. The platform team approved the Azure infrastructure and deployment pipeline. I coordinated across all of them and built the technical solution.
Architecture – Seven Resources, One Deploy
The pipeline is a timer-triggered Azure Function that queries Graph, classifies users, exports a CSV, and sends two types of Teams notification – all deployed via Bicep with zero portal clicks. It runs twice a year as part of HR’s review cycles.
Authentication map
- Function → Graph API
- Managed Identity –
User.Read.All,AuditLog.Read.All - Function → Blob Storage
- Managed Identity –
Storage Blob Data Contributor+Delegator - Function → Key Vault
- Managed Identity –
Key Vault Secrets User - DM Logic App → Graph
- MSI for
Chat.Create, then ROPC forChatMessage.Send
Execution
- Trigger
- Timer trigger – manually triggered by HR in April + August
- Runtime
- Azure Functions v4, PowerShell 7.4
- Hosting
- Consumption plan (Y1/Dynamic) – pay per execution
- Cadence
- Twice yearly – before salary review (Apr) and budget cycle (Aug)
Modular PowerShell Pipeline on Azure Functions
The timer trigger imports five modules and calls them in order. Each module does one thing. The orchestrator reads like a recipe: fetch, classify, export, notify channel, notify managers.
The entry point is run.ps1: fetch all users, classify them, export a CSV, post to the channel, DM the managers. If any step fails, the whole run throws – no silent half-states. Each module is a separate .psm1 file with a single exported function.
For the Graph API calls, I used Invoke-MgGraphRequest for direct REST instead of the full Microsoft.Graph.* module tree. The SDK modules are enormous – loading them bloats cold starts on a Consumption plan. Direct REST calls with the authentication module keep it lean.
param($Timer)
Import-Module "$PSScriptRoot/../Modules/GraphHelper.psm1" -Force
Import-Module "$PSScriptRoot/../Modules/UserAnalyzer.psm1" -Force
Import-Module "$PSScriptRoot/../Modules/ReportExporter.psm1" -Force
Import-Module "$PSScriptRoot/../Modules/TeamsNotifier.psm1" -Force
Import-Module "$PSScriptRoot/../Modules/ManagerNotifier.psm1" -Force
$ErrorActionPreference = 'Stop'
$inactivityThresholdDays = 180
Write-Host "Starting inactive user report — threshold: $inactivityThresholdDays days"
try {
# 1. Fetch all users with sign-in activity and manager details
$allUsers = Get-AllUsersWithSignInActivity
# 2. Classify users against the 180-day threshold
$report = Get-InactiveUserReport -Users $allUsers -ThresholdDays $inactivityThresholdDays
Write-Host "Classification complete — Inactive: $($report.Inactive.Count) | Stale: $($report.NeverLoggedInStale.Count)"
if ($report.Inactive.Count -eq 0 -and $report.NeverLoggedInStale.Count -eq 0) {
Write-Host "No inactive users found. Exiting."
return
}
# 3. Export full CSV to blob storage and get a download URL
$csvUrl = $null
$storageAccount = $env:STORAGE_ACCOUNT_NAME
$container = $env:STORAGE_CONTAINER_NAME
if ($storageAccount -and $container) {
$csvUrl = Export-InactiveUserReport `
-Report $report `
-StorageAccountName $storageAccount `
-ContainerName $container
}
# 4. Post summary to Teams channel
$webhookUrl = $env:TEAMS_WEBHOOK_URL
if (-not $webhookUrl) {
throw "TEAMS_WEBHOOK_URL is not set."
}
Send-InactiveUserReport -Report $report -WebhookUrl $webhookUrl -CsvDownloadUrl $csvUrl
# 5. DM managers about their inactive employees
$dmWebhookUrl = $env:MANAGER_DM_WEBHOOK_URL
$dmAllowList = @(
'manager.one@contoso.com'
'manager.two@contoso.com'
)
if ($dmWebhookUrl) {
Send-ManagerNotifications -Report $report -WebhookUrl $dmWebhookUrl -AllowList $dmAllowList
}
}
catch {
Write-Error "Inactive user report failed: $_"
throw
}
function Get-AllUsersWithSignInActivity {
$selectFields = @(
'id', 'displayName', 'userPrincipalName', 'mail',
'employeeType', 'createdDateTime', 'accountEnabled', 'signInActivity'
) -join ','
$expandFields = 'manager($select=id,displayName,userPrincipalName,mail)'
$uri = "https://graph.microsoft.com/v1.0/users" +
"?`$select=$selectFields" +
"&`$expand=$expandFields" +
"&`$top=999" +
"&`$filter=accountEnabled eq true"
$allUsers = [System.Collections.Generic.List[object]]::new()
do {
$response = Invoke-MgGraphRequest -Uri $uri -Method GET -OutputType PSObject
foreach ($user in $response.value) {
$allUsers.Add($user)
}
$uri = $response.'@odata.nextLink'
Write-Host "Fetched $($allUsers.Count) users so far..."
} while ($uri)
Write-Host "Total users retrieved: $($allUsers.Count)"
return $allUsers.ToArray()
}
Export-ModuleMember -Function Get-AllUsersWithSignInActivity
Classifying Inactive Accounts in Entra ID
Not all inactivity is the same. Some users logged in once and vanished. Some never logged in at all. The pipeline distinguishes between them because the response is different.
The classifier walks every user and checks two things: do they have a sign-in record, and when was it? Users who signed in but not in the last 180 days are Inactive. Users whose account is older than 180 days with zero sign-ins are Never Logged In (Stale). New accounts with no login are ignored – they’re just provisioned and waiting.
Disabled accounts are excluded at the Graph query level (accountEnabled eq true), so the pipeline only sees accounts that are still technically usable – exactly the ones that pose a risk.
There’s a third exclusion that required cross-team work. A big chunk of those 200–300 manually flagged users turned out to be on long-term leave – parental, sick, gardening leave. Flagging them as inactive was technically correct but operationally useless. The HRIS team built a custom attribute sync from the HR system into Entra ID: a simple boolean property indicating whether a user is on extended leave, without exposing the reason (no personal data leaks into the directory). The pipeline checks this property and skips those users entirely – no false positives, no wasted HR time reviewing people who are expected to be away.
The report exporter takes the classified output and writes a CSV to Azure Blob Storage. The download URL is a User Delegation SAS – no storage account keys involved. The Function App’s Managed Identity requests a delegation key from Azure AD and signs the token itself. The link expires after 7 days.
function Get-InactiveUserReport {
param(
[Parameter(Mandatory)]
[object[]]$Users,
[int]$ThresholdDays = 180
)
$cutoffDate = (Get-Date).AddDays(-$ThresholdDays)
$inactive = [System.Collections.Generic.List[object]]::new()
$neverStale = [System.Collections.Generic.List[object]]::new()
foreach ($user in $Users) {
$lastSignIn = $user.signInActivity?.lastSignInDateTime
$createdDate = $user.createdDateTime
$parsedCreated = if ($createdDate) { [datetime]$createdDate } else { $null }
if ($lastSignIn) {
$parsedSignIn = [datetime]$lastSignIn
if ($parsedSignIn -lt $cutoffDate) {
$inactive.Add((Build-UserRecord -User $user -Category 'Inactive' -LastSignIn $parsedSignIn))
}
}
else {
if ($parsedCreated -and $parsedCreated -lt $cutoffDate) {
$neverStale.Add((Build-UserRecord -User $user -Category 'NeverLoggedIn_Stale' -LastSignIn $null))
}
}
}
return @{
Inactive = $inactive.ToArray()
NeverLoggedInStale = $neverStale.ToArray()
}
}
function Build-UserRecord {
param([object]$User, [string]$Category, [nullable[datetime]]$LastSignIn)
$manager = $User.manager
return [PSCustomObject]@{
Category = $Category
DisplayName = $User.displayName
UPN = $User.userPrincipalName
Mail = $User.mail
EmployeeType = if ($User.employeeType) { $User.employeeType } else { 'Unknown' }
CreatedDate = if ($User.createdDateTime) { ([datetime]$User.createdDateTime).ToString('yyyy-MM-dd') } else { 'Unknown' }
LastSignIn = if ($LastSignIn) { $LastSignIn.ToString('yyyy-MM-dd') } else { 'Never' }
ManagerName = if ($manager) { $manager.displayName } else { 'No manager assigned' }
ManagerUPN = if ($manager) { $manager.userPrincipalName } else { $null }
ManagerMail = if ($manager) { $manager.mail } else { $null }
}
}
Export-ModuleMember -Function Get-InactiveUserReport
function Export-InactiveUserReport {
param(
[Parameter(Mandatory)]
[hashtable]$Report,
[Parameter(Mandatory)]
[string]$StorageAccountName,
[string]$ContainerName = 'reports'
)
$allUsers = @($Report.Inactive) + @($Report.NeverLoggedInStale)
$runDate = Get-Date -Format 'yyyy-MM-dd-HHmm'
$blobName = "inactive-users-report-$runDate.csv"
$tempPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), $blobName)
# Write CSV to temp file
$allUsers | Select-Object Category, DisplayName, UPN, Mail, EmployeeType,
CreatedDate, LastSignIn, ManagerName, ManagerUPN, ManagerMail |
Export-Csv -Path $tempPath -NoTypeInformation -Encoding UTF8
# Upload via Managed Identity
$ctx = New-AzStorageContext -StorageAccountName $StorageAccountName -UseConnectedAccount
Set-AzStorageBlobContent `
-Context $ctx `
-Container $ContainerName `
-File $tempPath `
-Blob $blobName `
-Force | Out-Null
# Generate User Delegation SAS — no account keys involved
$expiry = (Get-Date).AddDays(7)
$sasUrl = New-AzStorageBlobSASToken `
-Context $ctx `
-Container $ContainerName `
-Blob $blobName `
-Permission 'r' `
-ExpiryTime $expiry `
-FullUri
Remove-Item $tempPath -Force -ErrorAction SilentlyContinue
return $sasUrl
}
Export-ModuleMember -Function Export-InactiveUserReport
Teams DMs via ROPC OAuth and Logic Apps
Microsoft Graph has no app-only path for sending Teams 1:1 messages. The workaround: ROPC OAuth with a dedicated service account, running through a Logic App that creates the chat, fetches credentials from Key Vault, acquires a delegated token, and sends the message – all from a single HTTP trigger.
When a manager gets a message from a bot or a connector, it lands in a side panel and gets ignored. When they get a message from a person – even one named “IT Notifications” – it shows up in their chat list like any other conversation. That distinction matters when you need someone to actually read the message and take action.
The problem is that Microsoft Graph’s ChatMessage.Send permission only exists as a delegated scope. There is no app-only equivalent. You can’t send a Teams chat message with just a client credential – you need a real user token.
The solution is ROPC (Resource Owner Password Credentials): a dedicated service account with its own Conditional Access policies, its password stored in Key Vault, and an app registration with ChatMessage.Send delegated permission. The Logic App fetches the password at runtime, exchanges it for a token, and sends the message as that user.
The service account has MFA excluded via a scoped Conditional Access policy – this is a deliberate, audited exception for a single-purpose account. ROPC was enabled during active use and disabled between run cycles. The next iteration is planned to migrate to Power Automate flows to eliminate ROPC entirely.
Messages are also automatically localised. The pipeline reads each user’s usageLocation property from Entra ID to determine their country, then selects the appropriate message template – Swedish for the Swedish market, Norwegian for Norway, and English as a fallback. This means every manager receives the notification in the language their team operates in, without any manual routing or locale configuration.
Localisation
- Detection
- The pipeline reads the
usageLocationproperty from Entra ID to determine each user’s country – no manual tagging, no CSV lookups. - Templates
- Swedish for SE users, Norwegian for NO users, English as fallback. Each template was reviewed by native speakers and refined with HR feedback.
- Market-specific
- Messages reference the correct local HR processes and terminology for each market, so managers see instructions relevant to their country.
Logic App – 4-step DM workflow
- Step 1 – Create chat
- POST to Graph
/v1.0/chatsvia Managed Identity (Chat.Createapp role). Creates a group chat between the sender account and the recipient manager. - Step 2 – Fetch password
- GET from Key Vault via Managed Identity. The sender account’s password is stored as a secret and never touches code or configuration.
- Step 3 – ROPC token
- POST to
/oauth2/v2.0/tokenwithgrant_type=password. Exchanges the service account credentials for a delegated access token scoped toChatMessage.Send. - Step 4 – Send message
- POST to
/v1.0/chats/{chatId}/messageswith the ROPC token in the Authorization header. The message appears in Teams from the service account – a real user, not a bot.
| Name | Type | Last Login | Manager |
|---|---|---|---|
| User A | Employee | 2025-08-14 | Manager One |
| User B | Employee | 2025-07-22 | Manager Two |
| User C | Contractor | 2025-06-01 | Manager One |
| User D | Employee | 2025-09-30 | Manager Three |
| User E | Contractor | 2025-05-11 | Manager Two |
Hi Manager,
This is an automated notification to let you know that the following 2 direct reports have not logged into their Microsoft account for over 180 days:
- User A – 933 days since last login
- User B – 339 days since last login
Additionally, you have a direct report who has never logged in:
- User C – account active for 465 days, never logged in
What you need to do
Please review each individual and take the appropriate action:
- If the person no longer has an active contract, or is not expected to return for work:
→ Terminate the employment in the HRIS tool in line with HR procedures - If the person is on approved leave (e.g. extended leave):
→ No action needed
Taking action helps maintain a secure environment and avoids unnecessary licence costs.
Thank you
function Send-ManagerNotifications {
param(
[Parameter(Mandatory)]
[hashtable]$Report,
[Parameter(Mandatory)]
[string]$WebhookUrl,
[string[]]$AllowList = @()
)
if ($AllowList.Count -eq 0) {
Write-Host "Allow list empty — skipping manager notifications."
return
}
# Filter to allow-listed managers only
$eligibleInactive = @($Report.Inactive | Where-Object {
$_.ManagerUPN -and ($AllowList -contains $_.ManagerUPN)
})
$eligibleNeverLoggedIn = @($Report.NeverLoggedInStale | Where-Object {
$_.ManagerUPN -and ($AllowList -contains $_.ManagerUPN)
})
$allManagerUpns = @(
$eligibleInactive | Select-Object -ExpandProperty ManagerUPN
$eligibleNeverLoggedIn | Select-Object -ExpandProperty ManagerUPN
) | Select-Object -Unique
foreach ($managerUpn in $allManagerUpns) {
$inactiveGroup = @($eligibleInactive | Where-Object { $_.ManagerUPN -eq $managerUpn })
$neverLoggedInGroup = @($eligibleNeverLoggedIn | Where-Object { $_.ManagerUPN -eq $managerUpn })
$message = Build-ManagerDmMessage `
-ManagerFirstName ($inactiveGroup[0].ManagerName -split ' ')[0] `
-InactiveEmployees $inactiveGroup `
-NeverLoggedInEmployees $neverLoggedInGroup
$payload = @{
recipientUpn = $managerUpn
message = $message
} | ConvertTo-Json
try {
Invoke-RestMethod -Uri $WebhookUrl -Method POST -Body $payload -ContentType 'application/json'
Write-Host "DM sent to $managerUpn"
}
catch {
Write-Warning "Failed to send DM to $managerUpn`: $_"
}
}
}
Export-ModuleMember -Function Send-ManagerNotifications
Infrastructure as Code with Bicep
Every Azure resource – the Function App, Storage Account, Key Vault, both Logic Apps, and all RBAC assignments – is defined in Bicep and deployed with a single az deployment group create.
The root main.bicep orchestrates five modules. Each module creates its resources and exports outputs that downstream modules consume – the Function App’s Managed Identity principal ID feeds into the Key Vault access policy, the Key Vault name feeds into the Function App’s app settings as Key Vault references.
RBAC assignments are declared inline: the Function App’s identity gets Storage Blob Data Contributor and Storage Blob Delegator on the storage account. The Logic App’s identity gets Key Vault Secrets User. No portal role assignments, no out-of-band scripts – if the Bicep deploys, the permissions are in place.
param environmentName string = 'sandbox'
param location string = resourceGroup().location
param teamsGroupId string // Team Object ID
param teamsChannelId string // Channel ID
param teamsSenderUpn string // DM sender UPN
param ropcClientId string // ROPC app registration
var appName = 'inactive-users'
var resourceSuffix = '${appName}-${environmentName}'
module keyVault 'modules/keyVault.bicep' = {
name: 'deploy-keyVault'
params: {
resourceSuffix : resourceSuffix
location : location
}
}
module functionApp 'modules/functionApp.bicep' = {
name: 'deploy-functionApp'
params: {
resourceSuffix : resourceSuffix
location : location
keyVaultName : keyVault.outputs.keyVaultName
}
}
module logicApp 'modules/logicApp.bicep' = {
name: 'deploy-logicApp'
params: {
resourceSuffix : resourceSuffix
location : location
teamsGroupId : teamsGroupId
teamsChannelId : teamsChannelId
}
}
module logicAppManagerDm 'modules/logicAppManagerDm.bicep' = {
name: 'deploy-logicAppManagerDm'
params: {
resourceSuffix: resourceSuffix
location : location
keyVaultName : keyVault.outputs.keyVaultName
tenantId : tenant().tenantId
ropcClientId : ropcClientId
senderUpn : teamsSenderUpn
}
}
module kvAccessPolicy 'modules/keyVaultAccessPolicy.bicep' = {
name: 'deploy-kvAccessPolicy'
params: {
keyVaultName : keyVault.outputs.keyVaultName
principalId : functionApp.outputs.managedIdentityPrincipalId
}
}
output functionAppName string = functionApp.outputs.functionAppName
output managedIdentityPrincipalId string = functionApp.outputs.managedIdentityPrincipalId
output keyVaultName string = keyVault.outputs.keyVaultName
output logicAppName string = logicApp.outputs.logicAppName
param resourceSuffix string
param location string
param keyVaultName string
var reportsContainer = 'reports'
var storageBlobDataContributorRoleId = '<built-in-role-id>'
var storageBlobDelegatorRoleId = '<built-in-role-id>'
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name : take('stgidleusers${replace(resourceSuffix, '-', '')}', 24)
location : location
sku : { name: 'Standard_LRS' }
kind : 'StorageV2'
properties: {
allowBlobPublicAccess : false
minimumTlsVersion : 'TLS1_2'
}
}
resource functionApp 'Microsoft.Web/sites@2023-01-01' = {
name : 'func-${resourceSuffix}'
location : location
kind : 'functionapp'
identity : { type: 'SystemAssigned' }
properties: {
serverFarmId: appServicePlan.id
siteConfig : {
powerShellVersion : '7.4'
appSettings : [
{ name: 'FUNCTIONS_WORKER_RUNTIME', value: 'powershell' }
{ name: 'TEAMS_WEBHOOK_URL',
value: '@Microsoft.KeyVault(VaultName=${keyVaultName};SecretName=teams-webhook-url)' }
{ name: 'MANAGER_DM_WEBHOOK_URL',
value: '@Microsoft.KeyVault(VaultName=${keyVaultName};SecretName=manager-dm-webhook-url)' }
{ name: 'STORAGE_ACCOUNT_NAME', value: storageAccount.name }
{ name: 'STORAGE_CONTAINER_NAME', value: reportsContainer }
]
}
httpsOnly: true
}
}
// RBAC: Managed Identity → Storage Account
resource blobContributorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name : guid(storageAccount.id, functionApp.name, storageBlobDataContributorRoleId)
scope : storageAccount
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions',
storageBlobDataContributorRoleId)
principalId : functionApp.identity.principalId
principalType : 'ServicePrincipal'
}
}
resource blobDelegatorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name : guid(storageAccount.id, functionApp.name, storageBlobDelegatorRoleId)
scope : storageAccount
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions',
storageBlobDelegatorRoleId)
principalId : functionApp.identity.principalId
principalType : 'ServicePrincipal'
}
}
From ad-hoc to automated.
Before this project, IT was manually flagging 200–300 inactive users to HR every cycle – pulling exports, cross-referencing sign-in data, and emailing spreadsheets back and forth. There was no shared definition of “inactive,” no consistent threshold, and no way to distinguish a ghost account from someone on parental leave.
The pipeline replaced all of that. The HRIS leave attribute eliminated false positives. The localised manager DMs put clear action items in front of the right people. HR gets a downloadable CSV with full classification data, feeding into their twice-yearly review cycles – April before salary reviews, August before budgeting.
The first production run led to ~50 accounts being disabled. Each reclaimed account freed an M365 licence – but the licence savings were the smaller win. The larger value was in security posture: 50 fewer accounts accessible to credential-stuffing attacks, 50 fewer stale identities syncing across downstream applications. A cleaner tenant is a safer tenant, and the savings compound with every system that touches the directory.
On the process side, the manual cycle – the exports, the cross-referencing, the back-and-forth emails – is gone entirely. IT no longer touches it. Managers get a Teams message with names, numbers, and clear next steps instead of a spreadsheet they have to interpret. HR triggers the pipeline twice a year, reviews the CSV, and acts. The entire process now takes a fraction of the time it used to, and none of that time is IT’s.
Getting there meant coordinating across five teams: HR defined the process and refined the messaging. The HRIS team built the custom leave attribute sync. Medical operations validated that clinician sign-in patterns differed from office staff. The platform team approved the Azure infrastructure. I worked across all of them, designed the architecture, built the pipeline, and shipped it.
Five modules. Seven Azure resources. Five teams. ~50 accounts reclaimed on day one. And a process that HR now owns without IT involvement.