Public/NC.Security.ps1

#Requires -Version 5.0
using namespace System.Management.Automation

# Nebula.Core: Security helpers =====================================================================================================================

function Disable-UserDevices {
    <#
    .SYNOPSIS
        Disables all registered devices for specified users.
    .DESCRIPTION
        Ensures Microsoft Graph connectivity, resolves the target users, and sets AccountEnabled = $false
        on each registered device. Skips missing users and reports progress.
    .PARAMETER UserPrincipalName
        One or more user principal names. Accepts pipeline input.
    .PARAMETER PassThru
        Emit the impacted devices as objects.
    .EXAMPLE
        Disable-UserDevices -UserPrincipalName user1@contoso.com,user2@contoso.com -WhatIf
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('User', 'UPN', 'Identity')]
        [string[]]$UserPrincipalName,
        [switch]$PassThru
    )

    begin {
        Set-ProgressAndInfoPreferences
        $targets = [System.Collections.Generic.List[string]]::new()
    }

    process {
        foreach ($upn in $UserPrincipalName) {
            if (-not [string]::IsNullOrWhiteSpace($upn)) {
                $targets.Add($upn.Trim()) | Out-Null
            }
        }
    }

    end {
        try {
            if ($targets.Count -eq 0) {
                Write-NCMessage "No user principal names provided." -Level WARNING
                return
            }

            $scopes = @('Directory.ReadWrite.All', 'Device.ReadWrite.All')
            if (-not (Test-MgGraphConnection -Scopes $scopes -EnsureExchangeOnline:$false)) {
                Write-NCMessage "`nCan't connect or use Microsoft Graph modules. `nPlease check logs." -Level ERROR
                return
            }

            $results = [System.Collections.Generic.List[object]]::new()
            $dedup = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
            $queue = foreach ($entry in $targets) { if ($dedup.Add($entry)) { $entry } }
            $counter = 0

            foreach ($upn in $queue) {
                $counter++
                $percent = (($counter / $queue.Count) * 100)
                Write-Progress -Activity "Resolving user $upn" -Status "$counter of $($queue.Count) ($($percent.ToString('0.00'))%)" -PercentComplete $percent

                $escaped = $upn.Replace("'", "''")
                try {
                    $user = Get-MgUser -Filter "userPrincipalName eq '$escaped'" -ConsistencyLevel eventual -ErrorAction Stop | Select-Object -First 1
                }
                catch {
                    Write-NCMessage "Can't find Azure AD account for user $upn. $($_.Exception.Message)" -Level ERROR
                    continue
                }

                if (-not $user) {
                    Write-NCMessage "Can't find Azure AD account for user $upn." -Level ERROR
                    continue
                }

                try {
                    $devices = Get-MgUserRegisteredDevice -UserId $user.Id -All
                }
                catch {
                    Write-NCMessage "Unable to retrieve registered devices for $($user.UserPrincipalName). $($_.Exception.Message)" -Level ERROR
                    continue
                }

                if (-not $devices -or $devices.Count -eq 0) {
                    Write-NCMessage ("No registered devices found for {0}." -f $user.UserPrincipalName) -Level WARNING
                    continue
                }

                foreach ($device in $devices) {
                    $deviceLabel = if ($device.DisplayName) { $device.DisplayName } else { $device.Id }
                    if (-not $PSCmdlet.ShouldProcess($deviceLabel, "Disable device for user $($user.UserPrincipalName)")) {
                        continue
                    }

                    try {
                        Update-MgDevice -DeviceId $device.Id -AccountEnabled:$false -ErrorAction Stop | Out-Null
                        $results.Add([pscustomobject]@{
                                UserPrincipalName = $user.UserPrincipalName
                                UserDisplayName   = $user.DisplayName
                                DeviceId          = $device.Id
                                DeviceDisplayName = $device.DisplayName
                                Action            = 'Disabled'
                            }) | Out-Null
                    }
                    catch {
                        Write-NCMessage "Failed to disable device $deviceLabel for $($user.UserPrincipalName). $($_.Exception.Message)" -Level ERROR
                    }
                }
            }

            if ($PassThru.IsPresent) {
                $results
            }
            elseif ($results.Count -gt 0) {
                Write-NCMessage ("Disabled {0} device(s)." -f $results.Count) -Level SUCCESS
            }
        }
        finally {
            Write-Progress -Activity "Resolving user" -Completed
            Restore-ProgressAndInfoPreferences
        }
    }
}

function Disable-UserSignIn {
    <#
    .SYNOPSIS
        Blocks sign-in for specified users.
    .DESCRIPTION
        Ensures Microsoft Graph connectivity, resolves the target users, and sets AccountEnabled = $false.
        Skips missing users and supports WhatIf/Confirm.
    .PARAMETER UserPrincipalName
        One or more user principal names. Accepts pipeline input.
    .PARAMETER PassThru
        Emit the impacted users as objects.
    .EXAMPLE
        Disable-UserSignIn -UserPrincipalName user1@contoso.com,user2@contoso.com -Confirm:$false
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('User', 'UPN', 'Identity')]
        [string[]]$UserPrincipalName,
        [switch]$PassThru
    )

    begin {
        Set-ProgressAndInfoPreferences
        $targets = [System.Collections.Generic.List[string]]::new()
    }

    process {
        foreach ($upn in $UserPrincipalName) {
            if (-not [string]::IsNullOrWhiteSpace($upn)) {
                $targets.Add($upn.Trim()) | Out-Null
            }
        }
    }

    end {
        try {
            if ($targets.Count -eq 0) {
                Write-NCMessage "No user principal names provided." -Level WARNING
                return
            }

            $scopes = @('Directory.ReadWrite.All')
            if (-not (Test-MgGraphConnection -Scopes $scopes -EnsureExchangeOnline:$false)) {
                Write-NCMessage "`nCan't connect or use Microsoft Graph modules. `nPlease check logs." -Level ERROR
                return
            }

            $results = [System.Collections.Generic.List[object]]::new()
            $dedup = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
            $queue = foreach ($entry in $targets) { if ($dedup.Add($entry)) { $entry } }
            $counter = 0

            foreach ($upn in $queue) {
                $counter++
                $percent = (($counter / $queue.Count) * 100)
                Write-Progress -Activity "Processing $upn" -Status "$counter of $($queue.Count) ($($percent.ToString('0.00'))%)" -PercentComplete $percent

                $escaped = $upn.Replace("'", "''")
                try {
                    $user = Get-MgUser -Filter "userPrincipalName eq '$escaped'" -ConsistencyLevel eventual -ErrorAction Stop | Select-Object -First 1
                }
                catch {
                    Write-NCMessage "Can't find Azure AD account for user $upn. $($_.Exception.Message)" -Level ERROR
                    continue
                }

                if (-not $user) {
                    Write-NCMessage "Can't find Azure AD account for user $upn." -Level ERROR
                    continue
                }

                if (-not $PSCmdlet.ShouldProcess($user.UserPrincipalName, "Disable sign-in")) {
                    continue
                }

                try {
                    Update-MgUser -UserId $user.Id -AccountEnabled:$false -ErrorAction Stop | Out-Null
                    $results.Add([pscustomobject]@{
                            UserPrincipalName = $user.UserPrincipalName
                            DisplayName       = $user.DisplayName
                            Action            = 'SignInDisabled'
                        }) | Out-Null
                }
                catch {
                    Write-NCMessage "Failed to disable sign-in for $($user.UserPrincipalName). $($_.Exception.Message)" -Level ERROR
                }
            }

            if ($PassThru.IsPresent) {
                $results
            }
            elseif ($results.Count -gt 0) {
                Write-NCMessage ("Sign-in disabled for {0} user(s)." -f $results.Count) -Level SUCCESS
            }
        }
        finally {
            Write-Progress -Activity "Processing users" -Completed
            Restore-ProgressAndInfoPreferences
        }
    }
}

function Revoke-UserSessions {
    <#
    .SYNOPSIS
        Forces sign-out by revoking refresh tokens for users.
    .DESCRIPTION
        Ensures Microsoft Graph connectivity, targets all users or a selection (with optional exclusions),
        and calls Revoke-MgUserSignInSession. Supports WhatIf/Confirm.
    .PARAMETER All
        Target every user in the tenant.
    .PARAMETER UserPrincipalName
        Specific users to target. Accepts pipeline input.
    .PARAMETER Exclude
        Users to skip when using -All or a list.
    .PARAMETER PassThru
        Emit the impacted users as objects.
    .EXAMPLE
        Revoke-UserSessions -UserPrincipalName user1@contoso.com,user2@contoso.com
    .EXAMPLE
        Revoke-UserSessions -All -Exclude breakglass@contoso.com -Confirm:$false
    #>

    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [switch]$All,
        [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('User', 'UPN', 'Identity')]
        [string[]]$UserPrincipalName,
        [string[]]$Exclude,
        [switch]$PassThru
    )

    begin {
        Set-ProgressAndInfoPreferences
        $targets = [System.Collections.Generic.List[string]]::new()
        $exclusions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    }

    process {
        foreach ($upn in $UserPrincipalName) {
            if (-not [string]::IsNullOrWhiteSpace($upn)) {
                $targets.Add($upn.Trim()) | Out-Null
            }
        }
    }

    end {
        try {
            if (-not $All.IsPresent -and $targets.Count -eq 0) {
                Write-NCMessage "No target specified. Use -All or provide user principal names." -Level WARNING
                return
            }

            foreach ($ex in $Exclude) {
                if (-not [string]::IsNullOrWhiteSpace($ex)) { $exclusions.Add($ex.Trim()) | Out-Null }
            }

            $scopes = @('Directory.ReadWrite.All')
            if (-not (Test-MgGraphConnection -Scopes $scopes -EnsureExchangeOnline:$false)) {
                Write-NCMessage "`nCan't connect or use Microsoft Graph modules. `nPlease check logs." -Level ERROR
                return
            }

            $queue = [System.Collections.Generic.List[Microsoft.Graph.PowerShell.Models.IMicrosoftGraphUser]]::new()

            if ($All.IsPresent) {
                try {
                    $allUsers = Get-MgUser -All -ConsistencyLevel eventual -ErrorAction Stop
                    foreach ($u in $allUsers) { $queue.Add($u) | Out-Null }
                }
                catch {
                    Write-NCMessage "Unable to retrieve all users. $($_.Exception.Message)" -Level ERROR
                    return
                }
            }
            else {
                $dedup = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
                $uniqueTargets = foreach ($entry in $targets) { if ($dedup.Add($entry)) { $entry } }

                foreach ($upn in $uniqueTargets) {
                    $escaped = $upn.Replace("'", "''")
                    try {
                        $user = Get-MgUser -Filter "userPrincipalName eq '$escaped'" -ConsistencyLevel eventual -ErrorAction Stop | Select-Object -First 1
                        if ($user) {
                            $queue.Add($user) | Out-Null
                        }
                        else {
                            Write-NCMessage "Can't find Azure AD account for user $upn." -Level ERROR
                        }
                    }
                    catch {
                        Write-NCMessage "Can't find Azure AD account for user $upn. $($_.Exception.Message)" -Level ERROR
                    }
                }
            }

            if ($queue.Count -eq 0) {
                Write-NCMessage "No users to process." -Level WARNING
                return
            }

            $results = [System.Collections.Generic.List[object]]::new()
            $counter = 0

            foreach ($user in $queue) {
                $counter++
                $percent = (($counter / $queue.Count) * 100)
                Write-Progress -Activity "Revoking sessions" -Status "$counter of $($queue.Count) ($($percent.ToString('0.00'))%)" -PercentComplete $percent

                if ($exclusions.Contains($user.UserPrincipalName)) {
                    Write-NCMessage ("Skipping user {0}" -f $user.UserPrincipalName) -Level INFO
                    continue
                }

                if (-not $PSCmdlet.ShouldProcess($user.UserPrincipalName, "Revoke sign-in sessions")) {
                    continue
                }

                try {
                    Revoke-MgUserSignInSession -UserId $user.Id -ErrorAction Stop | Out-Null
                    $results.Add([pscustomobject]@{
                            UserPrincipalName = $user.UserPrincipalName
                            DisplayName       = $user.DisplayName
                            Action            = 'SessionsRevoked'
                        }) | Out-Null
                }
                catch {
                    Write-NCMessage "Failed to revoke sessions for $($user.UserPrincipalName). $($_.Exception.Message)" -Level ERROR
                }
            }

            if ($PassThru.IsPresent) {
                $results
            }
            elseif ($results.Count -gt 0) {
                Write-NCMessage ("Revoked sessions for {0} user(s)." -f $results.Count) -Level SUCCESS
            }
        }
        finally {
            Write-Progress -Activity "Revoking sessions" -Completed
            Restore-ProgressAndInfoPreferences
        }
    }
}