Disable “stale” user accounts for Office 365 and on-prem hybrid

We recently had a cyber audit at work – and one of the recommendations was to ensure that any user accounts not used within 90 days were disabled.

Prior to us moving to Office 365 hybrid we did have an on-prem tool that would do this for us, checking on-prem domain controllers for last login dates and times.

However – this doesn’t work so well with hybrid. Running this tool disabled some user accounts that were very much active – they just didn’t do anything that registered on-prem as a login.

So, grab login details from the cloud?

A bit of Powershell should be able to do this!

Fortunately at the end of 2023 Microsoft introduced the lastSuccessfulSignInDateTime property – which is a more reliable indication of account activity as it only records successful user logins.

If you want to know more about this property and why it’s so useful I found this blog post to be very helpful:

No Azure runbook however

I’ve recently been moving on-prem scripts into Azure runbooks wherever possible – I’d rather not have the overhead of an on-prem server to run scripts that Azure could do for me.

Azure runbooks aren’t an option in this instance. As we are in hybrid our on-prem domain is authoritative – so any disabling done in Entra/Azure will be overwritten on the next sync.

Rather than look into any hybrid runbooks I decided to just create an on-prem script to do it all for me.

What this script does

This script will grab all users (on-prem, cloud-only and guests) and disable them if one of the following is true:

  • Account created more than 30 days ago, but no login
  • There has been a login, but more than 90 days ago

Unless the account is either already disabled, or a member of a group to exclude the account.

In detail the script will:

  • Connect to MS Graph (using app-only client secret auth)
  • Get all users
  • For each user:
    • Check if account is enabled
    • Check that it is not a member of a group of protected accounts we don’t want to disable
    • Check if there is anything in lastSuccessfulSignInDateTime (this will only have data if the account has been used since the property was introduced in December 2023)
      • If no login, check if the account was created more than 30 days ago
      • If there is a login, check if it was more than 90 days ago
    • Check if the account is on-prem/Entra or guest
    • Disable any accounts that match the above
    • Add them to a “Automatically disabled” group
  • If any accounts end up being disabled, produce a CSV with details and sends an alert to Teams

Setup

To use this script you’ll need to:

  • Be using 0365 in hybrid, and have a server on-prem that can run this script
    • Server needs to have MSGraph SDK installed
  • Set up app-only client secret auth
  • Create the following groups in Entra:
    • A group to hold cloud-only “break glass” account(s). By design these groups won’t have regular logins, so we want to avoid disabling them
    • A group to hold other accounts that shouldn’t be disabled
    • A group to hold accounts that are disabled by this script
  • Create the following groups on-prem:
    • A group to hold accounts that shouldn’t be disabled (particularly any service accounts)
    • A group to hold accounts that are disabled by this script
  • (Optionally) Create a webhook in a Teams channel to receive notifications

Once you’ve set up the above there are several variables that need to be entered into the script:

  • Details of the app access token needed to connect to MS Graph
  • The webhook for your Teams channel
  • IDs of the groups you’ll be using

The script

As always – best practice is to make sure you understand any scripts you download from the internet before you run them – and this script has the potential to disable a large number of accounts!

You absolutely want to have some groups set up to ensure that key accounts are not disabled.

With this in mind the script below has the “disable” and “add to group” lines commented out – to avoid someone YOLO’ing it and finding out that multiple important accounts are now dead…

I’ll leave you to find the lines that need to be un-commented for the script to be useful!

<#
    .Synopsis
        Script to find "stale" accounts - mainly accounts with no login in past 90 days, but also new accounts that haven't been used in first 30 days
        Makes use of lastSuccessfulSignInDateTime property - introduced by Microsoft December 2023

    .DESCRIPTION
        Inteneded to be run on-prem
        Does not export any functions or variables

    .VERSION 1
        V1: Initial version

    .NOTES
        Filename  :    DisableStaleAccounts.ps1
        Author    :    Matt Kirby, April 2024

        Some of the code below based on: https://blog.admindroid.com/get-last-successful-sign-in-date-report-for-microsoft-365-users

        MS docs on Graph Powershell SDK:
        https://docs.microsoft.com/en-us/powershell/microsoftgraph/get-started?view=graph-powershell-1.0

        This module uses app-only client secret auth, as per https://thesysadminchannel.com/how-to-connect-to-microsoft-graph-api-using-powershell/#SecretAuthentication
#>

#Ensure there aren't typos in variables
Set-StrictMode -Version Latest

# =======================
# "Set variables" section
# =======================

#Bits for Access Token to use in the connection string to MSGraph
$AppId = ""
$TenantId = ""
$ClientSecret = "" #will need updating when it expires

#Teams Adpative Card bits
$TeamsWebhook = ""
$CardTitle = "DisableStaleAccounts script on on $env:computername"

#Entra Object IDs for groups for "protected" accounts - ones we don't want to touch. Accounts in these groups will *not* be disabled
$GrpBreakGlass = ""
$GrpDoNotTidyOnPrem = ""
$GrpDoNotTidyCloud = ""

#Details of groups we want to add disabled users to so someone in the future knows why they are disabled!
$GrpCloud = "" #Needs to be Object ID in Entra
$GrpOnPrem = "" #Needs to be on-prem distinguished name, GUID, security identifier, or Security Account Manager (SAM) account name

#Export to CSV bits
$ExportCSV = ".\Reports\DisableStaleAccounts_Report_$((Get-Date -format yyyy-MMM-dd-ddd` hh-mm-ss` tt).ToString()).csv"
$ExportResult=""   

#============================
# End "set variables" section
#============================

function Send-TeamsAlert {
param(
    [URI] $URI = $TeamsWebhook,
    [string] $CardTitle = "Test message",
    [string] $CardSummary = $CardTitle, #This isn't used in Teams, so we can default it to be the same as title
    [string] $CardText = "Test message"
    )

    $JSONBody = [PSCustomObject][Ordered]@{
    "@type"      = "MessageCard"
    "@context"   = "http://schema.org/extensions"
    "summary"    = $CardSummary
    "themeColor" = '0078D7'
    "title"      = $CardTitle
    "text"       = $CardText
    }
    
$TeamsMessageBody = ConvertTo-Json $JSONBody -Depth 100

$parameters = @{
    "URI"         = $URI
    "Method"      = 'POST'
    "Body"        = $TeamsMessageBody
    "ContentType" = 'application/json'
}
# Allow all the TLS!  As per https://blog.darrenjrobinson.com/powershell-the-underlying-connection-was-closed-an-unexpected-error-occurred-on-a-send/
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls -bor [Net.SecurityProtocolType]::Tls11 -bor [Net.SecurityProtocolType]::Tls12

# Send the card
Write-Output "Sending alert to Teams..."
Invoke-RestMethod @parameters | Out-Null
}

Function Handle-Error 
    {
    Send-TeamsAlert -CardTitle $CardTitle -CardText $args[0]
    Throw $args[0]
    }

#Log start of script
Write-Output "Connecting to graph..."

Try #MSGraph connect
    {
    $MsalToken = Get-MsalToken -TenantId $TenantId -ClientId $AppId -ClientSecret ($ClientSecret | ConvertTo-SecureString -AsPlainText -Force)
    Connect-MGGraph -AccessToken ($MsalToken.AccessToken|ConvertTo-SecureString -AsPlainText -Force) -ErrorAction Stop -NoWelcome
    Write-Output "Connected!"
    }
Catch {Handle-Error "Failed to connect to O365: $PSItem `n Has the secure secret expired? Check app in https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps"}

$Count=0
$InternalUsersDisabled=0
$ExternalUsersDisabled=0

$RequiredProperties=@('UserPrincipalName','Id', 'OnPremisesDistinguishedName','CreatedDateTime','AccountEnabled','Department','JobTitle','Description','SigninActivity', "onPremisesSyncEnabled")

Get-MgBetaUser -All -Property $RequiredProperties | Select-Object $RequiredProperties | ForEach-Object {  
    
    #Ensure variables are clear each run
    $BreakGlassAcc = $null
    $DoNotTidyOnPremAcc = $null
    $DoNotTidyCloudAcc = $null
    $ToBeDisabled = $False
    $Action = ""
    $AccType = ""

    $Count++
    
    $UPN=$_.UserPrincipalName
    
    Write-Progress -Activity "`n     Processing user: $Count - $UPN"

    #Is this account a member of any groups that should stop us tidying?
    $BreakGlassAcc = Confirm-MgUserMemberGroup -UserID $UPN -GroupId $GrpBreakGlass
    $DoNotTidyOnPremAcc = Confirm-MgUserMemberGroup -UserID $UPN -GroupId $GrpDoNotTidyOnPrem
    $DoNotTidyCloudAcc = Confirm-MgUserMemberGroup -UserID $UPN -GroupId $GrpDoNotTidyCloud
     If ($BreakGlassAcc){write-output "Break-glass account: $UPN"}
     If ($DoNotTidyOnPremAcc){write-output "On-prem do-not-tidy account: $UPN"}
     If ($DoNotTidyCloudAcc){write-output "Cloud do-not-tidy account: $UPN"}
  
    #We only want to deal with active accounts that are *not* members of the above groups- we want to avoid getting more details from accounts that we aren't going to do anything with
    If(($_.AccountEnabled -eq $true) -and ($BreakGlassAcc -eq $null) -and ($DoNotTidyOnPremAcc -eq $null) -and ($DoNotTidyCloudAcc -eq $null))
        {
        #Grab account properties
        $ID = $_.Id
        $DN = $_.OnPremisesDistinguishedName
        $LastSuccessfulSigninDate=$_.SignInActivity.lastSuccessfulSignInDateTime
        $LastInteractiveSignIn=$_.SignInActivity.LastSignInDateTime
        $LastNon_InteractiveSignIn=$_.SignInActivity.LastNonInteractiveSignInDateTime
        $CreatedDate=$_.CreatedDateTime
        $Department=$_.Department
        $JobTitle=$_.JobTitle
        $OnPremAcc=$_.onPremisesSyncEnabled

        #Has there been a login?
        If($LastSuccessfulSigninDate -eq $null)
            { #No login
            $LastSuccessfulSigninDate="Data not available"
            $InactiveUserDays= "-"
            
            #Was account created more than a month ago?
            If ($CreatedDate -le $([datetime]::UtcNow.AddDays(-30).ToString("s")))
                {
                   $ToBeDisabled = $True
                   $Action = "Account created more than 30 days ago and no login"
                }
            }
        Else
            { #Was login more than 90 days ago?
            If ($LastSuccessfulSigninDate -le $([datetime]::UtcNow.AddDays(-90).ToString("s"))) 
                {
                    $ToBeDisabled = $True
                    $Action = "No login in 90 days"
                }
            
            #Calculate how long ago last login was
            $InactiveUserDays= (New-TimeSpan -Start $LastSuccessfulSigninDate).Days
            }
        
        If ($ToBeDisabled)
            {
            If ($OnPremAcc) #Internal user - disable on-prem - we can't disable in Entra as next on-prem would sync and over-write
                {
                $AccType = "Internal"
                
                    Try #Disable user on-prem
                        {
 #                       Disable-ADAccount -Identity $DN     -ErrorAction Stop 
 #                       Add-ADGroupMember -Identity $GrpOnPrem -Members $DN -ErrorAction  SilentlyContinue #I only care that the account is disabled - adding to a group is a "nice to have"
                        $InternalUsersDisabled++
                        $Action = $Action + ", account disabled on-prem"
                        }
    
                    Catch {Handle-Error "Failed to disable/add $UPN to group on prem: $PSItem"}
                }
            Else  #Account must be cloud-only or a guest
                {
                If ($UPN -match '#EXT#')
                    {
                    $AccType = "External/guest"
                    $ExternalUsersDisabled++
                    }
                Else
                    {
                    $AccType = "Cloud only"
                    $InternalUsersDisabled++
                    }
                    
                Try #Disable user in Entra
                    {
                    $params = @{AccountEnabled = "false"}  
#                    Update-MgBetaUser -UserId $ID -BodyParameter $params -ErrorAction Stop 
#                    New-MgGroupMember -GroupId $GrpCloud -DirectoryObjectId $ID -ErrorAction SilentlyContinue #I only care that the account is disabled - adding to a group is a "nice to have"
                    
                    $Action = $Action + ", account disabled in Entra"
                    }
                Catch {Handle-Error "Failed to disable.add $UPN to group in Entra: $PSItem"}
                    }
            
            $ExportResult=[PSCustomObject]@{'Account type'=$AccType;'UPN'=$UPN;'Department'=$Department;'Job Title'=$JobTitle;'Action taken'=$Action;'Inactive Days'=$InactiveUserDays;'Last Successful Signin Date'=$LastSuccessfulSigninDate;'Last Interactive SignIn Date'=$LastInteractiveSignIn;'Last Non Interactive SignIn Date'=$LastNon_InteractiveSignIn;'Creation Date'=$CreatedDate}
            Try {$ExportResult | Export-Csv -Path $ExportCSV -Notype -Append}
            Catch {Handle-Error "Failed to export report: $PSItem"}
            }
        }
    }

If((Test-Path -Path $ExportCSV) -eq "True") #Did all of the above produce a report?
    {
    Write-Output "Disabled $InternalUsersDisabled internal user(s) and $ExternalUsersDisabled external user(s)." 
    Write-Output "Detailed report available in: $ExportCSV"
    
    $ReportPath = $PSScriptRoot +  $ExportCSV.trimstart(".")
    Send-TeamsAlert -CardTitle $CardTitle -CardText "I disabled $InternalUsersDisabled internal user(s) and $ExternalUsersDisabled external user(s), full report on $env:computername $ReportPath"
    }
Else
    {
     Write-Output "No users found that needed any actions"
    }

Leave a comment