IDandCollabTools.psm1

<#
    .SYNOPSIS
    A module containing helper functions for common tasks involving Azure AD and M365
#>


function Get-MSALAuthHeader {
    
    <#
        .SYNOPSIS
        Helper function to generate and return on MS Graph auth header using MSAL.PS
 
        .DESCRIPTION
        This scrip uses the MSAL.PS module to get a Bearer token for the MS Graph API.
        It uses the combination of tenant ID, client (app) id and certificate thumbprint to do this.
        As such an existign App Registration is required in Azure AD, and a certificate linked ot that
        App Registration whose private key is installed in the user's Personal Certififcate Store.
 
        The resulting token will have the API permissions assigned to the service principal
        (i.e. the App Registration), so ensure that App Reg has the require permissions for the task.
        Requires the module MSAL.PS
         
        .PARAMETER tenantID
        The tenant ID or DNS name of the tenant to target
 
        .PARAMETER clientID
        The ID of the application to use
 
        .PARAMETER thumbprint
        The thumbprint of the certificate associated with the application
        This certificate must be installed in the user's Personal >> Certificates store on the
        computer running the script
 
        .OUTPUTS
        An authenticaiton header in hashtable format, containing the Bearer token
    #>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [string]
        $tenantID,

        [Parameter(Mandatory=$true)]
        [string]
        $clientID,

        [Parameter(Mandatory=$true)]
        [string]
        $thumbprint
    )
    
    # Set path to certificate
    $path = "Cert:\CurrentUser\My\" + $thumbprint
    
    # Set up token request
    $connectionDetails = @{
        'TenantId'          = $tenantID
        'ClientId'          = $clientID
        'ClientCertificate' = Get-Item -Path $path
    }

    $token = Get-MsalToken @connectionDetails

    # prepare auth header for main query
    $MSALAuthHeader = @{
        'Authorization' = $token.CreateAuthorizationHeader()
    }

    return $MSALAuthHeader
}

function Connect-MgGraphAsMsi {

    <#
        .SYNOPSIS
        Get a Bearer token for MS Graph for a Managed Identity and connect to MS Graph.
 
        .DESCRIPTION
        Intended for use in Azure Automation Runbooks.
        Use the Get-AzAccessToken cmdlet to acquire a Bearer token for the MSI running the
        script/Runbook, then runs Connect-MgGraph using that token to connect the Managed
        Identity to MS Graph via the PowerShell SDK.
 
        Optinally, returns the acquired token.
 
        .NOTES
        When returning the token for use with Connect-MgGraph, you will need to convert the
        token property to a SecureString object
 
        .EXAMPLE
        'Connect-MgGraphAsMsi' -tenantID $tenantID
        Connects the Managed Service Identity running the scripot/Runbook to Azure and MgGraph
 
        .EXAMPLE
        '$MgBearerToken = Connect-MgGraphAsMsi -tenantID $tenantID -ReturnAccessToken -Verbose
        $authHeader = @{
            'Authorization' = "Bearer $($MgBearerToken.token)"
        }'
        1. Connects to Azure using Connect-Az.Account and the MS Graph using Connect-MgGraph,
        returning the token object. Produces messages for each executed step.
        2. Generates an auth header with that token, for e.g. use in direct calls to the MS
        Graph API via Invoke-Webrequest or Invoke-restMethod
 
        .PARAMETER ReturnAccessToken
        Switch - if present, function will return the BearerToken
 
        .PARAMETER tenantID
        the tenant on which to perform the action, used only when debugging
 
        .PARAMETER subscriptionID
        the subscription in which to perform the action, used only when debugging
 
        .OUTPUTS
        Microsoft.Azure.Commands.Profile.Models.PSAccessToken
    #>


    [CmdletBinding()]
    param (

        [Parameter (Mandatory = $False)]
        [Switch] $ReturnAccessToken,

        [Parameter (Mandatory=$False)]
        [string] $tenantID,

        [Parameter (Mandatory=$False)]
        [string] $subscriptionID

    )

    # Connect to Azure as the MSI
    $AzContext = Get-AzContext
    if (-not $AzContext) {
        Write-Verbose "Connect-MsgraphAsMsi: No existing connection, creating fresh connection"
        Connect-AzAccount -Identity
    }
    else {
        Write-Verbose "Connect-MsgraphAsMsi: Existing AzContext found, creating fresh connection"
        Disconnect-AzAccount | Out-Null
        Connect-AzAccount -Identity
        Write-Verbose "Connect-MsgraphAsMsi: Connected to Azure as Managed Identity"
    }

    # Get a Bearer token
    $BearerToken = Get-AzAccessToken -ResourceUrl 'https://graph.microsoft.com/'  -TenantId $tenantID
    # Check that it worked
    $TokenExpires = $BearerToken | Select-Object -ExpandProperty ExpiresOn | Select-Object -ExpandProperty DateTime
    Write-Verbose "Bearer Token acquired: expires at $TokenExpires"

    # Convert the token to a SecureString
    $SecureToken = $BearerToken.Token | ConvertTo-SecureString -AsPlainText -Force

    # check for and close any existing MgGraph connections then create fresh connection
    $MgContext = Get-MgContext
    if (-not $MgContext) {
        Write-Verbose "Connect-MsgraphAsMsi: No existing MgContext found, connecting"
        Connect-MgGraph -AccessToken $SecureToken
    } else {
        Write-Verbose "Connect-MsgraphAsMsi: MgContext exists for account $($MgContext.Account) - creating fresh connection"
        Disconnect-MgGraph | Out-Null
        # Use the SecureString type for connection to MS Graph
        Connect-MgGraph -AccessToken $SecureToken
        Write-Verbose "Connect-MsgraphAsMsi: Connected to MgGraph using token generated by Azure"
    }

    # Check that it worked
    $currentPermissions = Get-MgContext | Select-Object -ExpandProperty Scopes
    Write-Verbose "Access scopes acquired for MgGraph are $currentPermissions"

    if ($ReturnAccessToken.IsPresent) {
        return $BearerToken
    }

}

function Get-AadUsersLastSignin {

    <#
        .SYNOPSIS
        Gets the Last Signin Date and Time for all users in an AzureAd
 
        .DESCRIPTION
        Using native calls to the MS Graph REST API using Invoke-Webrequest,
        this funciton gets all Azure AD users, some of their properties, and
        the Last Signin Date and time for the account.
 
        The LastSigninDateTime property is returned as a DateTime type if present,
        or a string "NONE" if it is empty (i.e. account has never signed in).
 
        Requires a properly-formatted header for auth - see parameters for more
        details.
 
        These properties are returned:
        UserPrincipalName
        DisplayName
        EmployeeID
        Email
        Office
        City
        Country
        AccountEnabled
        AccountType
        UsageLocation
        LastSignInDateTime
 
        .EXAMPLE
        $LatestSigninData = Get-AadUsersLastSignin -MgGraphHeader $authHeader
        Connects to MS Graph using Invoke-WebRequest, collects data, and stores it
        in the object $LatestSigninData for further processing.
 
        .EXAMPLE
        $LatestSigninData = Get-AadUsersLastSignin -MgGraphHeader $authHeader
        $LatestSigninData | Export-Csv -Path "$((Get-Date).ToString('yyyy-MM-dd'))_LastLoginDate.csv" -NoTypeInformation
        Connects to MS Graph using Invoke-WebRequest, collects data, and stores it
        in the object $LatestSigninData.
        Exports the collected data to a CSV named "current date + LastLoginDate.csv"
 
        .PARAMETER MgGraphHeader
        An auth header containing a bearer token as Authorization header.
        Can be generated using the object returned by Get-MsalToken in the
        module MSAL.PS, using an App Reg and certificate thumbprint, or the
        function Connect-MgGraphAsMsi in the IDandCollabTools module.
 
        .OUTPUTS
        A collection containing the collected user data.
    #>


    [CmdletBinding()]
    param (
        [Parameter (Mandatory=$True)]
        $MgGraphHeader
)

    # Get the first page
    $currentpage = Invoke-WebRequest -Headers $MgGraphHeader -Method GET -Uri "https://graph.microsoft.com/beta/users?`$select=displayName,userPrincipalName,mail,accountEnabled,employeeId,otherMails,usageLocation,officeLocation,city,country,userType,signInActivity" -UseBasicParsing 
    # Exctract the nextlink if it is there
    $nextpage = ($currentpage.Content | ConvertFrom-Json) | Select-Object -ExpandProperty '@odata.nextlink' -ErrorAction SilentlyContinue
    # Store the first page of results
    $results = ($currentpage.Content | ConvertFrom-Json).Value
    # Get subsequent pages and nextlinks if they exist
    while ($nextpage) {
        $currentpage = Invoke-WebRequest -Headers $MgGraphHeader -Uri $nextpage -UseBasicParsing
        $nextpage = ($currentpage.Content | ConvertFrom-Json) | Select-Object -ExpandProperty '@odata.nextlink' -ErrorAction SilentlyContinue
        $results += ($currentpage.Content | ConvertFrom-Json).Value
    }

    $LastLoginData = @()
    # Process the query results so they can be output to file
    $results | ForEach-Object {

        $record = $_
        $signInActivity = $record.SignInActivity
        # convert any string-form date-times into DateTime objects
        Clear-Variable LastLogon -ErrorAction SilentlyContinue
        if ($signInActivity.LastSignInDateTime) { 
            $LastLogon = [Datetime]$signInActivity.LastSignInDateTime 
        } else { 
            $LastLogon = "NONE" 
        }
        # custom object to store combined output from different objects
        $userData = [pscustomobject] @{
            "UserPrincipalName" = $record.userPrincipalName
            "DisplayName" = $record.DisplayName
            "EmployeeID" = $record.employeeId
            "Email" = $record.mail
            "Office" = $record.officeLocation
            "City" = $record.city
            "Country" = $record.country
            "AccountEnabled" = $record.accountEnabled
            "AccountType" = $record.userType
            "UsageLocation" = $record.usageLocation
            "LastSignInDateTime" =  $LastLogon

        }
        $LastLoginData+=$userData   
    }

    return $LastLoginData
}

function Convert-AzureAdSidToObjectId {
    <#
    .SYNOPSIS
    Convert a Azure AD SID to Object ID
      
    .DESCRIPTION
    Converts an Azure AD SID to Object ID.
    Author: Oliver Kieselbach (oliverkieselbach.com)
    The script is provided "AS IS" with no warranties.
      
    .PARAMETER Sid
    The SID to convert
    #>

    
    [CmdletBinding()]
    param([String] $Sid)

    $text = $sid.Replace('S-1-12-1-', '')
    $array = [UInt32[]]$text.Split('-')

    $bytes = New-Object 'Byte[]' 16
    [Buffer]::BlockCopy($array, 0, $bytes, 0, 16)
    [Guid]$guid = $bytes

    return $guid
}

function Convert-AzureAdObjectIdToSid {
    <#
    .SYNOPSIS
    Convert an Azure AD Object ID to SID
      
    .DESCRIPTION
    Converts an Azure AD Object ID to a SID.
    Author: Oliver Kieselbach (oliverkieselbach.com)
    The script is provided "AS IS" with no warranties.
      
    .PARAMETER ObjectID
    The Object ID to convert
    #>


    [CmdletBinding()]
    param([String] $ObjectId)

    $bytes = [Guid]::Parse($ObjectId).ToByteArray()
    $array = New-Object 'UInt32[]' 4

    [Buffer]::BlockCopy($bytes, 0, $array, 0, 16)
    $sid = "S-1-12-1-$array".Replace(' ', '-')

    return $sid
}

function Get-LocalAdmins {

    <#
        .SYNOPSIS
        This code compensates for MSFT's laziness in fixing a flagship product.
        Issue here: https://github.com/PowerShell/PowerShell/issues/2996
        Credits:
        @ganlbarone on GitHub for the base code
        @ConfigMgrRSC on Github for the localisation supplement
 
        .DESCRIPTION
        The script uses ADSI to fetch all members of the local Administrators group.
        MSFT are aware of this issue, but have closed it without a fix.
        It will output the SID of AzureAD objects such as roles, groups and users,
        and any others which cnanot be resolved.
        the AAD principals' SIDS need to be mapped to identities using MS Graph.
 
        Designed to run from the Intune MDM and thus accepts no parameters.
 
        .EXAMPLE
        $results = Get-localAdmins
        $results
 
        The above will store the output of the function in the $results variable, and
        output the results to console
 
        .OUTPUTS
        System.Management.Automation.PSCustomObject
        Name MemberType Definition
        ---- ---------- ----------
        Equals Method bool Equals(System.Object obj)
        GetHashCode Method int GetHashCode()
        GetType Method type GetType()
        ToString Method string ToString()
        Computer NoteProperty string Computer=Workstation1
        Domain NoteProperty System.String Domain=Contoso
        User NoteProperty System.String User=Administrator
    #>


    [CmdletBinding()]

    $GroupSID='S-1-5-32-544'
    [string]$Groupname = (get-localgroup -SID $GroupSID)[0].Name
    
    $group = [ADSI]"WinNT://$env:COMPUTERNAME/$Groupname"
        $admins = $group.Invoke('Members') | ForEach-Object {
            $path = ([adsi]$_).path
            $memberSID = $(Split-Path $path -Leaf)
            $AadObjectID = Convert-AzureAdSidToObjectId -Sid $memberSID -ErrorAction SilentlyContinue
            [pscustomobject]@{
                Computer = $env:COMPUTERNAME
                Domain = $(Split-Path (Split-Path $path) -Leaf)
                User = $(Split-Path $path -Leaf)
                ObjectID = $AadObjectID
            }
        }
    return $admins
    
}

function Set-MSDfEDeviceTag {

    <#
         
        .SYNOPSIS
        Sets a registry value (ItemProperty) to a hard-coded string passed in the parameter '-Tag'
 
        .DESCRIPTION
        Sets a registry value (ItemProperty) to a hard-coded string passed in the parameter '-Tag'
        The registry value is used by Microsoft Defender for Endpoint (MSDfE) in the Defender console for filtering
 
        It will determine if the rewquired key exists, and if not create it then set the desired ItemProperty and value
        In case the key exists but the ItemProperty does not, it will be created and set to '$value'
        If the ItemProperty exists but contains an incorrect value, the value will be set to '$value'
 
        .PARAMETER tag
        The tag to be set on the device.
        Should be the same as the Device group targeted by the script for consistency purposes
 
    #>


    [CmdletBinding()]
    param (

        [Parameter(Mandatory=$true)]
        [string]
        $Tag

    )

    # Define the registry path, property name and desired value
    $TagPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows Advanced Threat Protection\DeviceTagging\"
    $Name = "Group"
    # Ensure $value is correct before deployment!!!
    $value = $Tag

    # If the key does not exist, create it, then add the property and value
    If (!(Test-Path $TagPath)) {
        New-Item -Path $TagPath -Force | Out-Null
        New-ItemProperty -Path $TagPath -Name $name -Value $value -PropertyType String -Force | Out-Null
    }

    # If the registry value does not exist but the key does, create the value and add the correct data
    elseif ((Test-Path $TagPath) -and (!(Get-ItemProperty -Path $TagPath -Name $Name -ErrorAction SilentlyContinue))) {
        New-ItemProperty -Path $TagPath -Name $name -Value $value -PropertyType String -Force | Out-Null
    }

    # If the registry key AND value exist, but the value does not contain the required data, edit it
    elseif ((Test-Path $TagPath) -and (Get-ItemProperty -Path $TagPath -Name $Name -ErrorAction SilentlyContinue)) {
        $currentPropertyValue = (Get-ItemProperty -Path $TagPath -Name $Name).$Name
        if ($currentPropertyValue -ne $value) {
            Set-ItemProperty -Path $TagPath -Name $Name -Value $value -Force | Out-Null
        }
    }
}

function Get-RecursiveMgDirectReports {

    <#
        .SYNOPSIS
        Gets an employee reporting hierarchy using the Microsoft.Graph PowerShell cmdlets
 
        .DESCRIPTION
        Uses the Get-MgUsersDirectReport cmdlet to recursively get the details of all people who report
        to a given Manager.
 
        Manager is specified using UPN or objectID using the named parameter.
 
        Use Connect-MgGraph first to establish a connection with the required MS graph API permissions.
        Specific properties are returned in a colleciton of PSObjects for export to CSV or onward processing.
 
        Properties returned are:
        AccountEnabled
        User DisplayName
        EmployeeID
        User UPN
        User Email
        Manager
 
        .PARAMETER Manager
        REQUIIRED: the UPN of the initial manager
    #>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]
        $Manager
    )

    $directReports = Get-MgUserDirectReport -UserId $Manager | ForEach-Object {
        Get-MgUser -UserId $_.Id -Property AccountEnabled, DisplayName, EmployeeId, Mail, Manager, UserPrincipalName | Where-Object {$null -ne $_.EmployeeId}
    }

    $Output = foreach ($User in $directReports){
        
        [pscustomobject]@{
            Enabled = $User.AccountEnabled
            UserDisplayName = $User.DisplayName
            EmpID = $user.EmployeeId
            UserUPN = $User.UserPrincipalName
            UserMail = $User.Mail
            Manager = $Manager
        }

        # recurse through each looking for sub-reports
        GetReportingLine -Manager $User.UserPrincipalName
    }

    return $Output
}

Export-ModuleMember -Function 'Get-MSALAuthHeader', 'Connect-MgGraphAsMsi', 'Get-AadUsersLastSignin', 'Convert-AzureAdSidToObjectId', 'Convert-AzureAdObjectIdToSid', 'Get-LocalAdmins', 'Set-MSDfEDeviceTag', 'Get-RecursiveMgDirectReports'