shiftavenue.GraphAutomation.psm1

<#
.SYNOPSIS
    Disables or enables a service principal or user account in Azure AD.
.DESCRIPTION
    Disables or enables a service principal or user account in Azure AD.
.PARAMETER PrincipalId
    The principal id to disable or enable. User can be guid or SPN
.PARAMETER AccountType
    The type of account to disable or enable. Valid values are 'servicePrincipal' or 'user'.
.PARAMETER Enabled
    Whether to enable or disable the account.
.EXAMPLE
    Set-SagaAccountStatus -ServicePrincipalAppId '00000000-0000-0000-0000-000000000000' -AccountType 'servicePrincipal' -Enabled $false
 
    Disables the service principal with the app id '00000000-0000-0000-0000-000000000000'.
#>

function Set-SagaAccountStatus
{
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [string[]]
        $PrincipalId,

        [Parameter()]
        [ValidateSet('servicePrincipal', 'user')]
        [string]
        $AccountType = 'servicePrincipal',

        [Parameter(Mandatory = $true)]
        [bool]
        $Enabled
    )

    $accountEnabledCounter = 1
    $idToSp = @{}

    $disableRequest = foreach ($sp in $PrincipalId)
    {
        @{
            url     = "$($AccountType)s/$($sp)"
            method  = "PATCH"
            id      = $accountEnabledCounter
            body    = @{"accountEnabled" = $Enabled }
            headers = @{
                "Content-Type" = "application/json"
            }
        }
        $idToSp[$accountEnabledCounter] = $sp
        $accountEnabledCounter++
    }

    $responses = Invoke-GraphRequestBatch -Request $disableRequest

    foreach ($response in $responses)
    {
        $sp = $idToSp[$response.id]
        if ($response.status -in 200 - 299)
        {
            Write-PSFMessage -Message "Disabled '$($sp)'"
        }
        else
        {
            Write-PSFMessage -Message "Error disabling '$($sp)': $($response.body.error.message)" -Level Error
        }
    }
}

<#
.SYNOPSIS
    Connects to the Microsoft Graph API.
.DESCRIPTION
    Connects to the Microsoft Graph API with the configured settings, Get-PSFConfig -Module shiftavenue.GraphAutomation.
    If the connection is already established, this function does nothing.
.EXAMPLE
    Connect-SagaGraph
 
    Connects to the Microsoft Graph API with the configured settings.
#>

function Connect-SagaGraph
{
    [CmdletBinding()]
    param ( )

    $clientId = Get-PSFConfigValue -FullName shiftavenue.GraphAutomation.GraphClientId
    $tenantId = Get-PSFConfigValue -FullName shiftavenue.GraphAutomation.GraphTenantId

    if (-not $clientId -or -not $tenantId)
    {
        Stop-PSFFunction -Message 'Please configure GraphClientId and GraphTenantId' -EnableException $true
    }

    $graphMethod = Get-PSFConfigValue -FullName shiftavenue.GraphAutomation.GraphConnectionMode

    $mg = Get-Module -Name MiniGraph
    $alreadyConnected = & $mg { $null -ne $script:token }

    if ($alreadyConnected) { return }

    switch ($graphMethod)
    {
        'DeviceCode'
        {
            Write-PSFMessage -Message 'Using DeviceCode Authentication'
            Connect-GraphDeviceCode -ClientID $ClientId -TenantID $TenantId
        }
        'Certificate'
        {
            Write-PSFMessage -Message 'Using Certificate Authentication'
            $certificate = Get-PSFConfigValue -FullName shiftavenue.GraphAutomation.GraphCertificate
            if (-not $certificate)
            {
                Stop-PSFFunction -Message 'Please configure GraphCertificate or switch to DeviceCode/Browser auth' -EnableException
            }
            Connect-GraphCertificate -Certificate $certificate -ClientID $clientId -TenantID $tenantId
        }
        'Browser'
        {
            Write-PSFMessage -Message 'Using Browser Authentication'
            Connect-GraphBrowser -ClientID $ClientId -TenantID $TenantId
        }
        'ClientSecret'
        {
            Write-PSFMessage -Message 'Using ClientSecret Authentication'
            $clientSecret = Get-PSFConfigValue -FullName shiftavenue.GraphAutomation.GraphClientSecret
            if (-not $clientSecret)
            {
                Stop-PSFFunction -Message 'Please configure GraphClientSecret or switch to DeviceCode/Browser auth' -EnableException
            }

            Connect-GraphClientSecret -ClientID $ClientId -ClientSecret $ClientSecret -TenantID $TenantId
        }
    }

    Set-GraphEndpoint -Type beta
}


<#
.SYNOPSIS
    Disables a service principal or user account in Entra ID.
.DESCRIPTION
    Disables a service principal or user account in Entra ID.
.PARAMETER PrincipalId
    The principal id to enable. User can be guid or SPN
.PARAMETER AccountType
    The type of account to disable. Valid values are 'servicePrincipal' or 'user'.
.PARAMETER WhatIf
    Shows what would happen if the cmdlet runs. The cmdlet is not run.
.PARAMETER Confirm
    Prompts you for confirmation before running the cmdlet.
.EXAMPLE
    Disable-SagaPrincipal -ServicePrincipalAppId '00000000-0000-0000-0000-000000000000' -AccountType 'servicePrincipal'
 
    Disables the service principal with the app id '00000000-0000-0000-0000-000000000000'.
#>

function Disable-SagaPrincipal
{
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param
    (

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

        [Parameter()]
        [ValidateSet('servicePrincipal', 'user')]
        [string]
        $AccountType = 'servicePrincipal'
    )

    Connect-GraphClientSecret -ClientID $ClientId -ClientSecret $ClientSecret -TenantID $TenantId
    Set-GraphEndpoint -Type beta

    if ($PSCmdlet.ShouldProcess("$($PrincipalId.Count) accounts", "Disable"))
    {
        Set-SagaAccountStatus -PrincipalId $PrincipalId -AccountType $AccountType -Enabled $false
    }
}

<#
.SYNOPSIS
    Enables a service principal or user account in Entra ID.
.DESCRIPTION
    Enables a service principal or user account in Entra ID.
.PARAMETER PrincipalId
    The principal id to enable. User can be guid or SPN
.PARAMETER AccountType
    The type of account to enable. Valid values are 'servicePrincipal' or 'user'.
.EXAMPLE
    Enable-SagaPrincipal -ServicePrincipalAppId '00000000-0000-0000-0000-000000000000' -AccountType 'servicePrincipal'
 
    Enables the service principal with the app id '00000000-0000-0000-0000-000000000000'.
.PARAMETER WhatIf
    Shows what would happen if the cmdlet runs. The cmdlet is not run.
.PARAMETER Confirm
    Prompts you for confirmation before running the cmdlet.
#>

function Enable-SagaPrincipal
{
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param
    (
        [Parameter(Mandatory = $true)]
        [string[]]
        $PrincipalId,

        [Parameter()]
        [ValidateSet('servicePrincipal', 'user')]
        [string]
        $AccountType = 'servicePrincipal'
    )

    if ($PSCmdlet.ShouldProcess("$($PrincipalId.Count) accounts", "Disable"))
    {
        Set-SagaAccountStatus -ServicePrincipalAppId $PrincipalId -AccountType $AccountType -Enabled $true
    }
}

<#
.SYNOPSIS
    Exports all service principals and their permissions to an Excel file.
.DESCRIPTION
    Exports all service principals and their permissions to an Excel file.
.PARAMETER ServicePrincipal
    The service principal to export.
.PARAMETER SingleReportPath
    The path to the Excel file to export the data to.
.PARAMETER SummaryReportPath
    The path to the Excel file to export the summary data to.
.EXAMPLE
    Export-SagaAppPermission -SingleReportPath 'C:\temp\GraphAppInventory.xlsx' -SummaryReportPath 'C:\temp\GraphAppInventorySummary.xlsx'
 
    Exports all service principals and their permissions to an Excel file.
#>

function Export-SagaAppPermission
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [object[]]
        $ServicePrincipal,

        [Parameter()]
        [string]
        $SingleReportPath = "$(Get-Date -Format 'yyyy-MM-dd')_GraphAppInventory.xlsx",

        [Parameter()]
        $SummaryReportPath = "Report.xslx"
    )

    begin
    {
        $preparedOutput = [System.Collections.ArrayList]::new()
    }

    process
    {
        foreach ($sp in $ServicePrincipal)
        {
            $null = $preparedOutput.Add([PSCustomObject][ordered]@{
                    "Service Principal Name"             = $SP.displayName
                    "Application Name"                   = $SP.appDisplayName
                    "Publisher"                          = if ($SP.PublisherName) { $SP.PublisherName } else { $null }
                    "Verified"                           = if ($SP.verifiedPublisher.verifiedPublisherId) { $SP.verifiedPublisher.displayName } else { "Not verified" }
                    "Homepage"                           = if ($SP.Homepage) { $SP.Homepage } else { $null }
                    "Created on"                         = if ($SP.createdDateTime) { (Get-Date($SP.createdDateTime) -format g) } else { $null }
                    "ApplicationId"                      = $SP.AppId
                    "ObjectId"                           = $SP.id
                    "AccountEnabled"                     = $SP.AccountEnabled
                    "AccountEnabledDesiredState"         = if ($SP.AccountEnabledDesiredState) { $SP.AccountEnabledDesiredState } else { $false }
                    "Last modified"                      = $sp.lastModified
                    "Permissions (application)"          = $sp.permissionsByApplication
                    "Authorized By (application)"        = $sp.authorizedByApplication
                    "Permissions (delegate)"             = $sp.delegatePermissions
                    "Valid until (delegate)"             = $sp.delegateValidUntil
                    "Authorized By (delegate)"           = $sp.delegateAuthorizedBy
                    "SignIns last $TimeFrameInDays days" = $sp.signInsTimePeriod
                    "Active Users $TimeFrameInDays days" = $sp.activeUsersTimePeriod
                    "Detailed SignIns"                   = $sp.signInsTimePeriodDetail
                    "SignInAudience"                     = $sp.signInAudience
                }
            )
        }
    }

    end
    {
        #Export the result to Excel file
        $output | Export-Excel -Path $SingleReportPath
        $output | Export-Excel -Path $SummaryReportPath -WorksheetName "$((Get-Date).ToString('yyyy-MM-dd_HH-mm-ss'))_GraphAppInv" -TableName "GraphAppInv_$((Get-Date).ToString('yyyy-MM-dd_HH-mm-ss'))"

        # Prep table
        Copy-Item -Path $SummaryReportPath -Destination "$SummaryReportPath.bak" -Force
        Remove-Worksheet -WorksheetName Reporting -Path $SummaryReportPath -ErrorAction SilentlyContinue
        $package = Open-ExcelPackage -Path $SummaryReportPath -KillExcel

        $summaryData = foreach ($worksheet in $package.Workbook.Worksheets.Where({ $_.Name -ne 'Reporting' }))
        {
            $doc = Import-Excel -Path $SummaryReportPath -WorksheetName $worksheet.Name
            [pscustomobject]@{
                Date             = $worksheet.Name
                'NumberSPs'      = $doc.Count
                'NumberDisabled' = $doc.Where({ $_.PSObject.Properties.Name -contains 'Enabled' -and -not $_.Enabled }).Count
                'NumberEnabled'  = $doc.Where({ $_.PSObject.Properties.Name -contains 'Enabled' -and $_.Enabled }).Count
            }
        }

        $chart = New-ExcelChartDefinition -Title "Service principals over time" -ChartType Area -XRange 'Date'  -YRange "NumberSPs", "NumberDisabled" -SeriesHeader "Number SPs", "Number disabled"

        $summaryData | Export-Excel -AutoNameRange -WorksheetName Reporting -MoveToStart -Path $SummaryReportPath -ExcelChartDefinition $chart
    }
}


<#
.SYNOPSIS
    Gets the permissions of all service principals in the tenant.
.DESCRIPTION
    Gets the permissions, OAuth scopes, Signins and delegations of all service principals in the tenant.
.PARAMETER TimeFrameInDays
    The number of days to look back for signins.
.PARAMETER ExcludeBuiltInServicePrincipals
    Whether to exclude built-in service principals.
.PARAMETER ExcludeDisabledApps
    Whether to exclude disabled apps.
.EXAMPLE
    Get-SagaAppPermission -TimeFrameInDays 30 -ExcludeBuiltInServicePrincipals -ExcludeDisabledApps
 
    Gets the permissions, OAuth scopes, Signins and delegations of all service principals in the tenant for the last 30 days.
#>

function Get-SagaAppPermission
{
    [CmdletBinding()]
    param
    (
        [uint16]
        $TimeFrameInDays = 30,

        [bool]
        $ExcludeBuiltInServicePrincipals = $true,

        [bool]
        $ExcludeDisabledApps = $true
    )

    $TimeFrameDate = (Get-Date -format u ((Get-Date).AddDays(-$TimeFrameInDays)).Date).Replace(' ', 'T')

    Connect-SagaGraph

    $servicePrincipals = [System.Collections.ArrayList]::new()

    if ($ExcludeBuiltInServicePrincipals)
    {
        $query = "servicePrincipals?&`$filter=tags/any(t:t eq 'WindowsAzureActiveDirectoryIntegratedApp')"
    }

    $query = if (-not $ExcludeDisabledApps)
    {
        "servicePrincipals?&`$filter=accountEnabled eq true"
    }
    else
    {
        "servicePrincipals"
    }

    $servicePrincipals.AddRange([array](Invoke-GraphRequest -Query $query))


    $araCounter = 1
    $idToSp = @{}
    $appRoleAssignmentsRequest = foreach ($sp in $servicePrincipals)
    {
        @{
            url    = "/servicePrincipals/$($sp.id)/appRoleAssignments"
            method = "GET"
            id     = $araCounter
        }
        $idToSp[$araCounter] = $sp
        $araCounter++
    }

    $responses = Invoke-GraphRequestBatch -Request $appRoleAssignmentsRequest
    foreach ($response in $responses)
    {
        $idToSp[[int]$response.id] | Add-Member -NotePropertyName 'appRoleAssignments' -NotePropertyValue $response.body.value -Force
    }

    $assignedPrincipals = ($servicePrincipals | Where-Object { $_.appRoleAssignments.Count -gt 0 }).appRoleAssignments.resourceId | Select-Object -Unique
    foreach ($principal in $assignedPrincipals)
    {
        if ($servicePrincipals.id -notcontains $principal)
        {
            $null = $servicePrincipals.Add((Invoke-GraphRequest -Query "servicePrincipals/$principal"))
        }
    }

    foreach ($sp in ($servicePrincipals | Where-Object { $_.appRoleAssignments.Count -gt 0 }))
    {
        $sp | Add-Member -NotePropertyName 'lastModified' -NotePropertyValue (Get-Date($sp.appRoleAssignments.CreationTimestamp | Select-Object -Unique | Sort-Object -Descending | Select-Object -First 1) -format g) -Force

        $permissionsByApplication = foreach ($appRoleAssignment in $sp.appRoleAssignments)
        {
            $roleId = (($servicePrincipals | Where-Object id -eq $appRoleAssignment.resourceId).appRoles | Where-Object { $_.id -eq $appRoleAssignment.appRoleId }).Value | Select-Object -Unique
            if (!$roleID) { $roleId = "Orphaned ($($appRoleAssignment.appRoleId))" }

            "[$($appRoleAssignment.ResourceDisplayName)]:$($roleId -join ',')"
        }

        $sp | Add-Member -NotePropertyName 'permissionsByApplication' -NotePropertyValue ($permissionsByApplication -join ';') -Force
        $sp | Add-Member -NotePropertyName authorizedByApplication -NotePropertyValue 'An administrator (application persmissions)' -Force
    }

    $oauthCounter = 1
    $idToSp = @{}
    $oauth2PermissionGrantsRequest = foreach ($sp in $servicePrincipals)
    {
        @{
            url    = "/servicePrincipals/$($sp.id)/oauth2PermissionGrants"
            method = "GET"
            id     = $oauthCounter
        }
        $idToSp[$oauthCounter] = $sp
        $oauthCounter++
    }

    $responses = Invoke-GraphRequestBatch -Request $oauth2PermissionGrantsRequest
    foreach ($response in $responses)
    {
        $idToSp[[int]$response.id] | Add-Member -NotePropertyName 'oauth2PermissionGrants' -NotePropertyValue $response.body.value -Force
    }

    $users = [System.Collections.ArrayList]::new()
    $grantedPrincipals = $servicePrincipals | Where-Object { $_.oauth2PermissionGrants.Count -gt 0 } | ForEach-Object { $_.oauth2PermissionGrants.principalId } | Select-Object -Unique

    $userCounter = 1
    $idToSp = @{}
    $grantedUsersRequest = foreach ($principal in $grantedPrincipals)
    {
        if ($users.id -notcontains $principal)
        {
            @{
                url    = "/users/$($principal)?`$select=UserPrincipalName"
                method = "GET"
                id     = $userCounter
            }
            $idToSp[$userCounter] = $principal
            $userCounter++
        }
    }

    if ($grantedUsersRequest)
    {
        $responses = Invoke-GraphRequestBatch -Request $grantedUsersRequest

        $users = @{}
        foreach ($response in $responses)
        {
            $users[$idToSp[[int]$response.id]] = $response.body.UserPrincipalName
        }
    }

    foreach ($sp in ($ServicePrincipals | Where-Object { $_.oauth2PermissionGrants.Count -gt 0 }))
    {
        $perms = foreach ($oauth2PermissionGrant in $sp.oauth2PermissionGrants)
        {
            $resID = ($servicePrincipals | Where-Object id -eq $appRoleAssignment.resourceId).appDisplayName
            if ($null -ne $oauth2PermissionGrant.PrincipalId)
            {
                $userId = "($($users[$oauth2PermissionGrant.principalId]))"
            }
            else { $userId = $null }
            "[" + $resID + $userId + "]:$($oauth2PermissionGrant.Scope.TrimStart().Split(' ') -join ',')"
        }

        $sp | Add-Member -NotePropertyName delegatePermissions -NotePropertyValue $perms -Force
        $sp | Add-Member -NotePropertyName delegateValidUntil -NotePropertyValue (Get-Date($sp.oauth2PermissionGrants.ExpiryTime | Select-Object -Unique | Sort-Object -Descending | Select-Object -First 1) -format g) -Force

        $assignedTo = @()
        if (($sp.oauth2PermissionGrants.ConsentType | Select-Object -Unique) -eq "AllPrincipals") { $assignedto += "All users (admin consent)" }
        $assignedto += $perms | ForEach-Object { if ($_ -match "\((.*@.*)\)") { $Matches[1] } }

        $sp | Add-Member -NotePropertyName delegateAuthorizedBy -NotePropertyValue $(($assignedto | Select-Object -Unique) -join ",") -Force
    }

    $accountEnabledCounter = 1
    $idToSp = @{}
    $signInRequest = foreach ($sp in ($servicePrincipals | Where-Object AccountEnabled))
    {
        @{
            url    = "/auditLogs/signIns?`$filter=(CreatedDateTime ge $TimeFrameDate) and (appid eq '$($sp.appId)') and signInEventTypes/any(t: t eq 'interactiveUser' or t eq 'nonInteractiveUser')"
            method = "GET"
            id     = $accountEnabledCounter
        }
        $idToSp[$accountEnabledCounter] = $sp
        $accountEnabledCounter++
    }

    $responses = Invoke-GraphRequestBatch -Request $signInRequest

    foreach ($response in $responses)
    {
        $signinsCurrent = $response.body.value
        $idToSp[[int]$response.id] | Add-Member -NotePropertyName 'signInsTimePeriod' -NotePropertyValue $signinsCurrent.Count -Force
        $idToSp[[int]$response.id] | Add-Member -NotePropertyName 'activeUsersTimePeriod' -NotePropertyValue ($signinsCurrent | Group-Object userPrincipalName).Name.Count -Force

        if (($signinsCurrent | Group-Object userPrincipalName).Name.Count -gt 0)
        {
            $detailed = -join ($signinsCurrent | Group-Object userPrincipalName | Sort-Object Count -Descending | ForEach-Object { "$($_.Name) - $($_.Count) | " })
            $idToSp[[int]$response.id] | Add-Member -NotePropertyName 'signInsTimePeriodDetail' -NotePropertyValue $detailed -Force
            $idToSp[[int]$response.id] | Add-Member -NotePropertyName 'AccountEnabledDesiredState' -NotePropertyValue $true -Force
        }
        else
        {
            $idToSp[[int]$response.id] | Add-Member -NotePropertyName 'AccountEnabledDesiredState' -NotePropertyValue $false -Force
        }
    }
}

<#
.SYNOPSIS
    Gets the last signin time for all users in the Saga tenant.
.DESCRIPTION
    Gets the last signin time for all users in the Saga tenant.
.EXAMPLE
    Get-SagaUserLastSignIn
 
    Gets the last signin time for all users in the Saga tenant.
#>

function Get-SagaUserLastSignIn
{
    [CmdletBinding()]
    param
    ( )

    Connect-SagaGraph
    Invoke-GraphRequest -Query "users?`$filter=accountEnabled eq true&`$select=UserPrincipalName,id,signInActivity"
}


<#
.SYNOPSIS
    Get the signin methods and license status for all enabled users in the tenant.
.DESCRIPTION
    Get the signin methods and license status for all enabled users in the tenant.
.EXAMPLE
    Get-SagaUserSigninAndLicenseStatus
 
    Get the signin methods and license status for all users in the tenant.
#>

function Get-SagaUserSigninAndLicenseStatus
{
    [CmdletBinding()]
    param
    ( )

    Connect-SagaGraph

    [System.Collections.Arraylist]$users = Invoke-GraphRequest -Query "users?`$filter=accountEnabled eq true&`$select=UserPrincipalName,id"

    $batchCounter = 1
    $idToUser = @{}
    $requests = foreach ($user in $users)
    {
        @{
            url    = "/users/$($user.id)/authentication/methods"
            method = "GET"
            id     = $batchCounter
        }
        $idToUser[$batchCounter] = $user
        $batchCounter++
    }

    $methods = Invoke-GraphRequestBatch -Request $requests

    foreach ($method in $methods)
    {
        [string[]] $formatedMethod = foreach ($authMethod in $method.body.value)
        {
            if ($authMethod.psobject.properties.name -contains 'createdDateTime' -and -not $authMethod.createdDateTime) { $authMethod.createdDateTime = [datetime]::MinValue }
            switch -Regex ($authMethod.'@odata.type')
            {
                'phoneAuthentication' { '{0}_{1}_{2}_SMSSigninEnabled_{3}' -f 'PhoneAuthentication', $authMethod.phoneNumber, $authMethod.phoneType, $authMethod.smsSignInState }
                'microsoftAuthenticatorAuthentication' { '{0}_{1}_{2}_{3}' -f 'AuthenticatorApp', $authMethod.displayName, $authMethod.deviceTag, $authMethod.phoneAppVersion }
                'fido2Authentication' { '{0}_{1:yyyyMMdd}_{2}_{3}' -f 'FIDO2', , $authMethod.createdDateTime, $authMethod.attestationLevel, $authMethod.model }
                'windowsHelloForBusinessAuthentication' { '{0}_{1:yyyyMMdd}_{2}_strength_{3}' -f 'Hello', , $authMethod.createdDateTime, $authMethod.displayName, $authMethod.keyStrength }
                'passwordAuthentication' { '{0}_{1:yyyyMMdd-HHmmss}' -f 'Password', $_.createdDateTime }
                'emailAuthentication' { $authMethod.emailAddress }
            }
        }

        $idToUser[[int]$method.id] | Add-Member -MemberType NoteProperty -Name AuthenticationMethods -Value ($formatedMethod -join '#') -Force
    }

    $batchCounter = 1
    $idToUser = @{}
    $requests = foreach ($user in $users)
    {
        @{
            url    = "/users/$($user.id)/authentication/signInPreferences"
            method = "GET"
            id     = $batchCounter
        }
        $idToUser[$batchCounter] = $user
        $batchCounter++
    }

    $preferences = Invoke-GraphRequestBatch -Request $requests

    foreach ($preference in $preferences)
    {
        $idToUser[[int]$preference.id] | Add-Member -MemberType NoteProperty -Name SystemPreferredAuthenticationMethodEnabled -Value $preference.body.isSystemPreferredAuthenticationMethodEnabled
        $idToUser[[int]$preference.id] | Add-Member -MemberType NoteProperty -Name UserPreferredMethodForSecondaryAuthentication -Value $preference.body.userPreferredMethodForSecondaryAuthentication
        $idToUser[[int]$preference.id] | Add-Member -MemberType NoteProperty -Name SystemPreferredAuthenticationMethod -Value $preference.body.systemPreferredAuthenticationMethod
    }

    $batchCounter = 1
    $idToUser = @{}
    $requests = foreach ($user in $users)
    {
        @{
            url    = "/users/$($user.id)/licenseDetails?`$select=skuPartNumber,servicePlans"
            method = "GET"
            id     = $batchCounter
        }
        $idToUser[$batchCounter] = $user
        $batchCounter++
    }

    $licenses = Invoke-GraphRequestBatch -Request $requests

    foreach ($license in $licenses)
    {
        $idToUser[[int]$license.id] | Add-Member -MemberType NoteProperty -Name License -Value ($license.body.value.skuPartNumber)
        $idToUser[[int]$license.id] | Add-Member -MemberType NoteProperty -Name ServicePlans -Value (($license.body.value.servicePlans | Where-Object { $_.provisioningStatus -eq 'Success' -and $_.AppliesTo -eq 'User' }).servicePlanName)
    }

    $idToUser.Values
}


<#
.SYNOPSIS
    Import attributes to users in Entra ID
.DESCRIPTION
    Import attributes to users in Entra ID. Bulk import can be done using CSV or JSON. All
    objects must have the property UserPrincipalName as either the actual UPN or the object GUID of
    each user. All other properties will be used as attributes which are updated.
    There is no schema validation against available Entra attributes!
.EXAMPLE
    Import-SagaEntraAttribute -CsvPath 'C:\temp\users.csv'
 
    Import attributes to users in Entra ID from a CSV file.
.PARAMETER WhatIf
    Shows what would happen if the cmdlet runs. The cmdlet is not run.
.PARAMETER Confirm
    Prompts you for confirmation before running the cmdlet.
#>

function Import-SagaEntraAttribute
{
    [CmdletBinding(DefaultParameterSetName = 'Json', SupportsShouldProcess, ConfirmImpact = 'High')]
    param
    (
        # Column UserPrincipalName, Attribute1Name, Attribute2Name, ...
        [Parameter(ParameterSetName = 'Csv')]
        [string]
        $CsvPath,

        <# Array of objects with UserPrincipalName, Attribute1Name, Attribute2Name, ...
        [
            {
                "UserPrincipalName" : "john@contoso.com",
                "Attribute1Name" : "Attribute1Value",
                "Attribute2Name" : "Attribute2Value"
            },
            {
                "UserPrincipalName" : "sally@contoso.com",
                "Attribute1Name" : "Attribute1Value",
                "Attribute2Name" : "Attribute2Value"
            }
        ]
        #>

        [Parameter(ParameterSetName = 'Json')]
        [string]
        $JsonPath
    )

    Connect-SagaGraph

    $bulkData = if ($PSCmdlet.ParameterSetName -eq 'Json')
    {
        Get-Content -Path $JsonPath | ConvertFrom-Json
    }
    else
    {
        Import-Csv -Path $CsvPath
    }

    if (-not $PSCmdlet.ShouldProcess("$($bulkData.Count) users", "Update")) { return }

    $batchCounter = 1
    $idToUser = @{}

    [hashtable[]] $requests = foreach ($item in $bulkData)
    {
        $body = @{}

        foreach ($property in $item.PsObject.Properties.Where({ $_.Name -ne 'UserPrincipalName' }))
        {
            $body[$property.Name] = $property.Value
        }

        @{
            url     = "/users/$($item.UserPrincipalName)"
            method  = "PATCH"
            id      = $batchCounter
            body    = $body
            headers = @{
                "Content-Type" = "application/json"
            }
        }
        $idToUser[$batchCounter] = $item
        $batchCounter++
    }

    $userUpdate = Invoke-GraphRequestBatch -Request $requests

    foreach ($userUpdate in ($userUpdate | Where-Object { $_.status -in 200..299 }))
    {
        $user = $idToUser[[int]$userUpdate.Id]
        Write-PSFMessage -Message "Updated $($user.UserPrincipalName)"
    }

    foreach ($userUpdate in ($userUpdate | Where-Object { $_.status -notin 200..299 }))
    {
        $user = $idToUser[[int]$userUpdate.Id]
        Write-PSFMessage -Message "Failed to update $($user.UserPrincipalName) with $($userUpdate.status)"
    }
}


Register-PSFConfigValidation -Name "x509certificate" -ScriptBlock {
    Param (
        $Value
    )
    
    $Result = [PSCustomObject]@{
        Success = $True
        Value   = $null
        Message = ""
    }
    
    try { [System.Security.Cryptography.X509Certificates.X509Certificate2]$certificate = $Value }
    catch
    {
        $Result.Message = "Not an X509Certificate2: $Value"
        $Result.Success = $False
        return $Result
    }
    
    $Result.Value = $certificate
    
    return $Result
}

Register-PSFConfigValidation -Name "guid" -ScriptBlock {
    Param (
        $Value
    )
    
    $Result = [PSCustomObject]@{
        Success = $True
        Value   = $null
        Message = ""
    }
    
    try { [guid]$guid = $Value }
    catch
    {
        $Result.Message = "Not a GUID: $Value"
        $Result.Success = $False
        return $Result
    }
    
    $Result.Value = $guid
    
    return $Result
}


Set-PSFConfig -Module shiftavenue.GraphAutomation -Name GraphConnectionMode -Value DeviceCode -Validation string -Description 'DeviceCode, Browser, Certificate, or ClientSecret' -Default
Set-PSFConfig -Module shiftavenue.GraphAutomation -Name GraphClientId -Value '' -Validation guid -Description 'Client ID for Graph API' -Default
Set-PSFConfig -Module shiftavenue.GraphAutomation -Name GraphTenantId -Value '' -Validation guid -Description 'Tenant ID for Graph API' -Default
Set-PSFConfig -Module shiftavenue.GraphAutomation -Name GraphClientSecret -Value '' -Validation secret -Description 'Client Secret for Graph API' -Default
Set-PSFConfig -Module shiftavenue.GraphAutomation -Name GraphCertificate -Value '' -Validation x509certificate -Description 'Certificate for Graph API' -Default
Set-PSFConfig -Module shiftavenue.GraphAutomation -Name Logpath -Value (Join-Path -Path $HOME -ChildPath '.saga/logs') -Validation string -Default -Description 'Path to CMtrace log files'


$paramSetPSFLoggingProvider = @{
    Name         = 'logfile'
    InstanceName = '<saga>'
    FilePath     = Join-Path -Path (Get-PSFConfigValue -FullName shiftavenue.GraphAutomation.LogPath -Fallback "$home") -ChildPath 'Saga-%Date%.log'
    FileType     = 'CMTrace'
    Enabled      = $true
}

Set-PSFLoggingProvider @paramSetPSFLoggingProvider