Case study · June 2026

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.

Timeline
Feb '26 → Apr '26
build · test · HR handoff
Scope
All accounts
every enabled user in the tenant
Output
Automated pipeline
scan · classify · notify · export
Stack
Azure + PS7
Functions · Graph · Logic Apps · Bicep
Executive summary

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.

Data flow
T Timer trigger G Graph API C Classify B Blob Storage L Logic App Ch Teams channel L Logic App DM Manager DMs CSV + 7-day SAS URL Adaptive Card ROPC → real user
~0users
Flagged manually each cycle – now automated
0teams
HR · HRIS · Medical Ops · Platform · IT
0resources
Deployed via Bicep in one command

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 for ChatMessage.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.

TimerTrigger / run.ps1
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
}
Modules / GraphHelper.psm1
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.

Modules / UserAnalyzer.psm1
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
Modules / ReportExporter.psm1
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 usageLocation property 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/chats via Managed Identity (Chat.Create app 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/token with grant_type=password. Exchanges the service account credentials for a delegated access token scoped to ChatMessage.Send.
Step 4 – Send message
POST to /v1.0/chats/{chatId}/messages with the ROPC token in the Authorization header. The message appears in Teams from the service account – a real user, not a bot.
Teams – Channel notification (Adaptive Card)
Inactive User Report
Run: 02 Jun 2026 07:01 UTC Inactive (180+ days): 12 Never logged in (stale): 3 Total flagged: 15 By employee type: Employee: 9 · Contractor: 4 · Unknown: 2
Download full report (CSV) – link valid 7 days
Inactive – last sign-in over 180 days ago (12 total)
NameTypeLast LoginManager
User AEmployee2025-08-14Manager One
User BEmployee2025-07-22Manager Two
User CContractor2025-06-01Manager One
User DEmployee2025-09-30Manager Three
User EContractor2025-05-11Manager Two
3 accounts have never logged in and are over 180 days old. Download the CSV for the full list.
Teams – Manager DM (personalised per manager)

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:

  1. 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
  2. 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

Modules / ManagerNotifier.psm1
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.

F
Azure Function App
PowerShell 7.4 · Consumption
P
App Service Plan
Y1 / Dynamic
S
Storage Account
Standard_LRS · TLS 1.2 · no public blob
B
Blob Container
reports
K
Key Vault
RBAC mode · secrets only
L
Logic App – Channel
HTTP trigger → Teams API connection
L
Logic App – Manager DM
MSI + ROPC dual-auth
infra / main.bicep
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
infra / modules / functionApp.bicep
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.

Project · Inactive User Flow
Runtime · Azure Functions + Logic Apps
Live · Apr 2026
Written · June 2026
Back to blog