PSAzureSecurityAssessment.psm1


#region Invoke-AzResourceGraphCheck
function Invoke-AzResourceGraphCheck {
    [CmdletBinding()]
    param (
        $CheckList,
        $CheckListId,
        $DebugMode = $false
    )
    $Query = $CheckList | Where-Object { $_.guid -eq $CheckListId } | Select-Object -ExpandProperty graph

    if ($DebugMode) {
        Write-Host "Running query '$Query'`n"
    }

    Search-AzGraph $Query | Select-Object -ExpandProperty Data | Where-Object { $_.compliant -eq 0 } | Select-Object -ExpandProperty id
}
#endregion

#region Get-GroupMember
function Get-GroupMember {
    [CmdletBinding()]
    param (
        [array]$DirectoryObjectByIds
    )
    $params = @{
        ids   = $DirectoryObjectByIds
        types = @(
            "user"
            "group"
        )
    }

    if ($null -eq $DirectoryObjectByIds -or $DirectoryObjectByIds.Count -eq 0) {
        return $()
    }

    $DirectoryObject = Get-MgDirectoryObjectById -BodyParameter $params

    $DirectoryObject | ForEach-Object {
        $ObjectType = $_.AdditionalProperties.'@odata.type'
        if ($ObjectType -eq '#microsoft.graph.group') {
            Get-GroupMember -DirectoryObjectByIds (Get-MgGroupMember -GroupId $_.Id | Select-Object -ExpandProperty Id)
        }
        if ($ObjectType -eq '#microsoft.graph.user') {
            $_
        }
    }
}
#endregion

#region Get-EntraIdRoleAssignment
function Get-EntraIdRoleAssignment {
    [CmdletBinding()]
    param (
        [string]$RoleName = $null,
        [switch]$ExcludeEligebleRoles
    )

    if ([string]::IsNullOrEmpty($RoleName)) {
        throw "Must supply a rolename"
    }

    [array]$Assigned = @()
    [array]$Eligeble = @()

    # Get the role definition id
    $DirectoryRoleDefinitionId = Get-MgBetaRoleManagementDirectoryRoleDefinition -Filter "DisplayName eq '$RoleName'" -Property "id" | Select-Object -ExpandProperty Id

    # only get those that are assigned
    $Filter = "roleDefinitionId eq '$DirectoryRoleDefinitionId' and AssignmentType eq 'Assigned'"
    [array]$AssignedPrincipals = Get-MgBetaRoleManagementDirectoryRoleAssignmentScheduleInstance -Filter $Filter | Select-Object -ExpandProperty PrincipalId
    Write-Verbose "Found $($AssignedPrincipals.Count) assigned principals"
    # recursively get group members
    $Assigned = Get-GroupMember -DirectoryObjectByIds $AssignedPrincipals | Sort-Object -Unique -Property Id
    Write-Verbose "Found $($Assigned.Count) assigned user principals"
    if (-not $ExcludeEligebleRoles.IsPresent) {
        $Filter = "roleDefinitionId eq '$DirectoryRoleDefinitionId'"
        # get principals that are eligble for the role
        [array]$EligeblePrincipals = Get-MgBetaRoleManagementDirectoryRoleEligibilityScheduleInstance -Filter $Filter | Select-Object -ExpandProperty PrincipalId
        Write-Verbose "Found $($EligeblePrincipals.Count) eligeble principals"
        # recursively get group members
        [array]$Eligeble = Get-GroupMember -DirectoryObjectByIds $EligeblePrincipals
        Write-Verbose "Found $($Assigned.Count) eligeble user principals"
    }
    # sort unique to remove duplicates (eligble and assigned)
    $Assigned + $Eligeble | Sort-Object -Unique -Property Id
}
#endregion

#region Get-PrivilegedAdministratorRoleAssignment
function Get-PrivilegedAdministratorRoleAssignment {
    [CmdletBinding()]
    param (
        $TenantId,
        $SubscriptionId, # TODO: only using in set-azcontext
        $ManagementGroupId
    )

    function Get-GroupMember {
        [CmdletBinding()]
        param (
            [array]$DirectoryObjectByIds
        )
        $params = @{
            ids   = $DirectoryObjectByIds
            types = @(
                "user"
                "group"
            )
        }

        if ($null -eq $DirectoryObjectByIds -or $DirectoryObjectByIds.Count -eq 0) {
            return $()
        }

        $DirectoryObject = Get-MgDirectoryObjectById -BodyParameter $params

        $DirectoryObject | ForEach-Object {
            $ObjectType = $_.AdditionalProperties.'@odata.type'
            if ($ObjectType -eq '#microsoft.graph.group') {
                Get-GroupMember -DirectoryObjectByIds (Get-MgGroupMember -GroupId $_.Id | Select-Object -ExpandProperty Id)
            }
            if ($ObjectType -eq '#microsoft.graph.user') {
                $_
            }
        }  
    }

    # NOTE: Highly recommend to always use latest version of Microsoft.Graph modules, and uninstall old versions
    $RequiredModules = 'Az.ResourceGraph', 'Microsoft.Graph.Groups', 'Microsoft.Graph.Users', 'Microsoft.Graph.Authentication'
    $null = Import-Module -Name $RequiredModules -ErrorAction Stop
    
    try {
        $null = Set-AzContext -TenantId $TenantId -SubscriptionId $SubscriptionId -ErrorAction Stop -WarningAction Stop
    }
    catch {
        $null = Connect-AzAccount -TenantId $TenantId -SubscriptionId $SubscriptionId -UseDeviceAuthentication
    }

    <# we need to input options and set authorizationScopeFilter to AtScopeAboveAndBelow to get inherited permissions
    this means we cannot use search-azgraph
    #>


    function Get-PrivilegedUser {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory)]
            [ValidateSet('Owner', 'Contributor', 'User Access Administrator')]
            $Role,
            [Parameter(Mandatory)]
            [System.Security.SecureString]
            $Token,
            [Parameter(Mandatory)]
            $ManagementGroupId
        )
        $PrivilegedRoles = @{
            'Owner'                     = '8e3af657-a8ff-443c-a75c-2fe8c4bcb635'
            'Contributor'               = 'b24988ac-6180-42a0-ab88-20f7382dd24c'
            'User Access Administrator' = '18d7d88d-d35e-4fb5-a5c3-7773c20a72d9'
        }
        $RoleDefId = $PrivilegedRoles[$Role]
        $Query = @"
        authorizationResources
        | where type == 'microsoft.authorization/roleassignments'
        | extend prop = properties
        | extend roleDefinitionIdFull = tostring(properties.roleDefinitionId)
        | extend roleDefinitionIdsplit = split(roleDefinitionIdFull,'/')
        | extend roleDefinitionId = tostring(roleDefinitionIdsplit[(4)])
        | extend roleAssignmentPrincipalType = properties.principalType
        | extend roleAssignmentDescription = properties.description
        | extend roleAssignmentPrincipalId = properties.principalId
        | extend roleAssignmentCreatedOn = properties.createdOn
        | extend roleAssignmentUpdatedOn = properties.updatedOn
        | extend roleAssignmentUpdatedById = properties.updatedBy
        | extend roleAssignmentCreatedById = properties.createdBy
        | extend roleAssignmentScope = properties.scope
        | project roleDefinitionId,roleAssignmentPrincipalType,roleAssignmentPrincipalId,roleAssignmentCreatedOn,roleAssignmentUpdatedOn,roleAssignmentUpdatedById,roleAssignmentCreatedById,roleAssignmentScope
        | where roleDefinitionId == '$RoleDefId'
"@


        $Body = ConvertTo-Json @{
            managementGroups = @($ManagementGroupId)
            query            = $Query
            options          = @{
                autorizationScopeFilter = "AtScopeAboveAndBelow"
            }
        }
        $Uri = 'https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01'
        $Headers = @{
        }
        Write-Verbose "Sending query:`n$Query"
        $Response = Invoke-RestMethod -Method Post -Uri $Uri -Body $Body -Headers $Headers -Authentication Bearer -Token $Token -ContentType 'application/json'
        $PrivilegedRBAC = $Response.data  #Search-AzGraph -Query $Query -ManagementGroup $ManagementGroupId -debug
    
        [array]$Groups = ($PrivilegedRBAC | Where-Object { $_.roleAssignmentPrincipalType -eq 'Group' }).roleAssignmentPrincipalId | Sort-Object -Unique
        if ($Groups.Count -gt 0) {
            $Filter = "Id in ($(($Groups | ForEach-Object { "'$_'" }) -join ','))"
            $Groups = Get-MgGroup -Filter $Filter
        }
        [array]$GroupMembers = Get-GroupMember -DirectoryObjectByIds $Groups.Id | Select-Object -ExpandProperty Id

        [array]$Users = $GroupMembers + ($PrivilegedRBAC | Where-Object { $_.roleAssignmentPrincipalType -eq 'User' }).roleAssignmentPrincipalId | Sort-Object -Unique
        if ($Users.Count -gt 0) {
            # TODO: chunks of 15 as this is the max allowed
            $Users = $Users | Select-Object -First 15
            $Filter = "Id in ($(($Users | ForEach-Object { "'$_'" }) -join ','))"
            $Users = Get-MgUser -Filter $Filter
            $Users | ForEach-Object {
                # $UserId = $_.Id
                $_ | Add-Member -MemberType NoteProperty -Name Role -Value $Role -PassThru -Force
            } |  Select-Object -Property DisplayName, Role
        }
    }

    # this will check if already connected, so we can run this multiple times
    Connect-MgGraph -TenantId $TenantId -UseDeviceCode -Scopes 'User.Read.All', 'Group.Read.All'
    # $Token = ConvertTo-SecureString -String (Get-AzAccessToken -TenantId $TenantId).Token -Force -AsPlainText
    $Token = Get-AzAccessToken -TenantId $TenantId -AsSecureString | Select-Object -ExpandProperty Token
    $GetPrivilegedUsersParams = @{Token = $Token; ManagementGroupId = $ManagementGroupId }
    Write-Host "Privileged role assignments at '$ManagementGroupId'"
    
    Get-PrivilegedUser -Role 'Owner' @GetPrivilegedUsersParams
    
    Get-PrivilegedUser -Role 'Contributor' @GetPrivilegedUsersParams
    
    Get-PrivilegedUser -Role 'User Access Administrator' @GetPrivilegedUsersParams
}
#endregion

#region Measure-IpAddressCount
function Measure-IpAddressCount {
    [CmdletBinding()]
    param (
        $StartIpAddress,
        $EndIpAddress
    )
    $Ip_Adresa_Od = $StartIpAddress -split "\."
    $Ip_Adresa_Do = $EndIpAddress -split "\."

    #change endianness
    [array]::Reverse($Ip_Adresa_Od)
    [array]::Reverse($Ip_Adresa_Do)

    #convert octets to integer
    $start = [bitconverter]::ToUInt32([byte[]]$Ip_Adresa_Od, 0)
    $end = [bitconverter]::ToUInt32([byte[]]$Ip_Adresa_Do, 0)

    # if they are the same, return 1
    return [System.Math]::Max($end - $start, 1)
}
# Measure-IpAddressCount -StartIpAddress '192.168.1.0' -EndIpAddress '192.168.2.0'
# Measure-IpAddressCount -StartIpAddress '0.0.0.0' -EndIpAddress '255.255.255.255'
#endregion

#region ConvertTo-Markdown
# NOTE: this has trouble with properties that are null or empty strings. It will mess with the order of the columns
# from https://www.powershellgallery.com/packages/PSMarkdown/1.1
<#
.Synopsis
   Converts a PowerShell object to a Markdown table.
.Description
   The ConvertTo-Markdown function converts a Powershell Object to a Markdown formatted table
.EXAMPLE
   Get-Process | Where-Object {$_.mainWindowTitle} | Select-Object ID, Name, Path, Company | ConvertTo-Markdown
 
   This command gets all the processes that have a main window title, and it displays them in a Markdown table format with the process ID, Name, Path and Company.
.EXAMPLE
   ConvertTo-Markdown (Get-Date)
 
   This command converts a date object to Markdown table format
.EXAMPLE
   Get-Alias | Select Name, DisplayName | ConvertTo-Markdown
 
   This command displays the name and displayname of all the aliases for the current session in Markdown table format
#>

Function ConvertTo-Markdown {
    [CmdletBinding()]
    [OutputType([string])]
    Param (
        [Parameter(
            Mandatory = $true,
            Position = 0,
            ValueFromPipeline = $true
        )]
        [PSObject[]]$InputObject
    )

    Begin {
        $items = @()
        $columns = [ordered]@{}
    }

    Process {
        ForEach ($item in $InputObject) {
            $items += $item

            $item.PSObject.Properties | ForEach-Object {
                if ($null -ne $_.Value) {
                    if (-not $columns.Contains($_.Name) -or $columns[$_.Name] -lt $_.Value.ToString().Length) {
                        $columns[$_.Name] = $_.Value.ToString().Length
                    }
                }
            }
        }
    }

    End {
        ForEach ($key in $($columns.Keys)) {
            $columns[$key] = [Math]::Max($columns[$key], $key.Length)
        }

        $header = @()
        ForEach ($key in $columns.Keys) {
            $header += ('{0,-' + $columns[$key] + '}') -f $key
        }
        $header -join ' | '

        $separator = @()
        ForEach ($key in $columns.Keys) {
            $separator += '-' * $columns[$key]
        }
        $separator -join ' | '

        ForEach ($item in $items) {
            $values = @()
            ForEach ($key in $columns.Keys) {
                $values += ('{0,-' + $columns[$key] + '}') -f $item.($key)
            }
            $values -join ' | '
        }
    }
}
#endregion

#region Get-DeviceCodeAuthenticationToken
## get token for main.iam.ad.ext.azure.com
## https://rozemuller.com/use-internal-azure-api-in-automation/
function Get-DeviceCodeAuthenticationToken {
    [CmdletBinding()]
    param (
        $tenantId
    )

    #NOTE: we may be able to just use Get-AzAccessToken -Resource "https://main.iam.ad.ext.azure.com/"

    <# Stopped using this API?
    AADSTS500011: The resource principal named https://main.iam.ad.ext.azure.com/ was not found in the tenant named <TENANT>. This can happen if the application has not been installed by the administrator of the tenant or consented to by any user in the tenant. You might have sent your authentication request to the wrong tenant.
    #>


    $clientId = "1950a258-227b-4e31-a9cf-717495945fc2" # This is de Microsoft Azure Powershell application
    $resource = "https://main.iam.ad.ext.azure.com/"
    $tokenRequest = $null

    # Send the request to receive a device authentication URL
    $codeRequest = Invoke-RestMethod -Method POST -UseBasicParsing -Uri "https://login.microsoftonline.com/$tenantId/oauth2/devicecode" -Body "resource=$resource&client_id=$clientId" -Verbose:$false
    Write-Host "`n$($codeRequest.message)"
    Read-Host "Press enter to continue (in VS Code enter something random, then enter)"

    # Create the body for the token request, where the device code from the previous request will be used in the call
    $tokenBody = @{
        grant_type = "urn:ietf:params:oauth:grant-type:device_code"
        code       = $codeRequest.device_code
        client_id  = $clientId
    }

    # Get OAuth Token
    while ([string]::IsNullOrEmpty($tokenRequest.access_token)) {
        # Write-Verbose "`$tokenRequest is empty"
        $tokenRequest = try {
            Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$tenantId/oauth2/token" -Body $tokenBody -Verbose:$false -ErrorAction Stop
        }
        catch {
            $errorMessage = $_.ErrorDetails.Message | ConvertFrom-Json
            Write-Verbose $errorMessage
            # If not waiting for auth, throw error
            if ($errorMessage.error -ne "authorization_pending") {
                throw "Authorization is pending."
            }
            else {
                Write-Host "Waiting for device code authentication..."
                Start-Sleep -Seconds 10
            }
        }
    }

    # Printing the relevant information for tracability of the token and code
    # Write-Output $($tokenRequest | Select-Object -Property token_type, scope, resource, access_token, refresh_token, id_token)
    $refreshToken = $tokenRequest.refresh_token

    try {
        $response = (Invoke-RestMethod "https://login.windows.net/$tenantId/oauth2/token" -Method POST -Body "resource=74658136-14ec-4630-ad9b-26e160ff0fc6&grant_type=refresh_token&refresh_token=$refreshToken&client_id=$clientId&scope=openid" -ErrorAction Stop -Verbose:$false)
    }
    catch {
        throw $_
    }
    $response.access_token
}
#endregion

#region Get-EntraIDApplicationInsight
function Get-EntraIDApplicationInsight {
    [CmdletBinding()]
    param (
        $appId,
        $Token
    )

    # start and end is in Unix time
    $Date = Get-Date
    $end = [int](Get-Date $Date -UFormat %s) * 1000
    $Date = $Date.AddDays(-30) # does not look like we can go further back than 30 days here
    $start = [int](Get-Date $Date -UFormat %s) * 1000

    # Microsoft Graph PowerShell | Usage & insights
    $session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
    $Uri = "https://main.iam.ad.ext.azure.com/api/ApplicationInsights/EnterpriseAppSignIns?appId=$appId&start=$Start&end=$End"
    $Headers = @{
        "x-ms-client-session-id" = [GUID]::NewGuid().Guid
        "x-ms-client-request-id" = [GUID]::NewGuid().Guid
        "x-ms-command-name"      = "ApplicationManagement - GetEnterpriseAppSignInInsights"
        "Accept-Language"        = "en"
        "Authorization"          = "Bearer $Token"
        "x-ms-effective-locale"  = "en.en-gb"
        "Accept"                 = "*/*"
    }
    $Response = Invoke-WebRequest -UseBasicParsing -Uri $Uri -WebSession $session -Headers $Headers -ContentType "application/json"
    $response.Content | Convertfrom-Json

    <# looks like we get back data for each day
for errorNo 0 the activityCount is the number of signins in the last x days

if erroNo is not 0 then it lists signin failures and a reason
#>

}
#endregion

#region Initialize-Notebook
function Initialize-Notebook {
    [CmdletBinding()]
    param (
        $TenantId = $Global:TenantId,
        $Scopes = @("Directory.AccessAsUser.All", "Policy.Read.All", "RoleManagement.Read.Directory", "RoleManagementAlert.Read.Directory", "AccessReview.Read.All", "Application.Read.All", "Directory.Read.All", "AuditLog.Read.All", "CrossTenantInformation.ReadBasic.All"),
        [SecureString]$AccessToken = $null
    )

    # these scopes are added automatically. To avoid a difference when comparing scopes we add them now
    $Scopes = $Scopes + @('profile', 'openid', 'User.Read', 'email') | Sort-Object -Unique
    $TenantId = $Global:TenantId = if ($null -eq $TenantId) { Read-Host -Prompt "Enter tenant ID" } else { $TenantId }

    # TODO: write a warning if any scope is a write scope

    # connect once for all necessary scopes for this notebook - these are delegated permissions so we cannot do something the authenticated user could not already do!
    # This means that 'Microsoft Graph Command Line Tools' must be approved by a Global Administrator
    # use Get-MgContext to check if we need to connect again
    $MgContext = Get-MgContext
    $NotConnected = $null -ne $AccessToken -or $null -eq $MgContext -or $MgContext.TenantId -ne $TenantId -or $null -ne (Compare-Object -ReferenceObject $MgContext.Scopes -DifferenceObject $Scopes)
    if ($NotConnected) {
        if ($null -ne $AccessToken) {
            Write-Verbose "Connecting to tenant '$TenantId' using provided access token"
            # cannot define scopes when using access token
            $null = Connect-MgGraph -AccessToken $AccessToken -ErrorAction Stop -NoWelcome
        }
        else {
            Write-Verbose "Connecting to tenant '$TenantId' with the following scope: $Scopes"
            $null = Connect-MgGraph -Scopes $Scopes -TenantId $TenantId -ContextScope Process -ErrorAction Stop -NoWelcome
        }
    }
    else {
        Write-Verbose "Already connected to tenant '$TenantId' with the following scope: $($MgContext.Scopes)"
    }

    Write-Verbose "Connected to tenant '$TenantId'"
    $null = Set-AzContext -TenantId $TenantId -ErrorAction Stop -WarningAction SilentlyContinue
}
#endregion

<#
.SYNOPSIS
Creates a new multi-tenant application in the specified tenant.

.DESCRIPTION
Creates a new multi-tenant application in the specified tenant. The application will have the application permissions to conduct the Entra ID Security Assessment.

.PARAMETER TenantId
Specifies the tenant ID of the tenant to create the application in.

.PARAMETER AppDisplayName
The display name of the application. Default is "Entra ID Security Assessment"

.EXAMPLE
New-MultiTenantApplication -TenantId 'abcdefg-8c5e-4a10-a32e-523da88a4c99'

.NOTES
General notes
#>

function New-MultiTenantApplication {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        $TenantId,
        $AppDisplayName = "Entra ID Security Assessment"
    )
    Connect-MgGraph -Scopes "Application.ReadWrite.All", "Directory.ReadWrite.All" -TenantId $TenantId -NoWelcome

    $apiPermission = @{
        "resourceAppId"  = "00000003-0000-0000-c000-000000000000"  # Microsoft Graph
        "resourceAccess" = @(
            @{
                "id"   = "d07a8cc0-3d51-4b77-b3b0-32704d1f69fa" # AccessReview.Read.All
                "type" = "Role"
            }
            @{
                "id"   = "9a5d68dd-52b0-4cc2-bd40-abcf44ac3a30" # Application.Read.All
                "type" = "Role"
            }
            @{
                "id"   = "b0afded3-3588-46d8-8b3d-9842eff778da" # AuditLog.Read.All
                "type" = "Role"
            }
            @{
                "id"   = "cac88765-0581-4025-9725-5ebc13f729ee" # CrossTenantInformation.ReadBasic.All
                "type" = "Role"
            }
            @{
                "id"   = "7ab1d382-f21e-4acd-a863-ba3e13f7da61" # Directory.Read.All
                "type" = "Role"
            }
            @{
                "id"   = "246dd0d5-5bd0-4def-940b-0421030a5b68" # Policy.Read.All
                "type" = "Role"
            }
            @{
                "id"   = "d5fe8ce8-684c-4c83-a52c-46e882ce4be1" # RoleAssignmentSchedule.Read.Directory
                "type" = "Role"
            }
            @{
                "id"   = "ff278e11-4a33-4d0c-83d2-d01dc58929a5" # RoleEligibilitySchedule.Read.Directory
                "type" = "Role"
            }
            @{
                "id"   = "c7fbd983-d9aa-4fa7-84b8-17382c103bc4" # RoleManagement.Read.All
                "type" = "Role"
            }
            @{
                "id"   = "483bed4a-2ad3-4361-a73b-c83ccdbdc53c" # RoleManagement.Read.Directory
                "type" = "Role"
            }
            @{
                "id"   = "ef31918f-2d50-4755-8943-b8638c0a077e" # RoleManagementAlert.Read.Directory
                "type" = "Role"
            }
            @{
                "id"   = "e1fe6dd8-ba31-4d61-89e7-88639da4683d" # User.Read
                "type" = "Scope"
            }
        )
    }
    # allows the app to redirect to portal.azure.com after consent is granted
    $Web = @{
        "RedirectUris" = @("https://portal.azure.com/")
    }
    $app = New-MgApplication -DisplayName $AppDisplayName -SignInAudience "AzureADMultipleOrgs" -RequiredResourceAccess @($apiPermission) -Web $Web
    $null = New-MgServicePrincipal -AppId $app.AppId
    #return the Application (client) ID
    $app.AppId
}

<#
functions used in the identity notebook.
#>


#region Get-UserState
function Get-UserState {
    [CmdletBinding()]
    param (
        [switch]$OutputToHost,
        [switch]$OutputMarkdown
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $TotalUsers = (Get-MgUser -All -Property Id).Count
    $Filter = "accountEnabled eq false"
    $DisabledUsers = (Get-MgUser -All -Property Id -Filter $Filter).Count
    $Filter = "userType eq 'Guest'"
    $GuestUsers = (Get-MgUser -All -Property Id -Filter $Filter).Count
    $DeletedUsers = (Get-MgBetaDirectoryDeletedItemAsUser -All).Count

    if ($OutputToHost.IsPresent) {
        Write-Host "Total users: $TotalUsers"
        Write-Host "Disabled users: $DisabledUsers"
        Write-Host "Guest users: $GuestUsers"
        Write-Host "Deleted users: $DeletedUsers"
    }
    else {
        # return an object
        $Output = [pscustomobject] @{
            TotalUsers    = $TotalUsers
            DisabledUsers = $DisabledUsers
            GuestUsers    = $GuestUsers
            DeletedUsers  = $DeletedUsers
        }
        if ($OutputMarkdown.IsPresent) { $Output | ConvertTo-Markdown } else { $Output | Format-Table -AutoSize }
    }
}
#endregion

#region Get-DisabledUser
function Get-DisabledUser {
    [CmdletBinding()]
    param (
        [switch]$IncludeLicenseDetails,
        [switch]$IncludeGroupMemberships,
        [switch]$OutputMarkdown
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $Filter = "accountEnabled eq false and userType eq 'Member'"
    $DisabledUsers = Get-MgUser -All -Filter $Filter -ErrorAction Stop
    Write-Verbose "Found $($DisabledUsers.Count) disabled users"
    
    $Output = $DisabledUsers | ForEach-Object {
        $UserId = $_.Id
        $UserDisplayName = $_.DisplayName
        $User = [PSCustomObject][Ordered]@{ User = $UserDisplayName; }
        if ($IncludeGroupMemberships.IsPresent) {
            $UserMemberOf = (Get-MgUserMemberOf -UserId $UserId -All)
    
            if ($null -ne $UserMemberOf) {
                [string]$Groups = ($UserMemberOf | Where-Object { $_.AdditionalProperties."@odata.type" -eq '#microsoft.graph.group' }).AdditionalProperties.displayName -join ','
                [string]$Roles = ($UserMemberOf | Where-Object { $_.AdditionalProperties."@odata.type" -ne '#microsoft.graph.group' }).AdditionalProperties.displayName -join ','
                # avoid listing too many groups as it messes with the table width
                $Groups = $Groups[0..128] -join ""
                $User = $User | Add-Member -MemberType NoteProperty -Name Groups -Value $Groups -PassThru
                if (-not [string]::IsNullOrEmpty($Roles)) {
                    $User = $User | Add-Member -MemberType NoteProperty -Name Roles -Value $Roles -PassThru
                }
            }
        }

        if ($IncludeLicenseDetails.IsPresent) {
            $SkuPartNumber = (Get-MgUserLicenseDetail -UserId $UserId -Property SkuPartNumber).SkuPartNumber
        
            if ($null -ne $SkuPartNumber) {
                $User = $User | Add-Member -MemberType NoteProperty -Name Sku -Value $SkuPartNumber -PassThru
                # [PSCustomObject][Ordered]@{ User = $_.DisplayName; Sku = $SkuPartNumber }
            }
        }
        $User
    }
    if ($OutputMarkdown.IsPresent) { $Output | ConvertTo-Markdown } else { $Output | Format-Table -AutoSize }       
}
#endregion

#region Get-GlobalAdminstrator
function Get-GlobalAdminstrator {
    [CmdletBinding()]
    param (
        [switch]$OutputMarkdown,
        [switch]$OutputToHost
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $Setting = Get-EntraIdRoleAssignment -RoleName "Global Administrator"
    $Compliant = $Setting.Count -lt 5

    if ($OutputToHost.IsPresent) {
        if ($Compliant) {
            Write-Host "Compliant to control; there are $($Setting.Count) Global Administrators (Assigned and Eligible)" -ForegroundColor Green
        }
        else {
            Write-Host "Not compliant to control; there are $($Setting.Count) Global Administrators (Assigned and Eligible)" -ForegroundColor Red
        }
    }

    $Output = $Setting | Select-Object -Property Id, @{ Name = 'Display Name'; Expression = { $_.AdditionalProperties.displayName } }, @{ Name = 'User Principal Name'; Expression = { $_.AdditionalProperties.userPrincipalName } }

    if ($OutputMarkdown.IsPresent) { $Output | ConvertTo-Markdown } else { $Output | Format-Table -AutoSize }
}
#endregion

#region Get-SynchronizedAccount
function Get-SynchronizedAccount {
    [CmdletBinding()]
    param (
        [switch]$OutputMarkdown,
        [switch]$ShowProgress
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $PrivilegedRolesList = @('62e90394-69f5-4237-9190-012177145e10', '194ae4cb-b126-40b2-bd5b-6091b380977d', 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c', '29232cdf-9323-42fd-ade2-1d097af3e4de', 'b1be1c3e-b65d-4f19-8427-f6fa0d97feb9', '729827e3-9c14-49f7-bb1b-9608f156bbb8', 'b0f54661-2d74-4c50-afa3-1ec803f12efe', 'fe930be7-5e62-47db-91af-98c3a49a38b1', 'c4e39bd9-1100-46d3-8c65-fb160da0071f', '9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3', '158c047a-c907-4556-b7ef-446551a6b5f7', '966707d0-3269-4727-9be2-8c3a10f19b9d', '7be44c8a-adaf-4e2a-84d6-ab2649e08a13', 'e8611ab8-c189-46e8-94e1-60213ab1f814')

    # check if there is any synchronized accounts at all
    $SynchronizedAccounts = Get-MgUser -Filter "onPremisesSyncEnabled eq true" -All # TODO: test and only return the count
    if ($SynchronizedAccounts.Count -eq 0) {
        return
    }

    $i = 0
    $UsersWithPrivilegedRoles = $PrivilegedRolesList | ForEach-Object {    
        $RoleName = Get-MgBetaRoleManagementDirectoryRoleDefinition -UnifiedRoleDefinitionId $_ -Property "DisplayName" | Select-Object -ExpandProperty DisplayName
        $i += 1
        if ($ShowProgress.IsPresent) {
            [int]$p = 100 * [float]$i / [float]($PrivilegedRolesList.Count)
            Write-Progress -Activity "Getting users with the role '$RoleName'" -PercentComplete $p -Status "$p% Complete"
        }
        Get-EntraIdRoleAssignment -RoleName $RoleName | ForEach-Object { $_ | Add-Member -MemberType NoteProperty -Name 'Role' -Value $RoleName -PassThru -Force }
    }

    $Output = $UsersWithPrivilegedRoles | ForEach-Object {
        $UserWithPrivilegedRole = $_
        Get-MgUser -UserId $UserWithPrivilegedRole.Id -Property onPremisesSyncEnabled, DisplayName, AccountEnabled -ErrorAction SilentlyContinue | Select-Object -Property DisplayName, AccountEnabled, onPremisesSyncEnabled, @{ Name = 'Role'; Expression = { $UserWithPrivilegedRole.Role } }
    } | Where-Object { $_.OnPremisesSyncEnabled -and $_.AccountEnabled } | Select-Object -Property @{ Name = 'Display Name'; Expression = { $_.DisplayName } }, Role

    if ($OutputMarkdown.IsPresent) { $Output | ConvertTo-Markdown } else { $Output | Format-Table -AutoSize }
}
#endregion

#region Get-GroupsWithRoleAssignment
function Get-GroupsWithRoleAssignment {
    [CmdletBinding()]
    param (
        [switch]$OutputMarkdown
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $RoleName = "Global Administrator"

    # Get the directory role id for $RoleName
    $DirectoryRoleId = Get-MgDirectoryRole -Filter "DisplayName eq '$RoleName'" | Select-Object -ExpandProperty Id
    # Get currently assigned
    $null = Get-MgDirectoryRoleMember -DirectoryRoleId $DirectoryRoleId | Select-Object -ExpandProperty Id

    # TODO: $Assigned includes eligeble that have activated the role, but does not provide any details. we need to kow the 'state' and if it is activated we can disregard

    # Get the role definition id for $RoleName
    $DirectoryRoleDefinitionId = Get-MgBetaRoleManagementDirectoryRoleDefinition -Filter "DisplayName eq '$RoleName'" -Property "id" | Select-Object -ExpandProperty Id
    # get principals that are eligble for GA
    $EligeblePrincipals = Get-MgBetaRoleManagementDirectoryRoleEligibilityScheduleInstance -Filter "roleDefinitionId eq '$DirectoryRoleDefinitionId'" | Select-Object -ExpandProperty PrincipalId

    $DirectoryObjectByIds = $EligeblePrincipals # + $Assigned

    $params = @{
        ids   = $DirectoryObjectByIds
        types = @(
            "user"
            "group"
        )
    }

    if ($params.ids.Count -gt 0) {
        $DirectoryObject = Get-MgDirectoryObjectById -BodyParameter $params

        $DirectoryObject | Select-Object Id, @{ Name = 'displayName'; Expression = { $_.AdditionalProperties.displayName } }, @{ Name = 'type'; Expression = { $_.AdditionalProperties.'@odata.type'.split('.') | Select-Object -Last 1 } }
    }

    if ($OutputMarkdown.IsPresent) { $Output | ConvertTo-Markdown } else { $Output | Format-Table -AutoSize }
}
#endregion

#region Get-PimAlert
function Get-PimAlert {
    [CmdletBinding()]
    param (
        [switch]$OutputMarkdown
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $GovernanceRoleManagementAlerts = Get-MgBetaIdentityGovernanceRoleManagementAlert -Filter "scopeId eq '/' and scopeType eq 'DirectoryRole' and isActive eq true" -ExpandProperty "alertDefinition,alertConfiguration,alertIncidents"

    $Output = $GovernanceRoleManagementAlerts | Select-Object -Property @{ Name = 'Alert'; Expression = { $_.alertDefinition.displayName } }, @{ Name = 'Incident Count'; Expression = { $_.IncidentCount } }

    if ($OutputMarkdown.IsPresent) { $Output | ConvertTo-Markdown } else { $Output | Format-Table -AutoSize }
}
#endregion

#region Get-PimAlertAffectedPrincipal
function Get-PimAlertAffectedPrincipal {
    [CmdletBinding()]
    param (
        [switch]$OutputMarkdown
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $GovernanceRoleManagementAlerts = Get-MgBetaIdentityGovernanceRoleManagementAlert -Filter "scopeId eq '/' and scopeType eq 'DirectoryRole' and isActive eq true" -ExpandProperty "alertDefinition,alertConfiguration,alertIncidents"

    $Output = $GovernanceRoleManagementAlerts.alertIncidents.AdditionalProperties | Where-Object { $_.assigneeUserPrincipalName } | ForEach-Object {
        $_ | Select-Object -Property @{ Name = 'User'; Expression = { "$($_.assigneeDisplayName) ($($_.assigneeUserPrincipalName))" } }, @{ Name = 'Role'; Expression = { $_.roleDisplayName } }
    }

    if ($OutputMarkdown.IsPresent) { $Output | ConvertTo-Markdown } else { $Output | Format-Table -AutoSize }
}
#endregion

#region Get-RecurringAccessReview
function Get-RecurringAccessReview {
    [CmdletBinding()]
    param (
        [switch]$OutputMarkdown
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $AccessReviewDefinitions = Get-MgBetaIdentityGovernanceAccessReviewDefinition

    $Output = [pscustomobject] @{"Access review definitions" = $(($AccessReviewDefinitions | Measure-Object).Count) }
    if ($OutputMarkdown.IsPresent) { $Output | ConvertTo-Markdown } else { $Output | Format-Table -AutoSize }
}
#endregion

#region Get-AppOwnersChangeGroupMembership
function Test-AppOwnersChangeGroupMembership {
    [CmdletBinding()]
    param (
        [switch]$OutputMarkdown
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    #get the graph id
    $Graph = Get-MgBetaServicePrincipal -filter "appId eq '00000003-0000-0000-c000-000000000000'" -ErrorAction Stop
    #get the permission IDs
    $group_permissions = $Graph | Select-Object -ExpandProperty approles | Select-Object * | Where-Object { $_.value -in ("GroupMember.ReadWrite.All", "Group.ReadWrite.All") }


    $Output = Get-MgBetaServicePrincipalAppRoleAssignedTo -ServicePrincipalId $graph.id -All | `
        Where-Object { $_.AppRoleId -in ($group_permissions.id) } | Select-Object PrincipalDisplayName, PrincipalId -Unique | ForEach-Object {
        Get-MgBetaServicePrincipal -serviceprincipalid $_.PrincipalId -ExpandProperty owners | ForEach-Object {
            $app = $null; $app = $_
            $app | Select-Object appid, displayname, PublisherName, @{N = "via"; Expression = { "AppRoleAssignment" } }
            ($app.owners).id | Select-Object @{N = "appid"; Expression = { $app.appid } }, `
            @{N = "DisplayName"; Expression = { (Get-MgBetaDirectoryObjectById -Ids $_ | Select-Object -ExpandProperty AdditionalProperties | ConvertTo-Json | ConvertFrom-Json).displayname } }, `
            @{N = "PublisherName"; Expression = { $app.PublisherName } }, @{N = "via"; Expression = { "Owner of $($app.displayname)" } }
            (Get-MgBetaApplication -filter "appId eq '$($_.appid)'" -ExpandProperty owners | `
                Select-Object -expandproperty owners).id | Select-Object @{N = "appid"; Expression = { $app.appid } }, `
            @{N = "DisplayName"; Expression = { (Get-MgBetaDirectoryObjectById -Ids $_ | Select-Object -ExpandProperty AdditionalProperties | ConvertTo-Json | ConvertFrom-Json).displayname } }, `
            @{N = "PublisherName"; Expression = { $app.PublisherName } }, @{N = "via"; Expression = { "Owner of $($app.displayname)" } }
        }
    } | Select-Object DisplayName, via -Unique

    # Roles that have GroupMember.ReadWrite.All or Group.ReadWrite.All
    $roles = '810a2642-a034-447f-a5e8-41beaa378541', ',11451d60-acb2-45eb-a7d6-43d0f0125c13', '45d8d3c5-c802-45c6-b32a-1d70b5e1e86e', `
        '744ec460-397e-42ad-a462-8b3f9747a02c', 'b5a8dcf3-09d5-43a9-a639-8e29ef291470', 'fdd7a751-b60b-444a-984c-02652fe8fa1c', `
        '69091246-20e8-4a56-aa4d-066075b2a7a8', 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c', '29232cdf-9323-42fd-ade2-1d097af3e4de', `
        '9360feb5-f418-4baa-8175-e2a00bac4301', 'fe930be7-5e62-47db-91af-98c3a49a38b1', '62e90394-69f5-4237-9190-012177145e10'

    $Output += Get-MgBetaDirectoryRole -all | Where-Object { $_.RoleTemplateId -in $roles } -pv role | ForEach-Object {
        Get-MgBetaDirectoryRoleMember -DirectoryRoleId $_.Id | ForEach-Object { $_ | Select-Object -expandproperty AdditionalProperties | `
                ConvertTo-Json -Depth 5 | ConvertFrom-Json }  | Select-Object displayName, @{N = "via"; Expression = { "Role Member of $($role.displayname)" } }
    } | Select-Object DisplayName, via -Unique

    $Output = $Output | Select-Object -Property @{ Name = 'Display Name'; Expression = { $_.DisplayName } }, via

    # TODO: column order is reversed
    if ($OutputMarkdown.IsPresent) { $Output | ConvertTo-Markdown } else { $Output | Format-Table -AutoSize }
    #$Output[0].PSObject.Properties
}
#endregion

#region Test-StandingAccess
function Test-StandingAccess {
    [CmdletBinding()]
    param (
        [switch]$OutputMarkdown,
        [switch]$ShowProgress
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    # get permanent assignments of privileged Entra ID roles

    $PrivilegedRolesList = @('62e90394-69f5-4237-9190-012177145e10', '194ae4cb-b126-40b2-bd5b-6091b380977d', 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c', '29232cdf-9323-42fd-ade2-1d097af3e4de', 'b1be1c3e-b65d-4f19-8427-f6fa0d97feb9', '729827e3-9c14-49f7-bb1b-9608f156bbb8', 'b0f54661-2d74-4c50-afa3-1ec803f12efe', 'fe930be7-5e62-47db-91af-98c3a49a38b1', 'c4e39bd9-1100-46d3-8c65-fb160da0071f', '9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3', '158c047a-c907-4556-b7ef-446551a6b5f7', '966707d0-3269-4727-9be2-8c3a10f19b9d', '7be44c8a-adaf-4e2a-84d6-ab2649e08a13', 'e8611ab8-c189-46e8-94e1-60213ab1f814')

    $i = 0
    $Output = $PrivilegedRolesList | ForEach-Object {    
        $RoleName = Get-MgBetaRoleManagementDirectoryRoleDefinition -UnifiedRoleDefinitionId $_ -Property "DisplayName" | Select-Object -ExpandProperty DisplayName
        $i += 1
        if ($ShowProgress.IsPresent) {
            [int]$p = 100 * [float]$i / [float]($PrivilegedRolesList.Count)
            Write-Progress -Activity "Getting users with the role '$RoleName'" -PercentComplete $p -Status "$p% Complete"
        }
        Get-EntraIdRoleAssignment -RoleName $RoleName -ExcludeEligebleRoles | ForEach-Object { $_ | Add-Member -MemberType NoteProperty -Name 'Role' -Value $RoleName -PassThru -Force }
    } | Select-Object -Property Role, @{Name = 'DisplayName'; Expression = { $_.AdditionalProperties.displayName } }, @{Name = 'UserPrincipalName'; Expression = { $_.AdditionalProperties.userPrincipalName } }

    if ($OutputMarkdown.IsPresent) { $Output | ConvertTo-Markdown } else { $Output | Format-Table -AutoSize }

    # get permanent assignments of privileged Azure RBAC roles
}
#endregion

#region Test-GuestInviteSetting
function Test-GuestInviteSetting {
    [CmdletBinding()]
    param (
        [switch]$OutputToHost,
        [switch]$ShowExplanation,
        [switch]$OutputMarkdown
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $AuthorizationPolicy = Get-MgPolicyAuthorizationPolicy

    $Setting = $AuthorizationPolicy.AllowInvitesFrom
    $Compliant = $Setting -in 'adminsAndGuestInviters', 'none'

    if ($ShowExplanation.IsPresent) {
        Write-Host "Compliant External Collaboration Settings: Guest invite settings set to 'Only users assigned to specific admin roles can invite guest users' or 'No one in the organization can invite guest users including admins (most restrictive)'"
    }

    if ($OutputToHost.IsPresent) {
        if ($Compliant) {
            Write-Host "Compliant to control, setting is $($Setting)" -ForegroundColor Green
        }
        else {
            Write-Host "Not compliant to control, setting is $($Setting)" -ForegroundColor Red
        }
    }
    if ($OutputMarkdown.IsPresent) {
        # return an object
        $Output = [pscustomobject] @{
            "Guest invite settings" = $Setting
        }
        $Output | ConvertTo-Markdown
    }
}
#endregion

#region Test-GuestUserAccessRestriction
function Test-GuestUserAccessRestriction {
    [CmdletBinding()]
    param (
        [switch]$ShowExplanation
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $Explanation = @"
Compliant External Collaboration Settings: Guest user access set to 'Guest user access is restricted to properties and memberships of their own directory objects (most restrictive)'
"@

    if ($ShowExplanation.IsPresent) {
        Write-Host $Explanation
    }
    Write-Warning "Work in progress - not yet implemented"; return
    # TODO: does not say anything about guest user access....

    # $ExternalIdentityPolicy = Get-MgBetaPolicyExternalIdentityPolicy #-ExpandProperty "AdditionalProperties"

    # $ExternalIdentityPolicy | fl *
    # $ExternalIdentityPolicy.AdditionalProperties | fl *
}
#endregion

#region Test-UsersCanRegisterApplication
function Test-UsersCanRegisterApplication {
    [CmdletBinding()]
    param (
        [switch]$OutputToHost,
        [switch]$ShowExplanation,
        [switch]$OutputMarkdown
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $Explanation = @"
Users can register applications should be set to `No`.

Users should not be allowed to register applications. Use specific roles such as `Application Developer`.
"@

    if ($ShowExplanation.IsPresent) {
        Write-Host $Explanation
    }
    $AuthorizationPolicy = Get-MgPolicyAuthorizationPolicy -Property "DefaultUserRolePermissions"

    $Setting = $AuthorizationPolicy.DefaultUserRolePermissions.AllowedToCreateApps
    $Compliant = $Setting -eq $false

    if ($OutputToHost.IsPresent) {
        if ($Compliant) {
            Write-Host "Compliant to control; users are not allowed to create applications" -ForegroundColor Green
        }
        else {
            Write-Host "Not compliant to control; users are allowed to create applications" -ForegroundColor Red
        }
    }
    if ($OutputMarkdown.IsPresent) {
        # return an object
        $Output = [pscustomobject] @{
            "Users are allowed to create applications" = $Compliant ? "No" : "Yes"
        }
        $Output | ConvertTo-Markdown
    }
}
#endregion

#region Test-AuthenticationMethod
function Test-AuthenticationMethod {
    [CmdletBinding()]
    param (
        [switch]$ShowExplanation
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $Explanation = @"
    
"@

    if ($ShowExplanation.IsPresent) {
        Write-Host $Explanation
    }

    $Response = Invoke-MgGraphRequest -Method GET "https://graph.microsoft.com/beta/policies/authenticationmethodspolicy" -ErrorAction Stop
    $policyMigrationState = $Response | Select-Object -ExpandProperty policyMigrationState
    # possible options??? preMigration, migrationInProgress, migrationComplete
    if ($policyMigrationState -eq 'migrationComplete') {
        Write-Host "Authentcation medthod policy migration already completed!"
        return
    }
    Write-Host "Authentcation method policy migration status is '$policyMigrationState'"
    
    # look up the state of authentication methods
    $authenticationMethodConfigurations = $Response | Select-Object -ExpandProperty authenticationMethodConfigurations | Where-Object { $_.state -eq "enabled" }
    Write-Host "$($authenticationMethodConfigurations.Count) authentication methods are enabled: $(($authenticationMethodConfigurations | Select-Object -ExpandProperty id) -join ',')"
    
    # get methods from SSPR
    # azure portal uses
    # https://main.iam.ad.ext.azure.com/api/PasswordReset/PasswordResetPolicies?getPasswordResetEnabledGroup=false
    # looks like there is no MG API for this - makes sense when moving to use authentication method policies
    # $Token = ConvertTo-SecureString -Force -AsPlainText (Get-AzAccessToken -TenantId $TenantId -ResourceUrl "https://main.iam.ad.ext.azure.com" | Select-Object -ExpandProperty Token)
    # $Uri = 'https://main.iam.ad.ext.azure.com/api/PasswordReset/PasswordResetPolicies?getPasswordResetEnabledGroup=false'
    # token does not work for this endpoint. inspecting a working token the audience and appid is a guid
    
    # get methods from old MFA portal
    
    # compare with what is enabled
}
#endregion

#region Test-VerifiedDomain
function Test-VerifiedDomain {
    [CmdletBinding()]
    param (
        [switch]$OutputToHost,
        [switch]$ShowExplanation,
        [switch]$OutputMarkdown
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $Explanation = @"
Check that only validated customer domains are registered in the tenant.
"@

    if ($ShowExplanation.IsPresent) {
        Write-Host $Explanation
    }
    $Domains = Get-MgBetaDomain

    $UnverifiedDomains = $Domains | Where-Object { -not $_.IsVerified }

    $Compliant = $UnverifiedDomains.Count -eq 0

    if ($OutputToHost.IsPresent) {
        if ($Compliant) {
            Write-Host "Compliant to control; All ($($Domains.Count)) domains are verified" -ForegroundColor Green
        }
        else {
            Write-Host "Not compliant to control; There are unverified domains registered: $($UnverifiedDomains | Select-Object -ExpandProperty Id)" -ForegroundColor Red
        }
    }
    if ($OutputMarkdown.IsPresent) {
        # return an object
        $Output = [pscustomobject] @{
            "Unverified domains registered" = $Compliant ? "0" : ($UnverifiedDomains | Select-Object -ExpandProperty Id)
        }
        $Output | ConvertTo-Markdown
    }
}
#endregion

#region Test-UserConsentForApp
function Test-UserConsentForApp {
    [CmdletBinding()]
    param (
        [switch]$OutputToHost,
        [switch]$ShowExplanation,
        [switch]$OutputMarkdown
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $Explanation = @"
Users should only be allowed to consent to apps from verified publishers or not consent at all. Allowing users to consent to any application is a security risk.
"@

    if ($ShowExplanation.IsPresent) {
        Write-Host $Explanation
    }

    $PolicyAuthorization = Get-MgPolicyAuthorizationPolicy #-ExpandProperty defaultUserRolePermissions
    $permissionGrantPoliciesAssigned = $PolicyAuthorization.DefaultUserRolePermissions.permissionGrantPoliciesAssigned

    $Setting = $permissionGrantPoliciesAssigned
    $Compliant = $Setting[0] -ne "ManagePermissionGrantsForSelf.microsoft-user-default-legacy"

    if ($OutputToHost.IsPresent) {
        if ($Compliant) {
            Write-Host "Compliant to control; users are only allowed to consent to apps from verified publishers or not consent at all." -ForegroundColor Green
        }
        else {
            Write-Host "Not compliant to control; users are allowed to consent to all applications." -ForegroundColor Red
        }
    }
    if ($OutputMarkdown.IsPresent) {
        # return an object
        $Output = [pscustomobject] @{
            "Users are allowed to consent to all applications" = $Compliant ? "No" : "Yes"
        }
        $Output | ConvertTo-Markdown
    }
}
#endregion

#region Test-GroupOwnerConsent
function Test-GroupOwnerConsent {
    [CmdletBinding()]
    param (
        [switch]$ShowExplanation
    )
    $Explanation = @"
TODO
"@

    if ($ShowExplanation.IsPresent) {
        Write-Host $Explanation
    }
    # TODO https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-user-consent-groups?tabs=azure-portal&pivots=ms-powershell
}
#endregion

#region Find-OwnersFirstPartyMicrosoftApplication
function Find-OwnersFirstPartyMicrosoftApplication {
    [CmdletBinding()]
    param (
        [switch]$OutputToHost,
        [switch]$ShowExplanation,
        [switch]$OutputMarkdown
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $Explanation = @"
Owners of builtin applications can be exploited, see https://dirkjanm.io/azure-ad-privilege-escalation-application-admin/
"@

    if ($ShowExplanation.IsPresent) {
        Write-Host $Explanation
    }
    $Output = Get-MgBetaServicePrincipal -all -ExpandProperty owners | `
        # looking for first-party Microsoft applications with owners
        # TODO: convert to filter
    Where-Object { $_.PublisherName -like "*Microsoft*" -or !($_.PublisherName -eq "Microsoft Accounts") -and $_.AppOwnerOrganizationId -eq 'f8cdef31-a31e-4b4a-93e4-5f571e91255a' } | `
        Where-Object { $_.owners -like "*" } | Select-Object appid, displayname, PublisherName, owners # TODO: look up owner
    if ($OutputToHost.IsPresent) {
    }
    if ($OutputMarkdown.IsPresent) {
        $Output | ConvertTo-Markdown
    }
    # TODO: can we look for anyone who persisted access through one of these?
}
#endregion

#region Find-ApplicationsWithApplicationPermissionsAndOwner
function Find-ApplicationsWithApplicationPermissionsAndOwner {
    [CmdletBinding()]
    [OutputType([String])]
    param (
        [switch]$OutputMarkdown,
        [switch]$ShowExplanation
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $Explanation = @"

"@

    if ($ShowExplanation.IsPresent) {
        Write-Host $Explanation
    }

    $MgGraphPermissionIds = Find-MgGraphPermission -All -PermissionType Application | Select-Object -ExpandProperty Id
    $Applications = Get-MgBetaApplication -All -ExpandProperty Owners | Where-Object {
        $RequiredResourceAccess = $_.RequiredResourceAccess
        $ResourceAppId = $RequiredResourceAccess.ResourceAppId
        $ResourceAccessIds = $RequiredResourceAccess.ResourceAccess.Id
        $ResourceAppId -eq '00000003-0000-0000-c000-000000000000' -and $_.Owners.Count -gt 0 -and ($ResourceAccessIds | Where-Object { $_ -In $MgGraphPermissionIds }).Count -gt 0
    }
    
    if ($Applications.Count -eq 0) {
        "Found no applications with Owners"
        return
    }
    
    $Owners = Get-MgDirectoryObjectById -ErrorAction SilentlyContinue -Ids ($Applications | ForEach-Object {
            $_.Owners.Id
        } | Sort-Object -Unique)
    
    # need these in a specific order for Markdown output, ConvertTo-Markdown still outputs in different order
    $Output = $Applications | ForEach-Object {
        $App = $_
        $OwnerIds = $App.Owners.Id
        [PSCustomObject][Ordered]@{
            DisplayName = $App.DisplayName
            AppId       = $App.AppId
            Owners      = ($OwnerIds | ForEach-Object { $Id = $_; $Owners | Where-Object { $_.Id -eq $Id } }).AdditionalProperties.userPrincipalName -join ','
        }
    }
    
    if ($OutputMarkdown.IsPresent) { $Output | ConvertTo-Markdown } else { $Output | Format-Table -AutoSize }
}
#endregion

$LowPermissions = @('14dad69e-099b-42c9-810b-d002981feec1', 'e1fe6dd8-ba31-4d61-89e7-88639da4683d', '64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0', '7427e0e9-2fba-42fe-b0c0-848c9e6a8182', '37f7f235-527c-4136-accd-4a02d197296e')

#region Show-LowRiskApplicationPermission
function Show-LowRiskApplicationPermission {
    [CmdletBinding()]
    param (
        [switch]$ShowExplanation,
        [switch]$OutputMarkdown
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $Explanation = @"
These permissions are considered low risk
"@

    if ($ShowExplanation.IsPresent) {
        Write-Host $Explanation
    }
    
    $Output = Find-MgGraphPermission -All | Where-Object { $_.Id -in $LowPermissions } # uncomment to list low-risk permissions

    if ($OutputMarkdown.IsPresent) {
        $Output | ConvertTo-Markdown
    }
    else {
        $Output | Format-Table -AutoSize
    }
}
#endregion

#region Find-ApplicationsNonLowRiskPermissionsAndOwner
function Find-ApplicationsNonLowRiskPermissionsAndOwner {
    [CmdletBinding()]
    [OutputType([String])]
    param (
        [switch]$ShowExplanation,
        [switch]$OutputMarkdown
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $Explanation = @"
Look for applications with owners and any resource access that we do not consider low-risk.
"@

    if ($ShowExplanation.IsPresent) {
        Write-Host $Explanation
    }
    
    # filter out some of the classical delegated low-risk permissions
($LowPermissions | ForEach-Object { Find-MgGraphPermission $_ }).Name
    $Applications = Get-MgBetaApplication -All -ExpandProperty Owners | Where-Object {
        $RequiredResourceAccess = $_.RequiredResourceAccess
        $ResourceAppId = $RequiredResourceAccess.ResourceAppId
        $ResourceAccessIds = $RequiredResourceAccess.ResourceAccess.Id
        $HasOwners = $_.Owners.Count -gt 0
        $HasOnlyWindowsAzureActiveDirectoryUserRead = $ResourceAppId -eq '00000002-0000-0000-c000-000000000000' -and '311a71cc-e848-46a1-bdf8-97ff7156d8e6' -in $ResourceAccessIds -and $ResourceAccessIds.Count -eq 1
        $OnlyLowPermissions = $ResourceAppId -eq '00000003-0000-0000-c000-000000000000' -and ($ResourceAccessIds | Where-Object { $_ -notin $LowPermissions }).Count -eq 0
        # Application has owners, has API permissions and those permission are not only low-risk permissions
        $HasOwners -and $null -ne $_.RequiredResourceAccess -and -not $OnlyLowPermissions -and $ResourceAccessIds.Count -gt 0 -and -not $HasOnlyWindowsAzureActiveDirectoryUserRead
    }

    if ($Applications.Count -eq 0) {
        "Found no applications with Owners and above low-risk permissions"
        return
    }

    $Owners = Get-MgDirectoryObjectById -Ids ($Applications | ForEach-Object {
            $_.Owners.Id
        } | Sort-Object -Unique)

    # need these in a specific order for Markdown output, ConvertTo-Markdown still outputs in different order
    $Output = $Applications | ForEach-Object {
        $App = $_
        $OwnerIds = $App.Owners.Id    
        $ResourceAccessTypes = $App.RequiredResourceAccess.ResourceAccess.type
        [PSCustomObject][Ordered]@{
            DisplayName         = $App.DisplayName
            AppId               = $App.AppId
            Owners              = ($OwnerIds | ForEach-Object { $Id = $_; $Owners | Where-Object { $_.Id -eq $Id } }).AdditionalProperties.userPrincipalName -join ','
            ResourceAccessTypes = ($ResourceAccessTypes | Sort-Object -Unique) -join ','
        }
    }

    # TODO: pretty print headers

    if ($OutputMarkdown) { $Output | ConvertTo-Markdown } else { $Output | Format-Table -AutoSize }
}
#endregion

#region Get-EntraIdPrivilegedAppRoleAssignment
############################################################################################################
# #
# Powershell script showcasing how to fetch and report on all app role assignments for Microsoft Graph #
# and Azure AD Graph. Requires Microsoft Graph Powershell SDK v2, but the script can be altered to also #
# work in v1 if replacing beta-cmdlets. #
# #
# The script only requires read-access and a few Graph scopes in Entra ID. #
# #
# Please read the blogpost first: #
# https://learningbydoing.cloud/blog/audit-ms-graph-app-role-assignments/ #
# #
############################################################################################################

### Start of script

# asp@apento.com - making into function

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

    #region: Script Configuration

    # The tier 0 app roles below are typically what can be abused to become Global Admin.
    # NOTE: Organizations should do their own investigations and include any app roles to regard as sensitive, and which tier to assign them.
    $appRoleTiers = @{
        'Application.ReadWrite.All'          = 'Tier 0' # SP can add credentials to other high-privileged apps, and then sign-in as the high-privileged app
        'AppRoleAssignment.ReadWrite.All'    = 'Tier 0' # SP can add any app role assignments to any resource, including MS Graph
        'Directory.ReadWrite.All'            = 'Tier 0' # SP can read and write all objects in the directory, including adding credentials to other high-privileged apps
        'RoleManagement.ReadWrite.Directory' = 'Tier 0' # SP can grant any role to any principal, including Global Admin
    }

    #endregion: Script Configuration

    # Connect to Microsoft Graph - assuming this has already been done
    # Connect-MgGraph -Scopes "Application.Read.All", "AuditLog.Read.All", "CrossTenantInformation.ReadBasic.All"

    # Get Microsoft Graph SPN, appRoles, appRolesAssignedTo and generate hashtable for quick lookups
    $servicePrincipalMsGraph = Get-MgServicePrincipal -Filter "AppId eq '00000003-0000-0000-c000-000000000000'"
    [array] $msGraphAppRoles = $servicePrincipalMsGraph.AppRoles
    [array] $msGraphAppRolesAssignedTo = Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $servicePrincipalMsGraph.Id -All
    $msGraphAppRolesHashTableId = $msGraphAppRoles | Group-Object -Property Id -AsHashTable

    # Get Azure AD Graph SPN, appRoles, appRolesAssignedTo and generate hashtable for quick lookups
    $servicePrincipalAadGraph = Get-MgServicePrincipal -Filter "AppId eq '00000002-0000-0000-c000-000000000000'"
    [array] $aadGraphAppRoles = $servicePrincipalAadGraph.AppRoles
    [array] $aadGraphAppRolesAssignedTo = Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $servicePrincipalAadGraph.Id -All
    $aadGraphAppRolesHashTableId = $aadGraphAppRoles | Group-Object -Property Id -AsHashTable

    # Join appRolesAssignedTo entries for AAD / MS Graph
    $joinedAppRolesAssignedTo = @(
        $msGraphAppRolesAssignedTo
        $aadGraphAppRolesAssignedTo
    )

    # Process each appRolesAssignedTo for AAD / MS Graph
    $progressCounter = 0
    $cacheAppOwnerOrganizations = @()
    $cacheServicePrincipalObjects = @()
    $cacheServicePrincipalSigninActivities = @()
    $cacheServicePrincipalsWithoutSigninActivities = @()
    [array] $msGraphAppRoleAssignedToReport = $joinedAppRolesAssignedTo | ForEach-Object {
        $progressCounter++
        $currentAppRoleAssignedTo = $_
        Write-Verbose "Processing appRole # $progressCounter of $($joinedAppRolesAssignedTo.count)"

        # Lookup appRole for MS Graph
        $currentAppRole = $msGraphAppRolesHashTableId["$($currentAppRoleAssignedTo.AppRoleId)"]
        if ($null -eq $currentAppRole) {
            # Lookup appRole for AAD Graph
            $currentAppRole = $aadGraphAppRolesHashTableId["$($currentAppRoleAssignedTo.AppRoleId)"]
        }
    
        # Lookup servicePrincipal object - check cache
        $currentServicePrincipalObject = $null
        if ($cacheServicePrincipalObjects.Id -contains $currentAppRoleAssignedTo.PrincipalId) {
            $currentServicePrincipalObject = $cacheServicePrincipalObjects | Where-Object { $_.Id -eq $currentAppRoleAssignedTo.PrincipalId }
        } 

        else {
            # Retrieve servicePrincipalObject from MS Graph
            $currentServicePrincipalObject = Get-MgServicePrincipal -ServicePrincipalId $currentAppRoleAssignedTo.PrincipalId
            $cacheServicePrincipalObjects += $currentServicePrincipalObject
            Write-Verbose "Added servicePrincipal object to cache: $($currentServicePrincipalObject.displayName)"
        }

        # Lookup app owner organization
        $currentAppOwnerOrgObject = $null
        if ($null -ne $currentServicePrincipalObject.AppOwnerOrganizationId) {
            # Check if app owner organization is in cache
            if ($cacheAppOwnerOrganizations.tenantId -contains $currentServicePrincipalObject.AppOwnerOrganizationId) {
                $currentAppOwnerOrgObject = $cacheAppOwnerOrganizations | Where-Object { $_.tenantId -eq $currentServicePrincipalObject.AppOwnerOrganizationId }
            } 

            else {
                # Retrieve app owner organization from MS Graph
                $currentAppOwnerOrgObject = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/tenantRelationships/findTenantInformationByTenantId(tenantId='$($currentServicePrincipalObject.AppOwnerOrganizationId)')"
                $cacheAppOwnerOrganizations += $currentAppOwnerOrgObject
                Write-Verbose "Added app owner organization tenant to cache: $($currentAppOwnerOrgObject.displayName)"
            }
        }

        # Lookup servicePrincipal sign-in activity if not already in no-signin-activity list
        $currentSpSigninActivity = $null
        if ($currentServicePrincipalObject.AppId -notin $cacheServicePrincipalsWithoutSigninActivities) {
            if ($cacheServicePrincipalSigninActivities.AppId -contains $currentServicePrincipalObject.AppId) {
                $currentSpSigninActivity = $cacheServicePrincipalSigninActivities | Where-Object { $_.AppId -eq $currentServicePrincipalObject.AppId }
            } 

            else {
                # Retrieve servicePrincipal sign-in activity from MS Graph
                $currentSpSigninActivity = Get-MgBetaReportServicePrincipalSignInActivity -Filter "AppId eq '$($currentServicePrincipalObject.AppId)'"
            
                # If sign-in activity was found, add it to the cache - else add appId to no-signin-activity list
                if ($currentSpSigninActivity) {
                    $cacheServicePrincipalSigninActivities += $currentSpSigninActivity
                    Write-Verbose "Found servicePrincipal sign-in activity and added it to cache: $($currentServicePrincipalObject.displayName)"
                }

                else {
                    $cacheServicePrincipalsWithoutSigninActivities += $currentServicePrincipalObject.AppId
                    Write-Verbose "Did not find servicePrincipal sign-in activity: $($currentServicePrincipalObject.displayName)"
                }
            }
        }

        # Create reporting object
        [PSCustomObject][Ordered]@{
            AppRoleTier                                     = $appRoleTiers["$($currentAppRole.Value)"]
            ServicePrincipalDisplayName                     = $currentServicePrincipalObject.DisplayName
            ServicePrincipalId                              = $currentServicePrincipalObject.Id
            ServicePrincipalType                            = $currentServicePrincipalObject.ServicePrincipalType
            ServicePrincipalEnabled                         = $currentServicePrincipalObject.AccountEnabled
            AppId                                           = $currentServicePrincipalObject.AppId
            AppSignInAudience                               = $currentServicePrincipalObject.SignInAudience
            AppOwnerOrganizationTenantId                    = $currentServicePrincipalObject.AppOwnerOrganizationId
            AppOwnerOrganizationTenantName                  = $currentAppOwnerOrgObject.DisplayName
            AppOwnerOrganizationTenantDomain                = $currentAppOwnerOrgObject.DefaultDomainName
            Resource                                        = $currentAppRoleAssignedTo.ResourceDisplayName
            AppRole                                         = $currentAppRole.Value
            AppRoleAssignedDate                             = $(if ($currentAppRoleAssignedTo.CreatedDateTime) { (Get-Date $currentAppRoleAssignedTo.CreatedDateTime -Format 'yyyy-MM-dd') })
            AppRoleName                                     = $currentAppRole.DisplayName
            AppRoleDescription                              = $currentAppRole.Description
            LastSignInActivity                              = $currentSpSigninActivity.LastSignInActivity.LastSignInDateTime
            DelegatedClientSignInActivity                   = $currentSpSigninActivity.DelegatedClientSignInActivity.LastSignInDateTime
            DelegatedResourceSignInActivity                 = $currentSpSigninActivity.DelegatedResourceSignInActivity.LastSignInDateTime
            ApplicationAuthenticationClientSignInActivity   = $currentSpSigninActivity.ApplicationAuthenticationClientSignInActivity.LastSignInDateTime
            ApplicationAuthenticationResourceSignInActivity = $currentSpSigninActivity.ApplicationAuthenticationResourceSignInActivity.LastSignInDateTime
        }
    }

    $msGraphAppRoleAssignedToReport
}
#endregion

#region Get-PrivilegedAppRoleAssignment
function Get-PrivilegedAppRoleAssignment {
    [CmdletBinding()]
    param (
        [switch]$ShowExplanation,
        [switch]$OutputMarkdown
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $Explanation = @"

"@

    if ($ShowExplanation.IsPresent) {
        Write-Host $Explanation
    }
    $Output = Get-EntraIdPrivilegedAppRoleAssignment -ErrorAction SilentlyContinue -Verbose:$false
    # lots of properties. We need to reduce to make it fit in Markdown
    if ($OutputMarkdown.IsPresent) { $Output | Select-Object -Property @{ Name = 'Tier 0'; Expression = { $_.AppRoleTier -like "*Tier 0*" ? "Y" : "N" } }, @{ Name = 'AppRole'; Expression = { "$($_.AppRole) ($($_.AppRoleName))" } }, LastSignInActivity, ServicePrincipalDisplayName | ConvertTo-Markdown } else { $Output | Format-Table -AutoSize }
}
#endregion

#region Test-ConditionalAccessPolicy
function Test-ConditionalAccessPolicy {
    param (
        [switch]$OutputMarkdown,
        [switch]$OutputToHost,
        [parameter(ParameterSetName = "BlockLegacyProtocols")][switch]$BlockLegacyProtocols,
        [parameter(ParameterSetName = "MfaAdministrators")][switch]$MfaAdministrators,
        [parameter(ParameterSetName = "MfaAzureManagement")][switch]$MfaAzureManagement,
        [parameter(ParameterSetName = "RestrictedLocations")][switch]$RestrictedLocations,
        [parameter(ParameterSetName = "DeviceCompliance")][switch]$DeviceCompliance
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $CompliantText = ''
    $NonCompliantText = 'No valid CA Policy found'
    $Output = [pscustomobject] @{}
    ## BLOCK LEGACY PROTOCOLS ##
    if ($BlockLegacyProtocols.IsPresent) {
        # we are looking for a policy that is enabled, the control is block, includes all users, and condition is legacy clients
        $Filter = "state eq 'enabled' and grantControls/builtInControls/all(i:i eq 'block') and conditions/users/includeUsers/all(i:i eq 'All') and conditions/clientAppTypes/all(i:i eq 'exchangeActiveSync' or i eq 'other')"
        # need to use the beta API as v1.0 does not include policies made from templates
        $BlockLegacyProtocolPolicy = Get-MgBetaIdentityConditionalAccessPolicy -Filter $Filter

        # $BlockLegacyProtocolPolicy | Select-Object -Property DisplayName, Id

        $ExcludeUsers = $BlockLegacyProtocolPolicy.Conditions.Users.ExcludeUsers
        # $ExcludeGroups = $BlockLegacyProtocolPolicy.Conditions.Users.ExcludeGroups
        # $ExcludeGuestsOrExternalUsers = $BlockLegacyProtocolPolicy.Conditions.Users.ExcludeGuestsOrExternalUsers

        # TODO:
        # $ExcludeGroups
        # $ExcludeGuestsOrExternalUsers

        $Compliant = $null -ne $BlockLegacyProtocolPolicy -and $BlockLegacyProtocolPolicy.Count -gt 0
        $CompliantText = 'CA Policy found blocking legacy protocols'
        $NonCompliantText = 'No valid CA Policy found blocking legacy protocols'
    } # end if ($BlockLegacyProtocols.IsPresent)

    ## MFA FOR ADMINISTRATORS ##
    if ($MfaAdministrators.IsPresent) {
        # we are looking for a policy that is enabled, the control is MFA or authentication strenght, includes specific roles, and includes all applications
        $PrivilegedRolesList = "('62e90394-69f5-4237-9190-012177145e10','194ae4cb-b126-40b2-bd5b-6091b380977d','f28a1f50-f6e7-4571-818b-6a12f2af6b6c','29232cdf-9323-42fd-ade2-1d097af3e4de','b1be1c3e-b65d-4f19-8427-f6fa0d97feb9','729827e3-9c14-49f7-bb1b-9608f156bbb8','b0f54661-2d74-4c50-afa3-1ec803f12efe','fe930be7-5e62-47db-91af-98c3a49a38b1','c4e39bd9-1100-46d3-8c65-fb160da0071f','9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3','158c047a-c907-4556-b7ef-446551a6b5f7','966707d0-3269-4727-9be2-8c3a10f19b9d','7be44c8a-adaf-4e2a-84d6-ab2649e08a13','e8611ab8-c189-46e8-94e1-60213ab1f814')"
        # cannot filter on authenticationStrength: Invalid $filter: navigation property 'authenticationStrength' not found on type 'microsoft.graph.conditionalAccessPolicy'.
        # we will do this when checking if the policy is compliant
        $Filter = "state eq 'enabled' and conditions/applications/includeApplications/all(i:i eq 'All') and conditions/users/includeRoles/`$count gt 0 and conditions/users/includeRoles/all(i:i in $PrivilegedRolesList)" # and (grantControls/builtInControls/all(i:i eq 'mfa') or grantControls/authenticationStrength ne null)
        # need to use the beta API as v1.0 does not include policies made from templates
        $RequireMfaAdminsPolicy = Get-MgBetaIdentityConditionalAccessPolicy -Filter $Filter

        # $RequireMfaAdminsPolicy | Select-Object -Property DisplayName, Id

        $ExcludeUsers = $RequireMfaAdminsPolicy.Conditions.Users.ExcludeUsers
        # $ExcludeGroups = $RequireMfaAdminsPolicy.Conditions.Users.ExcludeGroups
        # $ExcludeGuestsOrExternalUsers = $RequireMfaAdminsPolicy.Conditions.Users.ExcludeGuestsOrExternalUsers

        # TODO:
        # $ExcludeGroups
        # $ExcludeGuestsOrExternalUsers

        if ($RequireMfaAdminsPolicy.Count -gt 1) {
            # $Compliant will become $false as we expect a single policy
            Write-Warning "Found multiple matching CA policies:`n$($RequireMfaAdminsPolicy.DisplayName -join ',')"
        }
        $Compliant = $null -ne $RequireMfaAdminsPolicy -and $RequireMfaAdminsPolicy.Count -eq 1 -and ('mfa' -in $RequireMfaAdminsPolicy.GrantControls.builtInControls -or $RequireMfaAdminsPolicy.GrantControls.authenticationStrength.requirementsSatisfied -eq 'mfa')
        $CompliantText = 'CA Policy found that requires administrators to use MFA or better'
        $NonCompliantText = 'No valid CA Policy found targeting Azure administrators'
    } # end if ($MfaAdministrators.IsPresent)

    ## ## MFA FOR AZURE MANAGEMENT ##
    if ($MfaAzureManagement.IsPresent) {
        # we are looking for a policy that is enabled, the control is MFA or authentication strenght, targeting application "Microsoft Azure Management"
        # cannot filter on authenticationStrength: Invalid $filter: navigation property 'authenticationStrength' not found on type 'microsoft.graph.conditionalAccessPolicy'.
        # we will do this when checking if the policy is compliant
        $Filter = "state eq 'enabled' and conditions/applications/includeApplications/all(i:i eq '797f4846-ba00-4fd7-ba43-dac1f8f63013') and conditions/users/includeRoles/`$count gt 0 and conditions/users/includeUsers/all(i:i eq 'All')" # and (grantControls/builtInControls/all(i:i eq 'mfa') or grantControls/authenticationStrength ne null)
        # need to use the beta API as v1.0 does not include policies made from templates
        $RequireMfaAdminsPolicy = Get-MgBetaIdentityConditionalAccessPolicy -Filter $Filter

        # $RequireMfaAdminsPolicy | Select-Object -Property DisplayName, Id

        $ExcludeUsers = $RequireMfaAdminsPolicy.Conditions.Users.ExcludeUsers
        # $ExcludeGroups = $RequireMfaAdminsPolicy.Conditions.Users.ExcludeGroups
        # $ExcludeGuestsOrExternalUsers = $RequireMfaAdminsPolicy.Conditions.Users.ExcludeGuestsOrExternalUsers

        # TODO:
        # $ExcludeGroups
        # $ExcludeGuestsOrExternalUsers

        if ($RequireMfaAdminsPolicy.Count -gt 1) {
            # $Compliant will become $false as we expect a single policy
            Write-Warning "Found multiple matching CA policies"
        }
        $Compliant = $null -ne $RequireMfaAdminsPolicy -and $RequireMfaAdminsPolicy.Count -eq 1 -and ('mfa' -in $RequireMfaAdminsPolicy.GrantControls.builtInControls -or $RequireMfaAdminsPolicy.GrantControls.authenticationStrength.requirementsSatisfied -eq 'mfa')
        $CompliantText = 'CA Policy found that requires MFA or better to use "Microsoft Azure Management"'
    }

    ## RESTRICTED LOCATIONS ##
    if ($RestrictedLocations.IsPresent) {
        # check if any named locations exist
        $NamedLocation = Get-MgBetaIdentityConditionalAccessNamedLocation

        $Compliant = $null -ne $NamedLocation -and $NamedLocation.Count -gt 0
        $CompliantText = "$($NamedLocation.Count) Named Locations are defined"
        $NonCompliantText = 'No Named Locations are defined'

        # we can look for policies that excludes locations under conditions but exclude those that block access
        $Filter = "state eq 'enabled' and conditions/locations/excludeLocations/all(i:i ne null) and grantControls/builtInControls/all(i:i ne 'block')"
        # need to use the beta API as v1.0 does not include policies made from templates
        $PoliciesLocationExclusion = Get-MgBetaIdentityConditionalAccessPolicy -Filter $Filter

        if ($PoliciesLocationExclusion.Count -gt 0) {
            Write-Warning "$($PoliciesLocationExclusion.Count) policies has location exclusions:"
            $Output = $PoliciesLocationExclusion | Select-Object -Property DisplayName, Id
        }
    }

    ## DEVICE COMPLIANCE ##
    if ($DeviceCompliance.IsPresent) {
        # we are looking for a policy that is enabled, the control is require device to be marked as compliant
        $Filter = "state eq 'enabled' and grantControls/builtInControls/`$count gt 0 and grantControls/builtInControls/all(i:i eq 'compliantDevice')"
        # need to use the beta API as v1.0 does not include policies made from templates
        $CompliantDevicePolicy = Get-MgBetaIdentityConditionalAccessPolicy -Filter $Filter

        # $CompliantDevicePolicy | Select-Object -Property DisplayName, Id

        $ExcludeUsers = $CompliantDevicePolicy.Conditions.Users.ExcludeUsers
        # $ExcludeGroups = $CompliantDevicePolicy.Conditions.Users.ExcludeGroups
        # $ExcludeGuestsOrExternalUsers = $CompliantDevicePolicy.Conditions.Users.ExcludeGuestsOrExternalUsers

        # TODO:
        # $ExcludeGroups
        # $ExcludeGuestsOrExternalUsers

        $Compliant = $null -ne $CompliantDevicePolicy -and $CompliantDevicePolicy.Count -gt 0
        $CompliantText = "CA Policy found that requires devices to be marked as compliant:`n$($CompliantDevicePolicy.DisplayName -join ',')"
    }

    # Output for use in markdown
    $Output = [pscustomobject] @{
        "Policy compliance" = $Compliant ? $CompliantText : $NonCompliantText
    }

    if ($OutputToHost.IsPresent) {
        if ($Compliant) {
            Write-Host "Compliant to control; $CompliantText" -ForegroundColor Green
            # only makes sense to show if the policy is compliant
            $ExcludeUsers | Where-Object { $null -ne $_ } | ForEach-Object {
                $ExcludedUser = Get-MgUser -Filter "id eq '$_'"
                Write-Host "Excluded user: $($ExcludedUser.DisplayName) ($($ExcludedUser.UserPrincipalName))"
            }        
        }
        else {
            Write-Host "Not compliant to control; $NonCompliantText" -ForegroundColor Red
        }
    }
    if ($OutputMarkdown.IsPresent) { 
        $Output | ConvertTo-Markdown 
        # only makes sense to show excluded users if the policy is compliant
        if ($Compliant) {
            $ExcludeUsers | Where-Object { $null -ne $_ } | ForEach-Object {
                $ExcludedUser = Get-MgUser -Filter "id eq '$_'"
                [pscustomobject] @{
                    "Excluded user" = "$($ExcludedUser.DisplayName) ($($ExcludedUser.UserPrincipalName))"
                }
            } | ConvertTo-Markdown      
        }
    }
    else { $Output | Format-Table -AutoSize }
}
#endregion

#region Test-ProtectedAction
function Test-ProtectedAction {
    [CmdletBinding()]
    param (
        [switch]$ShowExplanation
    )
    Write-Verbose "Running command: $($MyInvocation.MyCommand)"

    $Explanation = @"

"@

    if ($ShowExplanation.IsPresent) {
        Write-Host $Explanation
    }

    # look for protected actions with an authentication context
    # Get-MgBetaRoleManagementDirectoryResourceNamespaceResourceAction seems broken. what is UnifiedRbacResourceNamespaceId
    $Response = Invoke-MgGraphRequest -Method GET "https://graph.microsoft.com/beta/roleManagement/directory/resourceNamespaces/microsoft.directory/resourceActions?`$filter=isAuthenticationContextSettable eq true and authenticationContextId ne null&`$select=id,name,description,actionVerb,isEnabledForCustomRole,isAuthenticationContextSettable,authenticationContextId&`$top=500" -ErrorAction Stop
    # first we check if any protected actions are defined at all
    $ProtectedActions = $Response.value
    if ($ProtectedActions.Count -eq 0) {
        Write-Warning "No protected actions defined"
        return
    }
    # get all possible protected actions
    $Response = Invoke-MgGraphRequest -Method GET "https://graph.microsoft.com/beta/roleManagement/directory/resourceNamespaces/microsoft.directory/resourceActions?`$filter=isAuthenticationContextSettable eq true&`$select=id" -ErrorAction Stop
    Write-Host "$($ProtectedActions.Count) of $($Response.value.Count) protected actions are associated with an authentication context"

    $authenticationContextIds = ($ProtectedActions | Select-Object -ExpandProperty authenticationContextId) | Sort-Object -Unique
    # next we find if these are in use in any CA Policy
    $FilterArray = ($authenticationContextIds | ForEach-Object { "'$_'" }) -join ','
    $Filter = "state eq 'enabled' and grantControls/builtInControls/`$count gt 0 and conditions/applications/includeAuthenticationContextClassReferences/`$count gt 0 and conditions/applications/includeAuthenticationContextClassReferences/all(i:i in ($FilterArray))" 
    # need to use the beta API as v1.0 does not include policies made from templates
    $CompliantDevicePolicy = Get-MgBetaIdentityConditionalAccessPolicy -Filter $Filter
    if ($CompliantDevicePolicy.Count -eq 0) {
        Write-Warning "No CA policy uses the authentication context associated with a protected action"
    }
    else {
        # TODO: specify which action is protected by which policy. Most likely that they are all protected by a single policy, so not that important.
        Write-Host "Protected actions: $(($ProtectedActions | Select-Object -ExpandProperty description) -join ',') are protected by CA policies: $(($CompliantDevicePolicy | Select-Object -ExpandProperty DisplayName) -join ',')"
    }    
}
#endregion

#region Write-EntraIdAssessment
<#
This is the pure powershell based Entra ID assessment. It will strongly correlate to the experience in entra-id.ipynb (Notebook)
#>

function Write-EntraIdAssessment {
    [CmdletBinding()]
    param(
        [string]$TenantId,
        [string]$OutputFolder = ".\",
        [SecureString]$AccessToken = $null,
        [string]$StorageAccountName = $null,
        [string]$StorageAccountTenantId = $null,
        [string]$ContainerName = "entra-id"
    )

    Initialize-Notebook -TenantId $TenantId -AccessToken $AccessToken   

    #region Markdown here-string
    $Markdown = @"
# Entra ID Review for tenant $TenantId

## Users

### User States

Count the total number of users, disabled users, deleted users, and guest users.

$((Get-UserState -OutputMarkdown) -join "`n")

### Disabled Users

Find disabled users with group memberships or roles or licenses assigned.

Disabled users should not have roles or licenses assigned, and group memberships should at least be reviewed.

$((Get-DisabledUser -IncludeLicenseDetails -OutputMarkdown) -join "`n")

$((Get-DisabledUser -IncludeGroupMemberships -OutputMarkdown) -join "`n")

## Privileged Administration

### Limit the number of Global Administrators to less than 5

*Severity*: High

*Guid*: 9e6efe9d-f28f-463b-9bff-b5080173e9fe

[Entra ID best practice](https://learn.microsoft.com/en-us/azure/active-directory/roles/best-practices#5-limit-the-number-of-global-administrators-to-less-than-5)

*As a best practice, Microsoft recommends that you assign the Global Administrator role to fewer than five people in your organization...*

Global Administrators:

$((Get-GlobalAdminstrator -OutputMarkdown) -join "`n")

### Synchronized accounts

*Severity*: High

*Guid*: 87791be1-1eb0-48ed-8003-ad9bcf241b99

Do not synchronize accounts with the highest privilege access to on-premises resources as you synchronize your enterprise identity systems with cloud directories.

If below list any users then `onPremisesSyncEnabled` is true (and their account is enabled). Those should have the role removed, and a cloud-only user created as a replacement.

[Entra ID best practice](https://learn.microsoft.com/en-us/azure/security/fundamentals/identity-management-best-practices#centralize-identity-management)

*Do not synchronize accounts to Azure AD that have high privileges in your existing Active Directory instance...*

$((Get-SynchronizedAccount -OutputMarkdown) -join "`n")

### Use groups for Entra ID role assignments

*Work in Progress*

For now we can check the *Membership* column in [Privileged Identity Management | Azure AD roles](https://portal.azure.com/?feature.msaljs=true#view/Microsoft_Azure_PIMCommon/ResourceMenuBlade/~/members/resourceId//resourceType/tenant/provider/aadroles)

*Severity*: High

*Guid*: e0d968d3-87f6-41fb-a4f9-d852f1673f4c

[Best Practice: Use groups for Microsoft Entra role assignments and delegate the role assignment](https://learn.microsoft.com/en-us/azure/active-directory/roles/best-practices#7-use-groups-for-microsoft-entra-role-assignments-and-delegate-the-role-assignment)

*If you have an external governance system that takes advantage of groups, then you should consider assigning roles to Microsoft Entra groups, instead of individual users....*

<!-- Get-GroupsWithRoleAssignment -OutputMarkdown # WiP -->

### PIM Alerts

*Severity*: High

*Guid*: N/A

There should be no active alerts in PIM. If below identifies any active alerts go to [PIM alerts](https://portal.azure.com/#view/Microsoft_Azure_PIMCommon/ResourceMenuBlade/~/Alerts/resourceId//resourceType/tenant/provider/aadroles) for further details.

$((Get-PimAlert -OutputMarkdown) -join "`n")

We can also list affected principals. Note that in some cases there is no direct principal, ex. for the alert `NoMfaOnRoleActivationAlert`

$((Get-PimAlertAffectedPrincipal -OutputMarkdown) -join "`n")

### Recurring access reviews

*Severity*: High

*Guid*: eae64d01-0d3a-4ae1-a89d-cc1c2ad3888f

Configure recurring access reviews to revoke unneeded permissions over time.

[Best Practice: Configure recurring access reviews to revoke unneeded permissions over time](https://learn.microsoft.com/en-us/azure/active-directory/roles/best-practices#4-configure-recurring-access-reviews-to-revoke-unneeded-permissions-over-time)

If there are no access review definitions then there are no recurring access reviews.

$((Get-RecurringAccessReview -OutputMarkdown) -join "`n")

### Access Reviews: Enabled for all groups

*Severity*: Medium

*Guid*: e6b4bed3-d5f3-4547-a134-7dc56028a71f

[Plan a Microsoft Entra access reviews deployment](https://learn.microsoft.com/en-us/azure/active-directory/governance/deploy-access-reviews)

<!-- no code yet -->

### Apps and Owners Can Change All Group Membership

*Work in Progress*

Chad Cox: Group Membership changes to all groups, this script list every role and member (not pim eligible) with this capability , every application with the permission, and every owner of the application. Some of the permissions are to unified and some are to security. either way can you imagine if someone granted access to a group that gave them all kinds of access to teams sites or access to other cloud resources.

https://www.linkedin.com/posts/chad-cox-194bb560_entraid-aad-azuread-activity-7093368251329495040-Ff9T

https://github.com/chadmcox/Azure_Active_Directory/blob/master/Applications/get-AppsandOwnersCanChangeAllGroupMembership.ps1

TODO: does not show the display name for Owners of relevant apps

$((Test-AppOwnersChangeGroupMembership -OutputMarkdown) -join "`n")

### Avoid standing access for user accounts and permissions

*Work in Progress*

[MCSB: PA-2: Avoid standing access for user accounts and permissions](https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-2-avoid-standing-access-for-user-accounts-and-permissions)

$((Test-StandingAccess -OutputMarkdown) -join "`n")

## External Identities

### Guest invite settings

*Severity*: High

*Guid*: be64dd7d-f2e8-4bbb-a468-155abc9164e9

External Collaboration Settings: Guest invite settings set to `'Only users assigned to specific admin roles can invite guest users'` or `'No one in the organization can invite guest users including admins (most restrictive)'`

$((Test-GuestInviteSetting -OutputMarkdown) -join "`n")

### Guest user access restrictions

*Work in Progress*

*Severity*: High

*Guid*: 459c373e-7ed7-4162-9b37-5a917ecbe48f

External Collaboration Settings: Guest user access set to `'Guest user access is restricted to properties and memberships of their own directory objects (most restrictive)'`

<!-- code is work in progress -->

## User Setting

### User role permissions (Application registration)

*Severity*: High

*Guid*: a2cf2149-d013-4a92-9ce5-74dccbd8ac2a

Users can register applications should be set to `No`.

Users should not be allowed to register applications. Use specific roles such as `Application Developer`.

$((Test-UsersCanRegisterApplication -OutputMarkdown) -join "`n")

### Authentication Methods

*Work in Progress*

Check if authentication method policies are enabled or not

check if migration has already been done, and if not, can we check if the methods in the different places are enabled in the policy

<!-- code is work in progress -->

## Custom Domains

### Verified Domains

*Severity*: High

*Guid*: bade4aad-1e8c-439e-a946-667313c00567

Only validated customer domains are registered

$((Test-VerifiedDomain -OutputMarkdown) -join "`n")

## Enterprise Applications

### User consent for apps

*Severity*: Medium

*Guid*: 459c373e-7ed7-4162-9b37-5a917ecbe48f

Consent & Permissions: Allow user consent for apps from verified publishers

[Configure how users consent to applications](https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/configure-user-consent?pivots=ms-graph)

$((Test-UserConsentForApp -OutputMarkdown) -join "`n")

### Group Owner Consent

*Work in Progress*

*Severity*: Medium

*Guid*: 909aed8c-44cf-43b2-a381-8bafa2cf2149

Consent & Permissions: Allow group owner consent for selected group owners

[Configure group owner consent to applications](https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/configure-user-consent-groups?tabs=azure-portal)

<!-- code is work in progress -->

### Application Owners

*Severity*: High

*Guid*: N/A

MITRE ATT&CK tactics: [Persistence](https://attack.mitre.org/tactics/TA0003/), [Privilege Escalation](https://attack.mitre.org/tactics/TA0004/)

Credit: [Chad Cox](https://github.com/chadmcox) / [Applications/get-BuiltinAPPOwners.ps1](https://github.com/chadmcox/Azure_Active_Directory/blob/master/Applications/get-BuiltinAPPOwners.ps1)

Read here how these can be exploited: [Azure AD privilege escalation - Taking over default application permissions as Application Admin](https://dirkjanm.io/azure-ad-privilege-escalation-application-admin/) - Note that the Owner of the service principal can exploit this in the same way, hence why we look for owners.

Below code snippets look for various applications that are at an increased risk from having owners.

$((Find-OwnersFirstPartyMicrosoftApplication -OutputMarkdown) -join "`n")

Look for applications with application permission in Microsoft Graph and 1 or more owners assigned. Application permissions are often medium-high risk permissions.

$((Find-ApplicationsWithApplicationPermissionsAndOwner -OutputMarkdown) -join "`n")

Look for applications with owners and any resource access that we do not consider low-risk. The applications listed below is worth looking into.

These permissions are considered low risk:

$((Show-LowRiskApplicationPermission -OutputMarkdown) -join "`n")

Look for applications with owners and any resource access that we do not consider low-risk.

$((Find-ApplicationsNonLowRiskPermissionsAndOwner -OutputMarkdown) -join "`n")

### Applications with privileged app role assignments

All credit goes to [What's lurking in your Microsoft Graph app role assignments?](https://learningbydoing.cloud/blog/audit-ms-graph-app-role-assignments/)

$((Get-PrivilegedAppRoleAssignment -OutputMarkdown) -join "`n")

## Conditional Access Policies

### Block Legacy Protocols

*Severity*: High

*Guid*: 9e6efe9d-f28f-463b-9bff-b5080173e9fe

[Common Conditional Access policy: Block legacy authentication](https://learn.microsoft.com/en-us/azure/active-directory/conditional-access/howto-conditional-access-policy-block-legacy)

This policy, along with policies that enforce MFA are the most important to have. Legacy authentication will de-facto bypass MFA.

Below looks for a conditional access policy that blocks legacy protocols and also outputs users excluded.

$((Test-ConditionalAccessPolicy -BlockLegacyProtocols -OutputMarkdown) -join "`n")

## Require MFA for Administrators

*Severity*: High

*Guid*: fe1bd15d-d2f0-4d5e-972d-41e3611cc57b

[Common Conditional Access policy: Require MFA for administrators](https://learn.microsoft.com/en-us/azure/active-directory/conditional-access/howto-conditional-access-policy-admin-mfa)

Below looks for a conditional access policy that matches the policy template `"Require multifactor authentication for admins"`

$((Test-ConditionalAccessPolicy -MfaAdministrators -OutputMarkdown) -join "`n")

## Require MFA for Azure Management

*Severity*: High

*Guid*: 4a4b1410-d439-4589-ac22-89b3d6b57cfc

[Common Conditional Access policy: Require MFA for Azure management](https://learn.microsoft.com/en-us/azure/active-directory/conditional-access/howto-conditional-access-policy-azure-management)

Below looks for a conditional access policy that matches the policy template `"Require multifactor authentication for Azure management"`

$((Test-ConditionalAccessPolicy -MfaAzureManagement -OutputMarkdown) -join "`n")

## Restricted Locations

*Severity*: Medium

*Guid*: 079b588d-efc4-4972-ac3c-d21bf77036e5

[Using the location condition in a Conditional Access policy](https://learn.microsoft.com/en-us/azure/active-directory/conditional-access/location-condition)

Named locations can be used in numerous different way. A bad way to use them is to exclude from ex. enforcing MFA when coming from a `"trusted location"`. This does not conform to a `zero trust strategy`.

$((Test-ConditionalAccessPolicy -RestrictedLocations -OutputMarkdown) -join "`n")

## Require devices to be marked as compliant

*Severity*: High

*Guid*: 7ae9eab4-0fd3-4290-998b-c178bdc5a06c

[Require device to be marked as compliant](https://learn.microsoft.com/en-us/azure/active-directory/conditional-access/concept-conditional-access-grant#require-device-to-be-marked-as-compliant)

Requiring devices to be marked as compliant in CA policy grants can be a powerful way of ensuring that connections are made from devices that are managed by the organization. With sufficiently strict device configurations enforced, this can be combined with MFA, or just a standalone grant.

$((Test-ConditionalAccessPolicy -DeviceCompliance -OutputMarkdown) -join "`n")

## Protected Actions

Use [Protected Actions](https://learn.microsoft.com/en-us/azure/active-directory/roles/protected-actions-overview) to enforce strong authentcation and other strict grant controls when performing highly privileged actions, like `Delete conditional access policies`.

$((Test-ProtectedAction) -join "`n")
"@

    #endregion

    $FileName = "entra-id-$TenantId.md"
    $FilePath = "$OutputFolder\$FileName"
    $Markdown | Out-File -FilePath $FilePath

    # upload to Azure Blob Storage
    if ($null -ne $StorageAccountName -and $null -ne $StorageAccountTenantId) {
        # switch to the storage account tenant
        $null = Set-AzContext -TenantId $StorageAccountTenantId
        # Blob name is the same as the file name
        $BlobName = $FileName
        # create storage context with OAuth (Microsoft Entra ID) Authentication
        $StorageContext = New-AzStorageContext -StorageAccountName $StorageAccountName -UseConnectedAccount -ErrorAction Stop
        # create container if not exist
        $Container = Get-AzStorageContainer -Name $ContainerName -Context $StorageContext -ErrorAction SilentlyContinue
        if ($null -eq $Container) {
            $Container = New-AzStorageContainer -Name $ContainerName -Context $StorageContext
        }
        # upload file
        $null = Set-AzStorageBlobContent -Container $ContainerName -File $FilePath -Blob $BlobName -Context $StorageContext -Force
    }
}
#endregion