modules/ISC.EntraConnector.psm1

#Requires -Version 5.1
Set-StrictMode -Version Latest

$script:CreatedAppId = $null
$script:ServicePrincipalLookupErrors = @()

function Initialize-EntraConnectorData {
# API resources the connector can be granted permissions on.
$script:Resources = [ordered]@{
    # Microsoft Graph's well-known appId uses c000, not 0000, in the third group.
    Graph    = @{ AppId = '00000003-0000-0000-c000-000000000000'; Name = 'Microsoft Graph'; Required = $true }
    Defender = @{ AppId = 'fc780465-2017-40d4-a0c5-307022471b92'; Name = 'WindowsDefenderATP'; Required = $false }
    Exchange = @{ AppId = '00000002-0000-0ff1-ce00-000000000000'; Name = 'Office 365 Exchange Online'; Required = $false }
    AzureServiceManagement = @{ AppId = '797f4846-ba00-4fd7-ba43-dac1f8f63013'; Name = 'Windows Azure Service Management API'; Required = $false }
}

# -----------------------------------------------------------------------------
# Permissions - SailPoint Microsoft Entra ID connector required-permissions table
# https://documentation.sailpoint.com/connectors/saas/msentraid/help/saas_connectivity/microsoft_entra_id/administrator_permission.html
#
# User.Read and Directory.AccessAsUser.All appear in the table as delegated permissions for
# SAML bearer / auth-code / JWT grant types. This script configures client-credentials
# (client secret) auth, so they are intentionally not requested.
# -----------------------------------------------------------------------------

$script:CorePermissions = @(
    @{ Value = 'User.Invite.All';                   Purpose = 'Create / invite B2B user' }
    @{ Value = 'User.Read.All';                     Purpose = 'Account aggregation, delta, role and group membership' }
    @{ Value = 'User.ReadWrite.All';                Purpose = 'Create / update / enable / disable / delete user, licenses' }
    @{ Value = 'User.EnableDisableAccount.All';     Purpose = 'Delete user' }
    @{ Value = 'User-PasswordProfile.ReadWrite.All'; Purpose = 'Set password' }
    @{ Value = 'Organization.Read.All';             Purpose = 'Aggregate tenant license packs and plans' }
    @{ Value = 'Group.Read.All';                    Purpose = 'Group aggregation' }
    @{ Value = 'Group.ReadWrite.All';               Purpose = 'Create / update / delete group' }
    @{ Value = 'RoleManagement.Read.Directory';     Purpose = 'Directory role aggregation' }
    @{ Value = 'RoleManagement.ReadWrite.Directory'; Purpose = 'Add / remove directory roles' }
    @{ Value = 'Application.Read.All';              Purpose = 'Application role aggregation' }
    @{ Value = 'AppRoleAssignment.ReadWrite.All';   Purpose = 'Add / remove users from service principal' }
    @{ Value = 'DelegatedPermissionGrant.Read.All'; Purpose = 'Aggregate admin / user consented permissions' }
)

# Documented coarse alternative to the granular table.
$script:DirectoryPermissions = @(
    @{ Value = 'Directory.Read.All';      Purpose = 'Read directory data' }
    @{ Value = 'Directory.ReadWrite.All'; Purpose = 'Read and write directory data (excludes deleting users and groups)' }
)

$script:FeaturePacks = [ordered]@{
    AccessPackages = @{
        Label       = 'Access packages (entitlement management)'
        Permissions = @(
            @{ Value = 'EntitlementManagement.Read.All' }
            @{ Value = 'EntitlementManagement.ReadWrite.All' }
        )
    }
    MfaManagement = @{
        Label       = 'MFA / authentication method management'
        Permissions = @(
            @{ Value = 'UserAuthenticationMethod.Read.All' }
            @{ Value = 'UserAuthenticationMethod.ReadWrite.All' }
        )
    }
    ActivityInsights = @{
        Label       = 'Activity Insights (audit and sign-in logs)'
        Permissions = @(
            @{ Value = 'AuditLog.Read.All' }
        )
    }
    AdministrativeUnits = @{
        Label       = 'Administrative unit management'
        Permissions = @(
            @{ Value = 'AdministrativeUnit.Read.All' }
            @{ Value = 'AdministrativeUnit.ReadWrite.All' }
        )
    }
    ServicePrincipalProvisioning = @{
        Label       = 'Service principal account provisioning'
        Permissions = @(
            @{ Value = 'Application.ReadWrite.All' }
            @{ Value = 'Application.ReadWrite.OwnedBy' }
            @{ Value = 'DelegatedPermissionGrant.ReadWrite.All' }
        )
    }
    ExchangeOnline = @{
        Label       = 'Exchange Online mailbox management (also assign Exchange Administrator role + IQService cert)'
        Permissions = @(
            @{ Value = 'Exchange.ManageAsApp'; Resource = 'Exchange' }
        )
    }
    NhiDiscovery = @{
        Label       = 'NHI discovery - Azure cloud and NHI analysis'
        Permissions = @(
            @{ Value = 'Device.Read.All' }
        )
    }
    TeamsSecretScanning = @{
        Label       = 'NHI discovery - Microsoft Teams secret scanning'
        Permissions = @(
            @{ Value = 'Channel.ReadBasic.All' }
            @{ Value = 'ChannelMember.Read.All' }
            @{ Value = 'ChannelMessage.Read.All' }
            @{ Value = 'ChannelSettings.Read.All' }
            @{ Value = 'Chat.Read.All' }
            @{ Value = 'TeamsActivity.Read.All' }
            @{ Value = 'TeamsAppInstallation.ReadForChat.All' }
            @{ Value = 'TeamsAppInstallation.ReadForTeam.All' }
            @{ Value = 'TeamsAppInstallation.ReadForUser.All' }
            @{ Value = 'TeamsTab.Read.All' }
            @{ Value = 'TeamSettings.Read.All' }
        )
    }
    TeamsMessaging = @{
        Label       = 'NHI discovery - Microsoft Teams risk notifications'
        Permissions = @(
            @{ Value = 'TeamsAppInstallation.ReadWriteForTeam.All' }
            @{ Value = 'TeamsAppInstallation.ReadWriteForUser.All' }
            @{ Value = 'TeamsAppInstallation.ReadWriteSelfForUser.All' }
        )
    }
    SharePointScanning = @{
        Label       = 'NHI discovery - SharePoint and OneDrive secret scanning'
        Permissions = @(
            @{ Value = 'Files.Read.All' }
            @{ Value = 'Sites.Read.All' }
        )
    }
    CopilotDiscovery = @{
        Label       = 'Machine identity governance - Copilot Studio and Foundry agents'
        Permissions = @(
            @{ Value = 'AiEnterpriseInteraction.Read.All' }
            @{ Value = 'Reports.Read.All' }
            @{ Value = 'ExternalConnection.Read.All' }
            @{ Value = 'AppCatalog.Read.All' }
        )
        # Foundry agent aggregation reaches Azure Resource Manager to enumerate accounts and
        # projects, which the connector documentation gates behind this delegated scope. The Azure
        # RBAC roles it also needs (Reader, Cognitive Services Data Contributor) stay manual.
        DelegatedPermissions = @(
            @{ Value = 'user_impersonation'; Resource = 'AzureServiceManagement' }
        )
    }
    Agent365 = @{
        # The Agent 365 catalog endpoints reject application tokens, so this pack grants delegated
        # permissions and mints the refresh token ISC asks for in Machine Identity Governance Settings.
        Label                = 'Machine identity governance - Microsoft Agent 365 catalog (delegated refresh token)'
        Permissions          = @()
        DelegatedPermissions = @(
            @{ Value = 'CopilotPackages.Read.All' }
            @{ Value = 'CopilotPackages.ReadWrite.All' }
            @{ Value = 'User.Read' }
            @{ Value = 'offline_access' }
        )
    }
    DefenderHunting = @{
        Label       = 'NHI discovery - Microsoft Defender (WindowsDefenderATP API)'
        Permissions = @(
            # Advanced hunting is exposed as ThreatHunting.Read.All on Microsoft Graph; the legacy
            # WindowsDefenderATP resource names it AdvancedQuery.Read.All.
            @{ Value = 'Machine.Read.All';        Resource = 'Defender' }
            @{ Value = 'AdvancedQuery.Read.All';  Resource = 'Defender' }
            @{ Value = 'ThreatHunting.Read.All'; Resource = 'Graph' }
        )
    }
}

$script:DirectoryRoleMap = [ordered]@{
    None                = @()
    UserAdministrator   = @('User Administrator')
    PrivilegedAdmin     = @('User Administrator', 'Privileged Authentication Administrator')
    GlobalAdministrator = @('Global Administrator')
}
}

function Connect-EntraGraph {
    param(
        [string]$RequestedTenantId,
        [string[]]$AdditionalScopes = @()
    )

    $scopes = @(
        'Application.ReadWrite.All'
        'AppRoleAssignment.ReadWrite.All'
        'Directory.Read.All'
        'RoleManagement.ReadWrite.Directory'
    ) + @($AdditionalScopes | Where-Object { $_ })
    $scopes = @($scopes | Select-Object -Unique)

    $context = Get-MgContext -ErrorAction SilentlyContinue
    $needsConnect = $true
    if ($context) {
        $missingScopes = @($scopes | Where-Object { $context.Scopes -notcontains $_ })
        $tenantMismatch = $RequestedTenantId -and $context.TenantId -and
            ($RequestedTenantId -ne $context.TenantId) -and ($context.Account -notlike "*$RequestedTenantId")
        if ($missingScopes.Count -eq 0 -and -not $tenantMismatch) {
            $needsConnect = -not (Read-YesNo -Prompt "Already connected as $($context.Account). Reuse this session?" -Default $true)
        }
    }

    if ($needsConnect) {
        Write-Step 'Sign in to Microsoft Graph'
        $connectParams = @{ Scopes = $scopes }
        if ($RequestedTenantId) { $connectParams.TenantId = $RequestedTenantId }
        try {
            Connect-MgGraph @connectParams -NoWelcome -ErrorAction Stop
        }
        catch [System.Management.Automation.ParameterBindingException] {
            Connect-MgGraph @connectParams -ErrorAction Stop
        }
        $context = Get-MgContext
    }

    if (-not $context) { throw 'Microsoft Graph authentication failed.' }

    $orgName = $null
    $domainName = $null
    try {
        $org = Get-MgOrganization -ErrorAction Stop | Select-Object -First 1
        if ($org) {
            $orgName = $org.DisplayName
            $domains = @($org.VerifiedDomains)
            $initial = $domains | Where-Object { $_.IsInitial } | Select-Object -First 1
            $default = $domains | Where-Object { $_.IsDefault } | Select-Object -First 1
            if ($initial) { $domainName = $initial.Name }
            elseif ($default) { $domainName = $default.Name }
            elseif ($domains.Count -gt 0) { $domainName = $domains[0].Name }
        }
    }
    catch {
        Write-Warning "Could not read organization details: $($_.Exception.Message)"
    }

    $tenantLabel = if ($orgName) { "$orgName ($($context.TenantId))" } else { $context.TenantId }
    Write-Ok "Connected as $($context.Account)"
    Write-Ok "Tenant: $tenantLabel"

    return [PSCustomObject]@{
        TenantId   = $context.TenantId
        Account    = $context.Account
        OrgName    = $orgName
        DomainName = $domainName
    }
}

# -----------------------------------------------------------------------------
# Permission resolution
# -----------------------------------------------------------------------------

function Get-SelectedPermissions {
    param(
        [string]$Mode,
        [string[]]$FeatureNames,
        [string]$Access = ''
    )

    if ($Access -eq 'ReadOnly' -and -not (Get-Module -Name 'IW.Access')) {
        Import-Module (Join-Path $PSScriptRoot 'IW.Access.psm1') -Force -ErrorAction Stop
    }

    $selected = [System.Collections.Generic.List[object]]::new()
    if ($Mode -eq 'Directory') {
        $base = if ($Access -eq 'ReadOnly') {
            @($script:DirectoryPermissions | Where-Object { $_.Value -eq 'Directory.Read.All' })
        }
        else {
            $script:DirectoryPermissions
        }
    }
    else {
        $base = $script:CorePermissions
    }
    foreach ($permission in $base) {
        $selected.Add([PSCustomObject]@{
            Resource = 'Graph'
            Value    = $permission.Value
            Pack     = 'Core'
        })
    }

    foreach ($name in @($FeatureNames)) {
        if ([string]::IsNullOrWhiteSpace($name)) { continue }
        if ($name -eq 'Ciem') { continue }
        if (-not $script:FeaturePacks.Contains($name)) { continue }
        foreach ($permission in $script:FeaturePacks[$name].Permissions) {
            $resource = if ($permission.ContainsKey('Resource')) { $permission.Resource } else { 'Graph' }
            $selected.Add([PSCustomObject]@{
                Resource = $resource
                Value    = $permission.Value
                Pack     = $name
            })
        }
    }

    if (Get-Command Merge-EntraCiemPermissions -ErrorAction SilentlyContinue) {
        foreach ($item in @(Merge-EntraCiemPermissions -FeatureNames $FeatureNames)) {
            $selected.Add($item)
        }
    }

    $unique = @($selected | Sort-Object Resource, Value -Unique)
    if ($Access -ne 'ReadOnly') { return $unique }
    return @($unique | Where-Object {
        Test-RequirementIncludedForAccess -Kind 'MicrosoftGraphPermission' -Name $_.Value -Access 'ReadOnly'
    })
}

# Delegated permissions are kept apart from the application permissions above because they are
# consented and carried differently: they only take effect inside a user token, which this script
# obtains through the authorization-code flow rather than through client credentials.
function Get-SelectedDelegatedPermissions {
    param(
        [string[]]$FeatureNames,
        [string]$Access = ''
    )

    if ($Access -eq 'ReadOnly' -and -not (Get-Module -Name 'IW.Access')) {
        Import-Module (Join-Path $PSScriptRoot 'IW.Access.psm1') -Force -ErrorAction Stop
    }

    $selected = [System.Collections.Generic.List[object]]::new()
    foreach ($name in @($FeatureNames)) {
        if ([string]::IsNullOrWhiteSpace($name)) { continue }
        if (-not $script:FeaturePacks.Contains($name)) { continue }
        $pack = $script:FeaturePacks[$name]
        if (-not $pack.Contains('DelegatedPermissions')) { continue }
        foreach ($permission in $pack.DelegatedPermissions) {
            $resource = if ($permission.ContainsKey('Resource')) { $permission.Resource } else { 'Graph' }
            $selected.Add([PSCustomObject]@{
                Resource = $resource
                Value    = $permission.Value
                Pack     = $name
            })
        }
    }

    $unique = @($selected | Sort-Object Resource, Value -Unique)
    if ($Access -ne 'ReadOnly') { return $unique }
    return @($unique | Where-Object {
        Test-RequirementIncludedForAccess -Kind 'MicrosoftGraphPermission' -Name $_.Value -Access 'ReadOnly'
    })
}

function Get-EntraIscFeatureChecklist {
    param([string[]]$FeatureNames)

    $items = [System.Collections.Generic.List[string]]::new()
    $items.Add('Connection Settings: enable Use Continuous Access Evaluation if your tenant uses CAE.')
    $items.Add('Feature Management: enable Manage Microsoft 365 Groups when governing Teams.')

    if (@($FeatureNames) -contains 'ActivityInsights') {
        $items.Add('Enable Activity Insights on the source (requires Entra ID P1 or P2).')
    }
    if (@($FeatureNames) -contains 'AccessPackages') {
        $items.Add('Feature Management: enable Manage Access Packages.')
    }
    if (@($FeatureNames) -contains 'AdministrativeUnits') {
        $items.Add('Feature Management: enable Manage Administrative Units.')
    }
    if (@($FeatureNames) -contains 'ServicePrincipalProvisioning') {
        $items.Add('Feature Management: enable Manage Microsoft Entra Service Principals as Accounts.')
        # Entra types the identities Foundry and Copilot Studio mint for agents as ServiceIdentity,
        # so the connector default silently drops every agent identity.
        $items.Add('Service Principal Account Filter: the default servicePrincipalType eq ''Application'' excludes agent identities. Use servicePrincipalType eq ''Application'' or servicePrincipalType eq ''ServiceIdentity'' to govern Foundry and Copilot Studio agents.')
    }
    if (@($FeatureNames) -contains 'ExchangeOnline') {
        $items.Add('Feature Management: enable Manage Exchange Online (requires IQService + certificate on the app).')
        $items.Add('Assign Exchange Administrator directory role to the app registration.')
    }
    if (Test-EmbeddedCiemSelected -FeatureNames $FeatureNames) {
        $items.Add('Feature Management: enable Privileged Identity Management for Entra PIM group eligibility (CIEM).')
    }
    elseif (@($FeatureNames) -contains 'ServicePrincipalProvisioning') {
        $items.Add('If governing PIM role memberships on service principals, enable Privileged Identity Management in ISC.')
    }
    if (@($FeatureNames) -contains 'CopilotDiscovery') {
        $items.Add('Machine Identity Governance: enable Azure AI Foundry Agents and/or Microsoft Copilot Studio Agents.')
        $items.Add('For Agent 365 catalog aggregation, add -Feature Agent365 so the delegated refresh token is issued.')
        $items.Add('For Foundry: assign Reader and Cognitive Services Data Contributor (Preview) on each Azure subscription. Without both, the Foundry dataset aggregation reaches neither ARM nor the agents data plane and returns nothing.')
        $items.Add('For Foundry: add the Azure Service Management user_impersonation delegated permission to the app registration.')
        $items.Add('Agent identities are servicePrincipalType ServiceIdentity; widen the Service Principal Account Filter or they never aggregate as accounts.')
        # Copilot Studio agents live in Dataverse, not Azure, so Entra permissions alone never reach
        # them. The connector authenticates to each Power Platform environment as an application user.
        $items.Add('For Copilot Studio: add the app registration as a Dataverse application user in every Power Platform environment that holds agents.')
        $items.Add('For Copilot Studio: give that application user the Global Discovery Service Role plus a custom BotReader role granting organization-level Read on bot and botcomponent.')
        $items.Add('For Copilot Studio: do not rely on the built-in Bot Viewer role; it reads only owned records, so an application user sees zero agents.')
    }
    if (@($FeatureNames) -contains 'Agent365') {
        $items.Add('Machine Identity Governance: enable Microsoft Agent 365 and select the catalog platforms.')
        $items.Add('Machine Identity Governance: paste the Agent 365 refresh token. Client Credentials sources fail aggregation without it.')
        $items.Add('The account that authorized the refresh token needs AI Administrator (or Global Administrator) and a Microsoft Agent 365 license.')
    }
    $nhiPacks = @('NhiDiscovery', 'TeamsSecretScanning', 'SharePointScanning', 'DefenderHunting')
    if (@($FeatureNames | Where-Object { $nhiPacks -contains $_ }).Count -gt 0) {
        $items.Add('NHI Discovery features require SailPoint Agentic Fabric license on the tenant.')
    }
    $items.Add('Managed identities: assign Managed Identity Operator (or Reader) at Tenant Root for user-assigned identities.')
    $items.Add('Azure PIM for Azure resource roles: assign Owner or User Access Administrator on subscriptions (CIEM license).')

    return @($items)
}

# Why each lookup failed, so a failure to find a service principal can explain itself.
$script:ServicePrincipalLookupErrors = [System.Collections.Generic.List[string]]::new()

function Get-ServicePrincipalByAppId {
    param([Parameter(Mandatory)][string]$AppId)

    # The alternate-key REST call is tried first because Get-MgServicePrincipal -Filter returns an
    # empty result on some SDK versions even when the service principal exists.
    try {
        $sp = Invoke-MgGraphRequest -Method GET -Uri "/v1.0/servicePrincipals(appId='$AppId')" -OutputType PSObject -ErrorAction Stop
        if ($sp) { return $sp }
        $script:ServicePrincipalLookupErrors.Add("servicePrincipals(appId='$AppId') returned nothing")
    }
    catch {
        $script:ServicePrincipalLookupErrors.Add("servicePrincipals(appId='$AppId'): $($_.Exception.Message)")
    }

    try {
        $sp = Get-MgServicePrincipal -Filter "appId eq '$AppId'" -ErrorAction Stop | Select-Object -First 1
        if ($sp) { return $sp }
        $script:ServicePrincipalLookupErrors.Add("Get-MgServicePrincipal -Filter returned nothing")
    }
    catch {
        $script:ServicePrincipalLookupErrors.Add("Get-MgServicePrincipal -Filter: $($_.Exception.Message)")
    }

    return $null
}

function Get-ApiServicePrincipal {
    param(
        [Parameter(Mandatory)][string]$AppId,
        [Parameter(Mandatory)][string]$Name
    )

    $script:ServicePrincipalLookupErrors.Clear()

    # Retried because throttling and transient 5xx responses are the usual reason a service
    # principal that certainly exists comes back empty.
    for ($attempt = 1; $attempt -le 3; $attempt++) {
        $sp = Get-ServicePrincipalByAppId -AppId $AppId
        if ($sp) { return $sp }
        if ($attempt -lt 3) { Start-Sleep -Seconds (2 * $attempt) }
    }

    # Last resort for tenants where filtering on appId is unreliable. Only identifiers are listed:
    # selecting appRoles across every service principal is the query most likely to time out, so
    # the roles are fetched afterwards from the one match.
    Write-Info "Searching the tenant service principal list for $Name ..."
    try {
        $match = Get-MgServicePrincipal -All -Property 'id,appId,displayName' -ErrorAction Stop |
            Where-Object { $_.AppId -eq $AppId } | Select-Object -First 1
        if ($match) { return Get-MgServicePrincipal -ServicePrincipalId $match.Id -ErrorAction Stop }
        $script:ServicePrincipalLookupErrors.Add('the tenant service principal list contains no such appId')
    }
    catch {
        $script:ServicePrincipalLookupErrors.Add("service principal enumeration: $($_.Exception.Message)")
    }

    return $null
}

function Resolve-AppRoles {
    param(
        [Parameter(Mandatory)]$ServicePrincipal,
        [Parameter(Mandatory)][string[]]$PermissionValues,
        [Parameter(Mandatory)][string]$ResourceName
    )

    # REST responses expose 'appRoles', SDK objects expose 'AppRoles'; member lookup is case-insensitive.
    $appRolesProperty = $ServicePrincipal.PSObject.Properties['AppRoles']
    $appRoles = @()
    if ($appRolesProperty) { $appRoles = @($appRolesProperty.Value) }
    if ($appRoles.Count -eq 0) {
        throw "$ResourceName exposes no application roles. The signed-in account may lack Directory.Read.All."
    }

    $resolved = [System.Collections.Generic.List[object]]::new()
    foreach ($value in $PermissionValues) {
        $role = $appRoles |
            Where-Object { $_.Value -eq $value -and $_.AllowedMemberTypes -contains 'Application' } |
            Select-Object -First 1
        if (-not $role) {
            Write-Warning "$ResourceName does not expose '$value' as an application permission in this tenant. Skipping."
            continue
        }
        $resolved.Add([PSCustomObject]@{ Id = $role.Id; Value = $value })
    }
    return @($resolved)
}

function Resolve-DelegatedScopes {
    param(
        [Parameter(Mandatory)]$ServicePrincipal,
        [Parameter(Mandatory)][string[]]$PermissionValues,
        [Parameter(Mandatory)][string]$ResourceName
    )

    $scopeProperty = $ServicePrincipal.PSObject.Properties['Oauth2PermissionScopes']
    $scopes = @()
    if ($scopeProperty) { $scopes = @($scopeProperty.Value) }
    if ($scopes.Count -eq 0) {
        throw "$ResourceName exposes no delegated permissions. The signed-in account may lack Directory.Read.All."
    }

    $resolved = [System.Collections.Generic.List[object]]::new()
    foreach ($value in $PermissionValues) {
        $scope = $scopes | Where-Object { $_.Value -eq $value } | Select-Object -First 1
        if (-not $scope) {
            Write-Warning "$ResourceName does not expose '$value' as a delegated permission in this tenant. Skipping."
            continue
        }
        $resolved.Add([PSCustomObject]@{ Id = $scope.Id; Value = $value })
    }
    return @($resolved)
}

# -----------------------------------------------------------------------------
# Application operations
# -----------------------------------------------------------------------------

function Find-EntraApplicationByName {
    param([Parameter(Mandatory)][string]$DisplayName)

    $escaped = $DisplayName.Replace("'", "''")
    return @(Get-MgApplication -Filter "displayName eq '$escaped'" -All -ErrorAction Stop)
}

function New-EntraConnectorApplication {
    param(
        [Parameter(Mandatory)][string]$DisplayName
    )

    $body = @{
        DisplayName    = $DisplayName
        SignInAudience = 'AzureADMyOrg'
        Description    = 'SailPoint Identity Security Cloud - Microsoft Entra ID connector'
    }

    $app = New-MgApplication -BodyParameter $body -ErrorAction Stop
    $script:CreatedAppId = $app.AppId

    # The new application can take a few seconds to replicate before a service principal can reference it.
    $sp = $null
    for ($attempt = 1; $attempt -le 4; $attempt++) {
        try {
            $sp = New-MgServicePrincipal -AppId $app.AppId -ErrorAction Stop
            break
        }
        catch {
            if ($attempt -eq 4) { throw }
            Start-Sleep -Seconds (2 * $attempt)
        }
    }

    return [PSCustomObject]@{ Application = $app; ServicePrincipal = $sp }
}

function Get-EntraConnectorApplication {
    param([Parameter(Mandatory)][string]$ApplicationObjectId)

    $app = Get-MgApplication -ApplicationId $ApplicationObjectId -ErrorAction Stop
    $sp = Get-ServicePrincipalByAppId -AppId $app.AppId
    if (-not $sp) {
        Write-Warning 'The application has no service principal. Creating one.'
        $sp = New-MgServicePrincipal -AppId $app.AppId -ErrorAction Stop
    }
    return [PSCustomObject]@{ Application = $app; ServicePrincipal = $sp }
}

function Set-RequiredResourceAccess {
    param(
        [Parameter(Mandatory)]$Application,
        [Parameter(Mandatory)][hashtable]$RolesByResource,
        [hashtable]$ScopesByResource = @{}
    )

    $managedKeys = @(@($RolesByResource.Keys) + @($ScopesByResource.Keys) | Select-Object -Unique)
    $managedAppIds = @(foreach ($key in $managedKeys) { $script:Resources[$key].AppId })

    $required = [System.Collections.Generic.List[object]]::new()

    # Preserve entries for resources this script does not manage.
    foreach ($entry in @($Application.RequiredResourceAccess)) {
        if ($managedAppIds -contains $entry.ResourceAppId) { continue }
        $required.Add(@{
            ResourceAppId  = $entry.ResourceAppId
            ResourceAccess = @(foreach ($ra in $entry.ResourceAccess) { @{ Id = $ra.Id; Type = $ra.Type } })
        })
    }

    foreach ($key in $managedKeys) {
        $access = [System.Collections.Generic.List[object]]::new()
        foreach ($role in @($RolesByResource[$key])) {
            if ($role) { $access.Add(@{ Id = $role.Id; Type = 'Role' }) }
        }
        foreach ($scope in @($ScopesByResource[$key])) {
            if ($scope) { $access.Add(@{ Id = $scope.Id; Type = 'Scope' }) }
        }
        if ($access.Count -eq 0) { continue }
        $required.Add(@{
            ResourceAppId  = $script:Resources[$key].AppId
            ResourceAccess = $access.ToArray()
        })
    }

    Update-MgApplication -ApplicationId $Application.Id -RequiredResourceAccess $required.ToArray() -ErrorAction Stop
}

function Set-EntraRedirectUri {
    param(
        [Parameter(Mandatory)]$Application,
        [Parameter(Mandatory)][string]$RedirectUri
    )

    $existing = @()
    $web = $Application.PSObject.Properties['Web']
    if ($web -and $web.Value) {
        $uris = $web.Value.PSObject.Properties['RedirectUris']
        if ($uris) { $existing = @($uris.Value | Where-Object { $_ }) }
    }
    if ($existing -contains $RedirectUri) {
        Write-Info "Redirect URI already registered: $RedirectUri"
        return @($existing)
    }

    $updated = @($existing) + $RedirectUri
    Update-MgApplication -ApplicationId $Application.Id -Web @{ RedirectUris = $updated } -ErrorAction Stop
    Write-Ok "Registered redirect URI: $RedirectUri"
    return @($updated)
}

function Grant-AppRoleConsent {
    param(
        [Parameter(Mandatory)]$ServicePrincipal,
        [Parameter(Mandatory)]$ResourceServicePrincipal,
        [Parameter(Mandatory)]$AppRoles
    )

    $existing = @()
    try {
        $existing = @(Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $ServicePrincipal.Id -All -ErrorAction Stop)
    }
    catch {
        Write-Warning "Could not list existing app role assignments: $($_.Exception.Message)"
    }

    $granted = 0
    $skipped = 0
    $failed = [System.Collections.Generic.List[string]]::new()

    foreach ($role in $AppRoles) {
        $already = $existing | Where-Object { $_.AppRoleId -eq $role.Id -and $_.ResourceId -eq $ResourceServicePrincipal.Id }
        if ($already) {
            $skipped++
            continue
        }

        $body = @{
            PrincipalId = $ServicePrincipal.Id
            ResourceId  = $ResourceServicePrincipal.Id
            AppRoleId   = $role.Id
        }

        # A newly created service principal can take a few seconds to replicate.
        $attempt = 0
        while ($true) {
            $attempt++
            try {
                New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $ServicePrincipal.Id -BodyParameter $body -ErrorAction Stop | Out-Null
                Write-Ok "Consent granted: $($role.Value)"
                $granted++
                break
            }
            catch {
                if ($_.Exception.Message -match 'already exists|Permission being assigned already exists') {
                    $skipped++
                    break
                }
                if ($attempt -lt 3) {
                    Start-Sleep -Seconds (2 * $attempt)
                    continue
                }
                Write-Warning "Failed to grant $($role.Value): $($_.Exception.Message)"
                $failed.Add($role.Value)
                break
            }
        }
        Start-Sleep -Milliseconds 300
    }

    if ($skipped -gt 0) { Write-Info "$skipped permission(s) already consented" }
    return [PSCustomObject]@{ Granted = $granted; Skipped = $skipped; Failed = @($failed) }
}

# Tenant-wide consent for delegated permissions is a single oauth2PermissionGrant per resource whose
# scope is one space-delimited string, so an existing grant is merged into rather than duplicated.
function Grant-DelegatedConsent {
    param(
        [Parameter(Mandatory)]$ServicePrincipal,
        [Parameter(Mandatory)]$ResourceServicePrincipal,
        [Parameter(Mandatory)]$Scopes
    )

    $wanted = @(@($Scopes) | ForEach-Object { [string]$_.Value })
    if ($wanted.Count -eq 0) {
        return [PSCustomObject]@{ Granted = @(); Skipped = @(); Failed = @() }
    }

    $existing = $null
    try {
        $grants = Invoke-MgGraphRequest -Method GET -OutputType PSObject -ErrorAction Stop `
            -Uri "/v1.0/servicePrincipals/$($ServicePrincipal.Id)/oauth2PermissionGrants"
        $existing = @($grants.value) |
            Where-Object { $_.resourceId -eq $ResourceServicePrincipal.Id -and $_.consentType -eq 'AllPrincipals' } |
            Select-Object -First 1
    }
    catch {
        Write-Verbose "Could not list delegated permission grants: $($_.Exception.Message)"
    }

    $current = @()
    if ($existing) {
        $scopeProperty = $existing.PSObject.Properties['scope']
        if ($scopeProperty -and $scopeProperty.Value) {
            $current = @([string]$scopeProperty.Value -split '\s+' | Where-Object { $_ })
        }
    }

    $missing = @($wanted | Where-Object { $current -notcontains $_ })
    if ($missing.Count -eq 0) {
        Write-Info "$($wanted.Count) delegated permission(s) already consented"
        return [PSCustomObject]@{ Granted = @(); Skipped = @($wanted); Failed = @() }
    }

    $merged = (@(@($current) + $missing | Select-Object -Unique)) -join ' '

    # A newly created service principal can take a few seconds to replicate.
    for ($attempt = 1; $attempt -le 3; $attempt++) {
        try {
            if ($existing) {
                Invoke-MgGraphRequest -Method PATCH -Uri "/v1.0/oauth2PermissionGrants/$($existing.id)" `
                    -Body @{ scope = $merged } -ErrorAction Stop | Out-Null
            }
            else {
                Invoke-MgGraphRequest -Method POST -Uri '/v1.0/oauth2PermissionGrants' -ErrorAction Stop -Body @{
                    clientId    = $ServicePrincipal.Id
                    consentType = 'AllPrincipals'
                    resourceId  = $ResourceServicePrincipal.Id
                    scope       = $merged
                } | Out-Null
            }
            foreach ($value in $missing) { Write-Ok "Delegated consent granted: $value" }
            return [PSCustomObject]@{ Granted = @($missing); Skipped = @($current); Failed = @() }
        }
        catch {
            if ($attempt -lt 3) {
                Start-Sleep -Seconds (2 * $attempt)
                continue
            }
            Write-Warning "Failed to grant delegated consent: $($_.Exception.Message)"
            return [PSCustomObject]@{ Granted = @(); Skipped = @(); Failed = @($missing) }
        }
    }
}

# Role membership reads from the service principal side in one call. Returns $null when the
# membership cannot be read, so callers fall back to inspecting each role's member list.
function Get-ServicePrincipalRoleMembership {
    param([Parameter(Mandatory)]$ServicePrincipal)

    $roleIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    try {
        $objects = @(Get-MgServicePrincipalMemberOf -ServicePrincipalId $ServicePrincipal.Id -All -ErrorAction Stop)
    }
    catch {
        Write-Verbose "Could not read role membership of the service principal: $($_.Exception.Message)"
        return $null
    }

    foreach ($object in $objects) {
        if ($object.Id) { [void]$roleIds.Add([string]$object.Id) }
    }
    return $roleIds
}

function Add-EntraDirectoryRoles {
    param(
        [Parameter(Mandatory)]$ServicePrincipal,
        [string[]]$RoleDisplayNames
    )

    # directoryRoles rejects $filter on displayName in some tenants, so the activated roles are
    # listed once and matched locally.
    $activatedRoles = @()
    try { $activatedRoles = @(Get-MgDirectoryRole -All -ErrorAction Stop) }
    catch { Write-Verbose "Could not list activated directory roles: $($_.Exception.Message)" }

    $memberOfRoleIds = Get-ServicePrincipalRoleMembership -ServicePrincipal $ServicePrincipal

    $assigned = [System.Collections.Generic.List[string]]::new()
    foreach ($displayName in @($RoleDisplayNames)) {
        $role = $activatedRoles | Where-Object { $_.DisplayName -eq $displayName } | Select-Object -First 1

        if (-not $role) {
            $template = Get-MgDirectoryRoleTemplate -All -ErrorAction Stop |
                Where-Object { $_.DisplayName -eq $displayName } | Select-Object -First 1
            if (-not $template) {
                Write-Warning "Directory role template not found: $displayName"
                continue
            }
            $role = New-MgDirectoryRole -RoleTemplateId $template.Id -ErrorAction Stop
            Write-Info "Activated directory role: $displayName"
        }

        $isMember = $false
        if ($null -ne $memberOfRoleIds) {
            $isMember = $memberOfRoleIds.Contains([string]$role.Id)
        }
        else {
            try {
                $members = @(Get-MgDirectoryRoleMember -DirectoryRoleId $role.Id -All -ErrorAction Stop)
                $isMember = [bool]($members | Where-Object { $_.Id -eq $ServicePrincipal.Id })
            }
            catch {
                Write-Verbose "Could not enumerate members of ${displayName}: $($_.Exception.Message)"
            }
        }

        if ($isMember) {
            Write-Info "Already assigned: $displayName"
            $assigned.Add($displayName)
            continue
        }

        try {
            New-MgDirectoryRoleMemberByRef -DirectoryRoleId $role.Id -BodyParameter @{
                '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$($ServicePrincipal.Id)"
            } -ErrorAction Stop
            Write-Ok "Assigned directory role: $displayName"
            $assigned.Add($displayName)
        }
        catch {
            # Graph reports an existing membership as a bad request on the role's members collection.
            if ($_.Exception.Message -match 'already exist') {
                Write-Info "Already assigned: $displayName"
                $assigned.Add($displayName)
                continue
            }
            Write-Warning "Failed to assign ${displayName}: $($_.Exception.Message)"
        }
    }
    return @($assigned)
}

function New-EntraClientSecret {
    param(
        [Parameter(Mandatory)]$Application,
        [Parameter(Mandatory)][string]$DisplayName,
        [Parameter(Mandatory)][int]$ValidityMonths
    )

    $result = Add-MgApplicationPassword -ApplicationId $Application.Id -PasswordCredential @{
        DisplayName = $DisplayName
        EndDateTime = (Get-Date).ToUniversalTime().AddMonths($ValidityMonths)
    } -ErrorAction Stop

    if ([string]::IsNullOrWhiteSpace($result.SecretText)) {
        throw 'The client secret was created but its value was not returned. Create one in the Entra portal instead.'
    }
    return $result
}

# -----------------------------------------------------------------------------
# Agent 365 refresh token - OAuth 2.0 authorization-code flow
#
# The Microsoft Agent 365 catalog endpoints accept delegated tokens only, so a Client Credentials
# source cannot aggregate them from its own token. ISC redeems this refresh token instead, which is
# why it is pasted into Machine Identity Governance Settings rather than Connection Settings.
# https://documentation.sailpoint.com/connectors/microsoft/entra_id/help/integrating_entra_id/generating_refresh_tokens.html
# -----------------------------------------------------------------------------

$script:EntraAuthCodeTimeoutSeconds = 300
$script:Agent365Scopes = @('offline_access', 'https://graph.microsoft.com/.default')

function Get-Agent365Scopes {
    return @($script:Agent365Scopes)
}

function Get-EntraAuthorizationUrl {
    param(
        [Parameter(Mandatory)][string]$TenantId,
        [Parameter(Mandatory)][string]$ClientId,
        [Parameter(Mandatory)][string]$RedirectUri,
        [Parameter(Mandatory)][string[]]$Scopes,
        [Parameter(Mandatory)][string]$State
    )

    $query = @(
        "client_id=$([uri]::EscapeDataString($ClientId))"
        'response_type=code'
        "redirect_uri=$([uri]::EscapeDataString($RedirectUri))"
        'response_mode=query'
        "scope=$([uri]::EscapeDataString(($Scopes -join ' ')))"
        "state=$([uri]::EscapeDataString($State))"
        'prompt=select_account'
    ) -join '&'

    return "https://login.microsoftonline.com/$([uri]::EscapeDataString($TenantId))/oauth2/v2.0/authorize?$query"
}

# Blocks on the loopback redirect until Entra sends the code back, so the operator never has to copy
# anything out of the address bar.
function Wait-EntraAuthorizationCode {
    param(
        [Parameter(Mandatory)][string]$RedirectUri,
        [Parameter(Mandatory)][string]$State
    )

    $prefix = $RedirectUri.TrimEnd('/') + '/'
    $listener = [System.Net.HttpListener]::new()
    $listener.Prefixes.Add($prefix)
    try {
        $listener.Start()
    }
    catch {
        throw "Could not listen on $prefix. Choose a free port with -Agent365RedirectUri. $($_.Exception.Message)"
    }

    try {
        Write-Info "Waiting for the Entra redirect on $prefix (Ctrl+C to cancel) ..."
        # A blocked sign-in never redirects here, so the wait is bounded rather than hanging until
        # Ctrl+C. Polling also keeps Ctrl+C responsive.
        $pending = $listener.GetContextAsync()
        $deadline = (Get-Date).AddSeconds($script:EntraAuthCodeTimeoutSeconds)
        while (-not $pending.Wait(500)) {
            if ((Get-Date) -gt $deadline) {
                throw ("No redirect arrived on $prefix within $([int]($script:EntraAuthCodeTimeoutSeconds / 60)) minutes. " +
                    'Confirm the redirect URI is registered on the app registration and that the sign-in was not blocked by Conditional Access.')
            }
        }

        $context = $pending.Result
        $code = $context.Request.QueryString['code']
        $failure = $context.Request.QueryString['error']
        $description = $context.Request.QueryString['error_description']
        $returnedState = $context.Request.QueryString['state']

        $body = if ($code) {
            '<html><body style="font-family:sans-serif"><h3>Authorization complete</h3><p>Return to the PowerShell window.</p></body></html>'
        }
        else {
            "<html><body style='font-family:sans-serif'><h3>Authorization failed</h3><p>$failure</p></body></html>"
        }
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($body)
        $context.Response.ContentType = 'text/html'
        $context.Response.ContentLength64 = $bytes.Length
        $context.Response.OutputStream.Write($bytes, 0, $bytes.Length)
        $context.Response.OutputStream.Close()

        if (-not $code) {
            $detail = if ($description) { "$failure - $description" } else { $failure }
            throw "Entra returned an error instead of an authorization code: $detail"
        }
        if ($returnedState -ne $State) {
            throw 'The redirect carried a different state value than the request. Discarding the authorization code.'
        }
        return $code
    }
    finally {
        $listener.Stop()
        $listener.Close()
    }
}

function Invoke-EntraTokenExchange {
    param(
        [Parameter(Mandatory)][string]$TenantId,
        [Parameter(Mandatory)][string]$ClientId,
        [Parameter(Mandatory)][string]$ClientSecret,
        [Parameter(Mandatory)][string]$RedirectUri,
        [Parameter(Mandatory)][string]$Code,
        [Parameter(Mandatory)][string[]]$Scopes,
        [int]$RetryDelaySeconds = 5
    )

    $uri = "https://login.microsoftonline.com/$([uri]::EscapeDataString($TenantId))/oauth2/v2.0/token"
    $body = @{
        client_id     = $ClientId
        client_secret = $ClientSecret
        code          = $Code
        redirect_uri  = $RedirectUri
        grant_type    = 'authorization_code'
        scope         = ($Scopes -join ' ')
    }

    # AADSTS7000215 here means the secret has not replicated to this token endpoint yet. Entra
    # rejects the client before it reads the code, so the code survives and a retry is safe.
    for ($attempt = 1; ; $attempt++) {
        try {
            return Invoke-RestMethod -Method Post -Uri $uri -ErrorAction Stop -Body $body
        }
        catch {
            $detail = $_.ErrorDetails
            $failure = if ($detail) { [string]$detail.Message } else { [string]$_.Exception.Message }
            if ($attempt -ge 3 -or $failure -notmatch 'AADSTS7000215') {
                throw "Entra rejected the token exchange: $failure"
            }
            Write-Info 'The client secret is not active yet; retrying the token exchange ...'
            if ($RetryDelaySeconds -gt 0) { Start-Sleep -Seconds $RetryDelaySeconds }
        }
    }
}

# A client-credentials request is the cheapest way to prove a freshly created secret is usable.
# Doing it before the browser hand-off keeps the authorization code, which Entra expires in
# minutes, out of the replication race.
function Wait-EntraClientSecretReady {
    param(
        [Parameter(Mandatory)][string]$TenantId,
        [Parameter(Mandatory)][string]$ClientId,
        [Parameter(Mandatory)][string]$ClientSecret,
        [int]$TimeoutSeconds = 120,
        [int]$DelaySeconds = 5
    )

    $uri = "https://login.microsoftonline.com/$([uri]::EscapeDataString($TenantId))/oauth2/v2.0/token"
    $body = @{
        client_id     = $ClientId
        client_secret = $ClientSecret
        grant_type    = 'client_credentials'
        scope         = 'https://graph.microsoft.com/.default'
    }
    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
    $waited = $false
    while ($true) {
        try {
            $null = Invoke-RestMethod -Method Post -Uri $uri -ErrorAction Stop -Body $body
            if ($waited) { Write-Ok 'Client secret is active' }
            return $true
        }
        catch {
            $detail = $_.ErrorDetails
            $failure = if ($detail) { [string]$detail.Message } else { [string]$_.Exception.Message }
            # Any other failure is the authorization code exchange's problem to report in context.
            if ($failure -notmatch 'AADSTS7000215') { return $true }
            if ((Get-Date) -ge $deadline) { return $false }
            if (-not $waited) {
                Write-Info 'Waiting for Entra to activate the new client secret ...'
                $waited = $true
            }
            Start-Sleep -Seconds $DelaySeconds
        }
    }
}

function Get-EntraAgent365RefreshToken {
    param(
        [Parameter(Mandatory)][string]$TenantId,
        [Parameter(Mandatory)][string]$ClientId,
        [Parameter(Mandatory)][string]$ClientSecret,
        [Parameter(Mandatory)][string]$RedirectUri
    )

    if (-not (Wait-EntraClientSecretReady -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret)) {
        throw 'Entra has not activated the client secret yet (AADSTS7000215). Re-run the script to retry; a new secret is usually usable within a minute.'
    }

    $scopes = Get-Agent365Scopes
    $state = [guid]::NewGuid().ToString('N')
    $authUrl = Get-EntraAuthorizationUrl -TenantId $TenantId -ClientId $ClientId -RedirectUri $RedirectUri `
        -Scopes $scopes -State $state

    Write-Host ''
    Write-Host 'Authorize the application at:' -ForegroundColor White
    Write-Host $authUrl -ForegroundColor Yellow
    Write-Host ''
    if (-not (Open-Url -Url $authUrl)) {
        Write-Info 'Could not open a browser automatically; copy the URL above.'
    }
    Write-Info 'Sign in as a user with AI Administrator (or Global Administrator) and a Microsoft Agent 365 license.'

    $code = Wait-EntraAuthorizationCode -RedirectUri $RedirectUri -State $state
    $response = Invoke-EntraTokenExchange -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret `
        -RedirectUri $RedirectUri -Code $code -Scopes $scopes

    $refreshProperty = $response.PSObject.Properties['refresh_token']
    if (-not $refreshProperty -or [string]::IsNullOrWhiteSpace([string]$refreshProperty.Value)) {
        throw 'Entra returned an access token without a refresh token. Confirm offline_access is consented on the application.'
    }
    return [string]$refreshProperty.Value
}

function Get-EntraConnectorCatalog {
    $packs = [ordered]@{}
    foreach ($key in $script:FeaturePacks.Keys) {
        $packs[$key] = $script:FeaturePacks[$key]
    }
    if (Get-Command Get-EmbeddedCiemFeaturePack -ErrorAction SilentlyContinue) {
        $packs['Ciem'] = Get-EmbeddedCiemFeaturePack
    }
    $sorted = [ordered]@{}
    foreach ($key in @($packs.Keys | Sort-Object { [string]$packs[$_].Label })) {
        $sorted[$key] = $packs[$key]
    }
    return [PSCustomObject]@{
        Resources        = $script:Resources
        CorePermissions  = $script:CorePermissions
        FeaturePacks     = $sorted
        DirectoryRoleMap = $script:DirectoryRoleMap
    }
}

function Get-EntraCreatedAppId {
    return $script:CreatedAppId
}

function Get-EntraServicePrincipalLookupErrors {
    return @($script:ServicePrincipalLookupErrors)
}

Export-ModuleMember -Function @(
    'Initialize-EntraConnectorData'
    'Get-EntraConnectorCatalog'
    'Get-EntraCreatedAppId'
    'Get-EntraServicePrincipalLookupErrors'
    'Connect-EntraGraph'
    'Get-SelectedPermissions'
    'Get-SelectedDelegatedPermissions'
    'Get-EntraIscFeatureChecklist'
    'Get-ServicePrincipalByAppId'
    'Get-ApiServicePrincipal'
    'Resolve-AppRoles'
    'Resolve-DelegatedScopes'
    'Find-EntraApplicationByName'
    'New-EntraConnectorApplication'
    'Get-EntraConnectorApplication'
    'Set-RequiredResourceAccess'
    'Set-EntraRedirectUri'
    'Grant-AppRoleConsent'
    'Grant-DelegatedConsent'
    'Get-ServicePrincipalRoleMembership'
    'Add-EntraDirectoryRoles'
    'New-EntraClientSecret'
    'Get-Agent365Scopes'
    'Get-EntraAuthorizationUrl'
    'Wait-EntraAuthorizationCode'
    'Invoke-EntraTokenExchange'
    'Wait-EntraClientSecretReady'
    'Get-EntraAgent365RefreshToken'
)