modules/IW.Check.psm1

# Read-only comparison of an existing identity against the integration's requirements.
# Check never creates or updates a tenant object. A missing CLI session produces a
# hand-comparison list instead of a guess.

Set-StrictMode -Version Latest

$script:IntegrationCheckInvoker = $null

function Set-IntegrationCheckInvoker {
    param($Invoker)
    $script:IntegrationCheckInvoker = $Invoker
}

function Get-CheckContextValue {
    param(
        [hashtable]$Context,
        [Parameter(Mandatory)][string]$Name,
        [string]$Default = ''
    )
    if ($Context -and $Context.ContainsKey($Name) -and -not [string]::IsNullOrWhiteSpace([string]$Context[$Name])) {
        return [string]$Context[$Name]
    }
    return $Default
}

function Get-CheckObjectProperty {
    param($Object, [Parameter(Mandatory)][string]$Name)
    if ($null -eq $Object) { return $null }
    if ($Object -is [hashtable]) {
        if ($Object.ContainsKey($Name)) { return $Object[$Name] }
        return $null
    }
    $prop = $Object.PSObject.Properties[$Name]
    if ($prop) { return $prop.Value }
    return $null
}

function ConvertFrom-CliJson {
    param([string]$Text)
    if ([string]::IsNullOrWhiteSpace($Text)) { return $null }
    $startObj = $Text.IndexOf('{')
    $startArr = $Text.IndexOf('[')
    $start = -1
    if ($startObj -ge 0 -and ($startArr -lt 0 -or $startObj -lt $startArr)) { $start = $startObj }
    elseif ($startArr -ge 0) { $start = $startArr }
    if ($start -lt 0) { return $null }
    try { return ($Text.Substring($start) | ConvertFrom-Json) }
    catch { return $null }
}

function Invoke-IntegrationCheckCli {
    param(
        [Parameter(Mandatory)][string]$Command,
        [string[]]$Arguments = @()
    )
    if ($script:IntegrationCheckInvoker -is [scriptblock]) {
        return & $script:IntegrationCheckInvoker $Command @($Arguments)
    }
    try {
        $output = & $Command @Arguments 2>&1 | Out-String
        $code = $LASTEXITCODE
        if ($null -eq $code) { $code = 0 }
        return @{ ExitCode = [int]$code; Output = [string]$output }
    }
    catch {
        return @{ ExitCode = 1; Output = $_.Exception.Message }
    }
}

function Test-CliNotFound {
    param($Result)
    if ($null -eq $Result) { return $false }
    if ([int]$Result.ExitCode -eq 0) { return $false }
    return [string]$Result.Output -match 'NoSuchEntity|NotFound|NOT_FOUND|does not exist|ResourceNotFound|was not found|Cannot find'
}

function Get-RequirementToken {
    param([Parameter(Mandatory)][string]$Line)
    $line = $Line.Trim()
    if ($line -eq '*') {
        return @{ Comparable = $true; Family = 'Aws'; Key = '*' }
    }
    # Service prefixes such as bedrock-agentcore contain hyphens. Dots stay out of
    # this pattern so a Google IAM permission is not classified as an AWS action.
    if ($line -match '^[A-Za-z0-9*][A-Za-z0-9*-]*:[A-Za-z0-9*][A-Za-z0-9*]*$') {
        return @{ Comparable = $true; Family = 'Aws'; Key = $line }
    }
    if ($line -match '^https://\S+$') {
        return @{ Comparable = $true; Family = 'Scope'; Key = $line }
    }
    if ($line -match '^[a-z][a-z0-9]*(\.[A-Za-z0-9*]+)+$') {
        return @{ Comparable = $true; Family = 'Iam'; Key = $line }
    }
    if ($line -match '^.+:\s+(?<perm>\S+)$') {
        return @{ Comparable = $true; Family = 'Named'; Key = $Matches['perm'] }
    }
    return @{ Comparable = $false; Family = 'Manual'; Key = $line }
}

function Test-AwsActionCoveredBy {
    param(
        [Parameter(Mandatory)][string]$Granted,
        [Parameter(Mandatory)][string]$Required
    )
    if ($Granted -eq '*' -or $Granted -eq $Required) { return $true }
    if ($Granted.EndsWith('*') -and $Granted.Length -gt 1) {
        $prefix = $Granted.Substring(0, $Granted.Length - 1)
        return $Required.StartsWith($prefix)
    }
    return $false
}

function Test-GrantCoversRequirement {
    param($Grant, $Required)
    if ($Grant.Key -eq $Required.Key) { return $true }
    if ($Required.Family -eq 'Aws' -and $Grant.Family -eq 'Aws') {
        return (Test-AwsActionCoveredBy -Granted $Grant.Key -Required $Required.Key)
    }
    return $false
}

function Get-CheckLinesForAccess {
    param(
        $Lists,
        [string]$Access
    )
    $required = [System.Collections.Generic.List[string]]::new()
    foreach ($line in @($Lists.ReadOnly)) {
        if (-not [string]::IsNullOrWhiteSpace([string]$line)) { $required.Add([string]$line) }
    }
    $planning = [System.Collections.Generic.List[string]]::new()
    foreach ($line in @($Lists.ReadWrite)) {
        if ([string]::IsNullOrWhiteSpace([string]$line)) { continue }
        if ($Access -eq 'ReadOnly') { $planning.Add([string]$line) }
        else { $required.Add([string]$line) }
    }
    return @{ Required = @($required.ToArray()); PlanningOnly = @($planning.ToArray()) }
}

function Compare-IntegrationRequirementCoverage {
    param(
        [string[]]$Required = @(),
        [string[]]$Granted = @(),
        [string[]]$Denied = @(),
        [string[]]$ReadableFamilies = @()
    )
    # A hashtable written to the pipeline is enumerated into entries, so tokens are
    # collected in a list instead of piped. Loop variables are not named $grant or
    # $denied: those are the same variables as the [string[]] parameters, and
    # assigning $false to a [string[]] becomes the truthy string 'False'.
    $grantTokens = [System.Collections.Generic.List[object]]::new()
    foreach ($grantedAction in @($Granted)) {
        if ([string]::IsNullOrWhiteSpace([string]$grantedAction)) { continue }
        $grantedToken = Get-RequirementToken -Line ([string]$grantedAction)
        if ($grantedToken.Comparable) { $grantTokens.Add($grantedToken) }
    }
    $denyTokens = [System.Collections.Generic.List[object]]::new()
    foreach ($deniedAction in @($Denied)) {
        if ([string]::IsNullOrWhiteSpace([string]$deniedAction)) { continue }
        $deniedToken = Get-RequirementToken -Line ([string]$deniedAction)
        if ($deniedToken.Comparable) { $denyTokens.Add($deniedToken) }
    }
    $met = [System.Collections.Generic.List[string]]::new()
    $missing = [System.Collections.Generic.List[string]]::new()
    $manual = [System.Collections.Generic.List[string]]::new()
    foreach ($line in @($Required)) {
        if ([string]::IsNullOrWhiteSpace([string]$line)) { continue }
        $requiredToken = Get-RequirementToken -Line ([string]$line)
        if (-not $requiredToken.Comparable -or @($ReadableFamilies) -notcontains $requiredToken.Family) {
            $manual.Add([string]$line)
            continue
        }
        $isDenied = $false
        foreach ($denyToken in $denyTokens) {
            if (Test-GrantCoversRequirement -Grant $denyToken -Required $requiredToken) { $isDenied = $true; break }
        }
        if ($isDenied) {
            $missing.Add("$line (denied by the identity's policy)")
            continue
        }
        $isCovered = $false
        foreach ($grantToken in $grantTokens) {
            if (Test-GrantCoversRequirement -Grant $grantToken -Required $requiredToken) { $isCovered = $true; break }
        }
        if ($isCovered) { $met.Add([string]$line) } else { $missing.Add([string]$line) }
    }
    return @{
        Met     = @($met.ToArray())
        Missing = @($missing.ToArray())
        Manual  = @($manual.ToArray())
    }
}

function Get-IamStatementList {
    param($Document)
    if ($null -eq $Document) { return @() }
    if ($Document -is [string]) {
        $text = [string]$Document
        if ($text.Contains('%')) {
            try { $text = [uri]::UnescapeDataString($text) } catch { }
        }
        $Document = ConvertFrom-CliJson -Text $text
    }
    $statements = Get-CheckObjectProperty -Object $Document -Name 'Statement'
    if ($null -eq $statements) { return @() }
    return @($statements)
}

function Get-IamPolicyGrantSet {
    param($Document)
    $allow = [System.Collections.Generic.List[string]]::new()
    $deny = [System.Collections.Generic.List[string]]::new()
    $notAction = $false
    foreach ($stmt in @(Get-IamStatementList -Document $Document)) {
        if ($null -ne (Get-CheckObjectProperty -Object $stmt -Name 'NotAction')) { $notAction = $true }
        $actions = @(Get-CheckObjectProperty -Object $stmt -Name 'Action')
        $effect = [string](Get-CheckObjectProperty -Object $stmt -Name 'Effect')
        # Assign the list directly. An if-expression would write the list to the
        # pipeline and enumerate it, so an empty list arrives as $null.
        $bucket = $allow
        if ($effect -eq 'Deny') { $bucket = $deny }
        foreach ($action in @($actions)) {
            if (-not [string]::IsNullOrWhiteSpace([string]$action)) { $bucket.Add([string]$action) }
        }
    }
    return @{
        Allow     = @($allow.ToArray())
        Deny      = @($deny.ToArray())
        NotAction = [bool]$notAction
    }
}

function New-CheckSubject {
    param(
        [Parameter(Mandatory)][string]$Kind,
        [Parameter(Mandatory)][string]$Label,
        [string]$Name = '',
        [string]$Note = '',
        [string[]]$ReadableFamilies = @(),
        [bool]$Readable = $false
    )
    return @{
        Kind              = $Kind
        Label             = $Label
        Name              = $Name
        Note              = $Note
        Readable          = $Readable
        ReadableFamilies  = @($ReadableFamilies)
    }
}

function Get-IntegrationCheckSubject {
    param(
        [Parameter(Mandatory)][hashtable]$Definition,
        [hashtable]$Context = @{}
    )
    $id = [string]$Definition.Id
    switch -Regex ($id) {
        '^aws-saas-connector$' {
            return New-CheckSubject -Kind 'IamRole' -Label 'IAM role' -Readable $true -ReadableFamilies @('Aws') `
                -Name (Get-CheckContextValue -Context $Context -Name 'RoleName' -Default 'SailPointAWSRole') `
                -Note 'Reads the role policy. Does not create the role or attach anything.'
        }
        '^aws-ciem-connector$' {
            return New-CheckSubject -Kind 'IamRole' -Label 'IAM role' -Readable $true -ReadableFamilies @('Aws') `
                -Name (Get-CheckContextValue -Context $Context -Name 'RoleName' -Default 'SailPointCIEMAuditRole') `
                -Note 'Reads the role policy. Does not create the role or change CloudFormation.'
        }
        '^aws-entro$' {
            return New-CheckSubject -Kind 'IamRole' -Label 'IAM role' -Readable $true -ReadableFamilies @('Aws') `
                -Name (Get-CheckContextValue -Context $Context -Name 'EntroRoleName' -Default 'EntroReadOnly') `
                -Note 'Reads the Entro role policy. Does not create the role.'
        }
        '^azure-connector$' {
            return New-CheckSubject -Kind 'EntraApplication' -Label 'Entra app registration' -Readable $true -ReadableFamilies @('Named') `
                -Name (Get-CheckContextValue -Context $Context -Name 'ApplicationName') `
                -Note 'Reads admin-consented application permissions on the existing service principal. Does not create the app or grant consent.'
        }
        '^azure-entro$' {
            return New-CheckSubject -Kind 'EntraApplication' -Label 'Entra app registration' -Readable $true -ReadableFamilies @('Named') `
                -Name (Get-CheckContextValue -Context $Context -Name 'DisplayName' -Default 'EntroSecurityApp') `
                -Note 'Reads admin-consented application permissions. Does not run Entro onboarding.'
        }
        '^google-(saas|ciem)-connector$' {
            $project = Get-CheckContextValue -Context $Context -Name 'ProjectId'
            $account = Get-CheckContextValue -Context $Context -Name 'ServiceAccountId' -Default 'sailpoint-isc-gws'
            $email = if ($account -match '@') { $account } elseif ($project -and $account) { "$account@$project.iam.gserviceaccount.com" } else { '' }
            return New-CheckSubject -Kind 'GcpServiceAccount' -Label 'Google service account' -Readable $true -ReadableFamilies @('Iam') `
                -Name $email `
                -Note 'Reads IAM permissions granted to the service account. Domain-wide delegation and OAuth scopes are set in the Admin console and are listed for you to compare. Nothing is created.'
        }
        '^google-gcp-entro$' {
            $project = Get-CheckContextValue -Context $Context -Name 'ProjectId'
            $account = Get-CheckContextValue -Context $Context -Name 'ServiceAccountName' -Default 'entro-automatic-onboard'
            $email = if ($account -match '@') { $account } elseif ($project -and $account) { "$account@$project.iam.gserviceaccount.com" } else { '' }
            return New-CheckSubject -Kind 'GcpServiceAccount' -Label 'Google service account' -Readable $true -ReadableFamilies @('Iam') `
                -Name $email `
                -Note 'Reads IAM permissions granted to the Entro service account. Does not create the account or grant roles.'
        }
        '^google-workspace-entro$' {
            return New-CheckSubject -Kind 'Manual' -Label 'Domain-wide delegation' `
                -Name (Get-CheckContextValue -Context $Context -Name 'ClientId') `
                -Note 'Domain-wide delegation is authorized in the Google Workspace Admin console. This check cannot read it. Nothing was changed.'
        }
        '^databricks-connector$' {
            $name = Get-CheckContextValue -Context $Context -Name 'ServicePrincipalName' -Default 'SailPoint-source'
            $account = Get-CheckContextValue -Context $Context -Name 'AccountId'
            return New-CheckSubject -Kind 'Manual' -Label 'Databricks account service principal' -Name $name `
                -Note "Open account service principal '$name'$(if ($account) { " on account $account" }) and confirm it is an Account Admin. This check does not call Databricks and does not create the principal."
        }
        '^salesforce-connector$' {
            $url = Get-CheckContextValue -Context $Context -Name 'InstanceUrl'
            return New-CheckSubject -Kind 'Manual' -Label 'Salesforce Connected App' `
                -Name (Get-CheckContextValue -Context $Context -Name 'ConsumerKey') `
                -Note "Compare the Connected App$(if ($url) { " on $url" }) and the integration user's permissions with the requirements checklist. This check does not call Salesforce."
        }
        '^github-connector$' {
            $org = Get-CheckContextValue -Context $Context -Name 'Organization'
            $method = Get-CheckContextValue -Context $Context -Name 'AuthMethod' -Default 'PersonalAccessToken'
            return New-CheckSubject -Kind 'Manual' -Label 'GitHub credential' -Name $org `
                -Note "Compare the $method for organization '$org' with the requirements checklist. This check does not call GitHub."
        }
        '^crowdstrike-connector$' {
            return New-CheckSubject -Kind 'Manual' -Label 'CrowdStrike API client' `
                -Name (Get-CheckContextValue -Context $Context -Name 'ClientId') `
                -Note 'Compare the API client scopes with the requirements checklist. This check does not call CrowdStrike.'
        }
        default {
            $title = if ($Definition['Title']) { [string]$Definition.Title } else { $id }
            return New-CheckSubject -Kind 'Manual' -Label $title `
                -Note 'This integration does not expose one role or application this check can read. Use BuildRequirements for the checklist. Nothing was changed.'
        }
    }
}

function Read-AwsIamRoleGrants {
    param(
        [Parameter(Mandatory)][string]$RoleName,
        [hashtable]$Context = @{}
    )
    $prefix = @()
    $profileName = Get-CheckContextValue -Context $Context -Name 'ProfileName'
    if ($profileName) { $prefix = @('--profile', $profileName) }
    $role = Invoke-IntegrationCheckCli -Command 'aws' -Arguments ($prefix + @('iam', 'get-role', '--role-name', $RoleName, '--output', 'json'))
    if (Test-CliNotFound -Result $role) {
        return @{ Found = $false; Grants = @(); Denied = @(); Incomplete = $false; Note = "No IAM role named $RoleName was found. Nothing was changed." }
    }
    if ([int]$role.ExitCode -ne 0) {
        return @{ Found = $false; Grants = @(); Denied = @(); Incomplete = $true; Note = "Could not read IAM role $RoleName. $([string]$role.Output)".Trim() + ' Nothing was changed.' }
    }
    $allow = [System.Collections.Generic.List[string]]::new()
    $deny = [System.Collections.Generic.List[string]]::new()
    $notes = [System.Collections.Generic.List[string]]::new()
    $incomplete = $false

    $attached = Invoke-IntegrationCheckCli -Command 'aws' -Arguments ($prefix + @('iam', 'list-attached-role-policies', '--role-name', $RoleName, '--output', 'json'))
    if ([int]$attached.ExitCode -ne 0) {
        $incomplete = $true
        $notes.Add('Could not list attached policies.')
    }
    else {
        foreach ($policy in @(Get-CheckObjectProperty -Object (ConvertFrom-CliJson -Text ([string]$attached.Output)) -Name 'AttachedPolicies')) {
            $arn = [string](Get-CheckObjectProperty -Object $policy -Name 'PolicyArn')
            if (-not $arn) { continue }
            $meta = Invoke-IntegrationCheckCli -Command 'aws' -Arguments ($prefix + @('iam', 'get-policy', '--policy-arn', $arn, '--output', 'json'))
            $versionId = [string](Get-CheckObjectProperty -Object (Get-CheckObjectProperty -Object (ConvertFrom-CliJson -Text ([string]$meta.Output)) -Name 'Policy') -Name 'DefaultVersionId')
            if ([int]$meta.ExitCode -ne 0 -or -not $versionId) {
                $incomplete = $true
                $notes.Add("Could not read policy $arn.")
                continue
            }
            $version = Invoke-IntegrationCheckCli -Command 'aws' -Arguments ($prefix + @('iam', 'get-policy-version', '--policy-arn', $arn, '--version-id', $versionId, '--output', 'json'))
            $document = Get-CheckObjectProperty -Object (Get-CheckObjectProperty -Object (ConvertFrom-CliJson -Text ([string]$version.Output)) -Name 'PolicyVersion') -Name 'Document'
            if ([int]$version.ExitCode -ne 0 -or $null -eq $document) {
                $incomplete = $true
                $notes.Add("Could not read the default version of $arn.")
                continue
            }
            $set = Get-IamPolicyGrantSet -Document $document
            foreach ($item in @($set.Allow)) { $allow.Add($item) }
            foreach ($item in @($set.Deny)) { $deny.Add($item) }
            if ($set.NotAction) { $notes.Add("Policy $arn uses NotAction. Those statements were not expanded.") ; $incomplete = $true }
        }
    }

    $inline = Invoke-IntegrationCheckCli -Command 'aws' -Arguments ($prefix + @('iam', 'list-role-policies', '--role-name', $RoleName, '--output', 'json'))
    if ([int]$inline.ExitCode -ne 0) {
        $incomplete = $true
        $notes.Add('Could not list inline policies.')
    }
    else {
        foreach ($policyName in @(Get-CheckObjectProperty -Object (ConvertFrom-CliJson -Text ([string]$inline.Output)) -Name 'PolicyNames')) {
            $name = [string]$policyName
            if (-not $name) { continue }
            $docResult = Invoke-IntegrationCheckCli -Command 'aws' -Arguments ($prefix + @('iam', 'get-role-policy', '--role-name', $RoleName, '--policy-name', $name, '--output', 'json'))
            $document = Get-CheckObjectProperty -Object (ConvertFrom-CliJson -Text ([string]$docResult.Output)) -Name 'PolicyDocument'
            if ([int]$docResult.ExitCode -ne 0 -or $null -eq $document) {
                $incomplete = $true
                $notes.Add("Could not read inline policy $name.")
                continue
            }
            $set = Get-IamPolicyGrantSet -Document $document
            foreach ($item in @($set.Allow)) { $allow.Add($item) }
            foreach ($item in @($set.Deny)) { $deny.Add($item) }
            if ($set.NotAction) { $notes.Add("Inline policy $name uses NotAction. Those statements were not expanded.") ; $incomplete = $true }
        }
    }

    return @{
        Found      = $true
        Grants     = @($allow.ToArray())
        Denied     = @($deny.ToArray())
        Incomplete = [bool]$incomplete
        Note       = ($notes -join ' ')
    }
}

function Read-EntraApplicationGrants {
    param([Parameter(Mandatory)][string]$DisplayName)
    $appsResult = Invoke-IntegrationCheckCli -Command 'az' -Arguments @('ad', 'app', 'list', '--display-name', $DisplayName, '-o', 'json')
    if ([int]$appsResult.ExitCode -ne 0) {
        return @{ Found = $false; Grants = @(); Denied = @(); Incomplete = $true; Note = "Could not list app registrations. $([string]$appsResult.Output)".Trim() + ' Nothing was changed.' }
    }
    $matchedApps = @(@(ConvertFrom-CliJson -Text ([string]$appsResult.Output)) | Where-Object {
            [string](Get-CheckObjectProperty -Object $_ -Name 'displayName') -eq $DisplayName
        })
    if ($matchedApps.Count -eq 0) {
        return @{ Found = $false; Grants = @(); Denied = @(); Incomplete = $false; Note = "No app registration named $DisplayName was found. Nothing was changed." }
    }
    if ($matchedApps.Count -gt 1) {
        return @{ Found = $false; Grants = @(); Denied = @(); Incomplete = $true; Note = "More than one app registration is named $DisplayName. Nothing was changed." }
    }
    $appId = [string](Get-CheckObjectProperty -Object $matchedApps[0] -Name 'appId')
    $spResult = Invoke-IntegrationCheckCli -Command 'az' -Arguments @('ad', 'sp', 'list', '--filter', "appId eq '$appId'", '-o', 'json')
    $servicePrincipals = @()
    if ([int]$spResult.ExitCode -eq 0) {
        $servicePrincipals = @(@(ConvertFrom-CliJson -Text ([string]$spResult.Output)) | Where-Object {
                [string](Get-CheckObjectProperty -Object $_ -Name 'appId') -eq $appId
            })
    }
    if ($servicePrincipals.Count -eq 0) {
        return @{ Found = $true; Grants = @(); Denied = @(); Incomplete = $false; Note = "App registration $DisplayName exists but has no service principal, so it cannot sign in and has no granted permissions. Nothing was changed." }
    }
    $spId = [string](Get-CheckObjectProperty -Object $servicePrincipals[0] -Name 'id')
    $assignUri = "https://graph.microsoft.com/v1.0/servicePrincipals/$spId/appRoleAssignments"
    $assigned = Invoke-IntegrationCheckCli -Command 'az' -Arguments @('rest', '--method', 'GET', '--uri', $assignUri)
    if ([int]$assigned.ExitCode -ne 0) {
        return @{ Found = $true; Grants = @(); Denied = @(); Incomplete = $true; Note = "Could not read granted app roles for $DisplayName. Nothing was changed." }
    }
    $assignmentObject = ConvertFrom-CliJson -Text ([string]$assigned.Output)
    $assignments = @(Get-CheckObjectProperty -Object $assignmentObject -Name 'value')
    $byResource = @{}
    foreach ($assignment in @($assignments)) {
        $resourceId = [string](Get-CheckObjectProperty -Object $assignment -Name 'resourceId')
        $roleId = [string](Get-CheckObjectProperty -Object $assignment -Name 'appRoleId')
        if (-not $resourceId -or -not $roleId) { continue }
        if (-not $byResource.ContainsKey($resourceId)) { $byResource[$resourceId] = [System.Collections.Generic.List[string]]::new() }
        $byResource[$resourceId].Add($roleId)
    }
    $grants = [System.Collections.Generic.List[string]]::new()
    $incomplete = $false
    $notes = [System.Collections.Generic.List[string]]::new()
    foreach ($resourceId in @($byResource.Keys)) {
        $uri = 'https://graph.microsoft.com/v1.0/servicePrincipals/' + $resourceId + '?$select=appRoles,displayName'
        $resource = Invoke-IntegrationCheckCli -Command 'az' -Arguments @('rest', '--method', 'GET', '--uri', $uri)
        $resourceObject = ConvertFrom-CliJson -Text ([string]$resource.Output)
        if ([int]$resource.ExitCode -ne 0 -or $null -eq $resourceObject) {
            $incomplete = $true
            $notes.Add("Could not resolve app roles for resource $resourceId.")
            continue
        }
        $roles = @(Get-CheckObjectProperty -Object $resourceObject -Name 'appRoles')
        foreach ($roleId in @($byResource[$resourceId])) {
            $matchedRole = @($roles | Where-Object { [string](Get-CheckObjectProperty -Object $_ -Name 'id') -eq $roleId } | Select-Object -First 1)
            $value = if ($matchedRole) { [string](Get-CheckObjectProperty -Object $matchedRole[0] -Name 'value') } else { '' }
            if ($value) { $grants.Add($value) }
            else {
                $incomplete = $true
                $notes.Add("Granted role id $roleId has no permission name.")
            }
        }
    }
    return @{
        Found      = $true
        Grants     = @($grants.ToArray())
        Denied     = @()
        Incomplete = [bool]$incomplete
        Note       = $(if ($notes.Count) { $notes -join ' ' } else { 'Compared granted application permissions (admin consent), not permissions that are only requested.' })
    }
}

function Add-GcpBindingRoles {
    param(
        $Policy,
        [Parameter(Mandatory)][string]$Member,
        $Roles
    )
    foreach ($binding in @(Get-CheckObjectProperty -Object $Policy -Name 'bindings')) {
        $role = [string](Get-CheckObjectProperty -Object $binding -Name 'role')
        if (-not $role) { continue }
        $members = @((Get-CheckObjectProperty -Object $binding -Name 'members') | ForEach-Object { [string]$_ })
        if ($members -contains $Member -or $members -contains ($Member -replace '^serviceAccount:', '')) {
            if (-not $Roles.Contains($role)) { [void]$Roles.Add($role) }
        }
    }
}

function Read-GcpServiceAccountGrants {
    param(
        [Parameter(Mandatory)][string]$Email,
        [Parameter(Mandatory)][string]$ProjectId,
        [string]$OrganizationId = ''
    )
    $describe = Invoke-IntegrationCheckCli -Command 'gcloud' -Arguments @('iam', 'service-accounts', 'describe', $Email, "--project=$ProjectId", '--format=json')
    if (Test-CliNotFound -Result $describe) {
        return @{ Found = $false; Grants = @(); Denied = @(); Incomplete = $false; Note = "No service account $Email was found. Nothing was changed." }
    }
    if ([int]$describe.ExitCode -ne 0) {
        return @{ Found = $false; Grants = @(); Denied = @(); Incomplete = $true; Note = "Could not read service account $Email. Nothing was changed." }
    }
    $roles = [System.Collections.Generic.List[string]]::new()
    $member = "serviceAccount:$Email"
    $projectPolicy = Invoke-IntegrationCheckCli -Command 'gcloud' -Arguments @('projects', 'get-iam-policy', $ProjectId, '--format=json')
    if ([int]$projectPolicy.ExitCode -eq 0) {
        Add-GcpBindingRoles -Policy (ConvertFrom-CliJson -Text ([string]$projectPolicy.Output)) -Member $member -Roles $roles
    }
    if ($OrganizationId) {
        $orgPolicy = Invoke-IntegrationCheckCli -Command 'gcloud' -Arguments @('organizations', 'get-iam-policy', $OrganizationId, '--format=json')
        if ([int]$orgPolicy.ExitCode -eq 0) {
            Add-GcpBindingRoles -Policy (ConvertFrom-CliJson -Text ([string]$orgPolicy.Output)) -Member $member -Roles $roles
        }
    }
    $grants = [System.Collections.Generic.List[string]]::new()
    $notes = [System.Collections.Generic.List[string]]::new()
    $incomplete = $false
    foreach ($role in @($roles)) {
        $described = Invoke-IntegrationCheckCli -Command 'gcloud' -Arguments @('iam', 'roles', 'describe', $role, '--format=json')
        if ([int]$described.ExitCode -ne 0 -and $OrganizationId -and $role -notmatch '/') {
            $described = Invoke-IntegrationCheckCli -Command 'gcloud' -Arguments @('iam', 'roles', 'describe', $role, "--organization=$OrganizationId", '--format=json')
        }
        $permissions = @(Get-CheckObjectProperty -Object (ConvertFrom-CliJson -Text ([string]$described.Output)) -Name 'includedPermissions')
        if ([int]$described.ExitCode -ne 0 -or $permissions.Count -eq 0) {
            $incomplete = $true
            $notes.Add("Could not expand role $role.")
            continue
        }
        foreach ($permission in @($permissions)) {
            if ($permission) { $grants.Add([string]$permission) }
        }
    }
    if ($roles.Count -eq 0) {
        $notes.Add('The service account has no project or organization IAM bindings in the policies that were read.')
    }
    return @{
        Found      = $true
        Grants     = @($grants.ToArray())
        Denied     = @()
        Incomplete = [bool]$incomplete
        Note       = ($notes -join ' ')
    }
}

function Read-IntegrationIdentityGrants {
    param(
        [Parameter(Mandatory)][hashtable]$Subject,
        [hashtable]$Context = @{}
    )
    switch ($Subject.Kind) {
        'IamRole' { return Read-AwsIamRoleGrants -RoleName $Subject.Name -Context $Context }
        'EntraApplication' { return Read-EntraApplicationGrants -DisplayName $Subject.Name }
        'GcpServiceAccount' {
            $project = Get-CheckContextValue -Context $Context -Name 'ProjectId'
            $org = Get-CheckContextValue -Context $Context -Name 'OrganizationId'
            return Read-GcpServiceAccountGrants -Email $Subject.Name -ProjectId $project -OrganizationId $org
        }
        default {
            return @{ Found = $false; Grants = @(); Denied = @(); Incomplete = $false; Note = $Subject.Note }
        }
    }
}

function Get-IntegrationCheckVerdict {
    param(
        [bool]$AttemptedRead,
        [bool]$Found,
        [bool]$Incomplete,
        [int]$RequiredCount,
        [int]$MissingCount,
        [int]$ManualCount
    )
    if ($RequiredCount -eq 0) { return 'Review' }
    if (-not $AttemptedRead) { return 'Review' }
    if (-not $Found) { return 'Fail' }
    if ($MissingCount -gt 0) { return 'Fail' }
    if ($Incomplete -or $ManualCount -gt 0) { return 'Review' }
    return 'Pass'
}

function Get-IntegrationCheckVerdictText {
    param([Parameter(Mandatory)][string]$Verdict)
    switch ($Verdict) {
        'Pass' { return 'Meets the requirements' }
        'Fail' { return 'Does not meet the requirements' }
        default { return 'Needs a person to finish the comparison' }
    }
}

function Get-WorstCheckVerdict {
    param([string[]]$Verdicts)
    if (@($Verdicts) -contains 'Fail') { return 'Fail' }
    if (@($Verdicts) -contains 'Review') { return 'Review' }
    if (@($Verdicts) -contains 'Pass') { return 'Pass' }
    return 'Review'
}

function Add-CheckBulletList {
    param($Builder, [string]$Heading, [string[]]$Items)
    if (@($Items).Count -eq 0) { return }
    [void]$Builder.AppendLine("#### $Heading")
    [void]$Builder.AppendLine('')
    foreach ($item in @($Items)) { [void]$Builder.AppendLine("- ``$item``") }
    [void]$Builder.AppendLine('')
}

function Format-IntegrationCheckSection {
    param([Parameter(Mandatory)][hashtable]$Result)
    $sb = [System.Text.StringBuilder]::new()
    $identity = if ($Result.Subject.Name) { "$($Result.Subject.Label) ``$($Result.Subject.Name)``" } else { $Result.Subject.Label }
    [void]$sb.AppendLine("## $identity")
    [void]$sb.AppendLine('')
    [void]$sb.AppendLine("**$(Get-IntegrationCheckVerdictText -Verdict $Result.Verdict).**")
    [void]$sb.AppendLine('')
    if ($Result.Subject.Note) { [void]$sb.AppendLine($Result.Subject.Note); [void]$sb.AppendLine('') }
    if ($Result.ReadNote) { [void]$sb.AppendLine($Result.ReadNote); [void]$sb.AppendLine('') }
    if (-not $Result.AttemptedRead) {
        [void]$sb.AppendLine('The live identity was not read. The list below is what to compare. Nothing was changed.')
        [void]$sb.AppendLine('')
    }
    Add-CheckBulletList -Builder $sb -Heading 'Missing' -Items $Result.Missing
    Add-CheckBulletList -Builder $sb -Heading 'Already present' -Items $Result.Met
    Add-CheckBulletList -Builder $sb -Heading 'Compare these yourself' -Items $Result.Manual
    Add-CheckBulletList -Builder $sb -Heading 'Not required for ReadOnly' -Items $Result.PlanningOnly
    return $sb.ToString()
}

function Get-IntegrationMemberCheckResult {
    param(
        [Parameter(Mandatory)][hashtable]$Definition,
        [hashtable]$Context = @{},
        [switch]$WhatIf
    )
    if (-not (Get-Command Get-IntegrationWizardAccessRequirementLists -ErrorAction SilentlyContinue)) {
        Import-Module (Join-Path $PSScriptRoot 'IW.Access.psm1') -Force -WarningAction SilentlyContinue
    }
    $subject = Get-IntegrationCheckSubject -Definition $Definition -Context $Context
    $lists = Get-IntegrationWizardAccessRequirementLists -Definition $Definition -Context $Context
    $access = Get-CheckContextValue -Context $Context -Name 'Access'
    $lines = Get-CheckLinesForAccess -Lists $lists -Access $access
    $attempted = $false
    $read = @{ Found = $false; Grants = @(); Denied = @(); Incomplete = $false; Note = '' }
    $skipReason = ''
    if ($subject.Kind -eq 'GcpServiceAccount' -and [string]::IsNullOrWhiteSpace([string]$subject.Name)) {
        $skipReason = 'Check needs -ProjectId and the service account id of an account that already exists. Nothing was changed.'
    }
    elseif ($subject.Kind -eq 'EntraApplication' -and [string]::IsNullOrWhiteSpace([string]$subject.Name)) {
        $skipReason = 'Check needs -ApplicationName, the display name of an app registration that already exists. Nothing was changed.'
    }
    elseif ($WhatIf) {
        $skipReason = "WhatIf: would read $($subject.Label) $($subject.Name). No tenant call was made."
    }
    elseif ($Context -and $Context['ConnectionMode'] -eq 'NoSession') {
        $skipReason = 'No CLI session was used. Sign in and run Check again to read the existing identity, or compare the list yourself. Nothing was changed.'
    }
    elseif (-not $subject.Readable) {
        $skipReason = $subject.Note
    }
    else {
        $attempted = $true
        $read = Read-IntegrationIdentityGrants -Subject $subject -Context $Context
    }
    # A missing identity is not a list of gaps. Listing every requirement as missing
    # would read as if the role existed and lacked those permissions.
    if ($attempted -and -not $read.Found) {
        $coverage = @{ Met = @(); Missing = @(); Manual = @() }
    }
    elseif ($attempted) {
        $coverage = Compare-IntegrationRequirementCoverage -Required $lines.Required -Granted @($read.Grants) -Denied @($read.Denied) -ReadableFamilies @($subject.ReadableFamilies)
    }
    else {
        $coverage = @{ Met = @(); Missing = @(); Manual = @($lines.Required) }
    }
    $verdict = Get-IntegrationCheckVerdict -AttemptedRead $attempted -Found ([bool]$read.Found) -Incomplete ([bool]$read.Incomplete) `
        -RequiredCount @($lines.Required).Count -MissingCount @($coverage.Missing).Count -ManualCount @($coverage.Manual).Count
    $readNote = $skipReason
    if ($attempted -and $read.Note) { $readNote = [string]$read.Note }
    return @{
        Title         = [string]$Definition.Title
        Subject       = $subject
        Verdict       = $verdict
        AttemptedRead = $attempted
        Found         = [bool]$read.Found
        Missing       = @($coverage.Missing)
        Met           = @($coverage.Met)
        Manual        = @($coverage.Manual)
        PlanningOnly  = @($lines.PlanningOnly)
        ReadNote      = $readNote
        SubjectNote   = $subject.Note
    }
}

function Write-IntegrationCheckReport {
    param(
        [Parameter(Mandatory)][hashtable]$Definition,
        [Parameter(Mandatory)][string]$OutputPath,
        [Parameter(Mandatory)][string]$Verdict,
        [string]$Access,
        [string[]]$Sections
    )
    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine("# Check: $($Definition.Title)")
    [void]$sb.AppendLine('')
    [void]$sb.AppendLine('Nothing in the tenant was created or changed. This report is a local file.')
    [void]$sb.AppendLine('')
    [void]$sb.AppendLine("**$(Get-IntegrationCheckVerdictText -Verdict $Verdict).**")
    [void]$sb.AppendLine('')
    if ($Access) {
        [void]$sb.AppendLine("Access level: $Access")
        [void]$sb.AppendLine('')
    }
    [void]$sb.AppendLine('`BuildRequirements` is the checklist. `Check` reads an identity you already have. `Run` is the only action that changes the tenant, and only after you confirm.')
    [void]$sb.AppendLine('')
    foreach ($section in @($Sections)) { [void]$sb.AppendLine($section) }
    $dir = Split-Path -Parent $OutputPath
    if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
    Set-Content -LiteralPath $OutputPath -Value $sb.ToString() -Encoding utf8
}

function Invoke-IntegrationRequirementCheck {
    param(
        [Parameter(Mandatory)][hashtable]$Definition,
        [Parameter(Mandatory)][hashtable]$Context,
        [Parameter(Mandatory)][string]$OutputPath,
        [switch]$WhatIf
    )
    $members = if ($Definition['IsComposite']) { @($Definition.Members) } else { @($Definition) }
    $results = [System.Collections.Generic.List[object]]::new()
    foreach ($member in @($members)) {
        $results.Add((Get-IntegrationMemberCheckResult -Definition $member -Context $Context -WhatIf:$WhatIf))
    }
    $verdicts = [System.Collections.Generic.List[string]]::new()
    $sections = [System.Collections.Generic.List[string]]::new()
    foreach ($result in $results) {
        $verdicts.Add([string]$result.Verdict)
        $sections.Add((Format-IntegrationCheckSection -Result $result))
    }
    $verdict = Get-WorstCheckVerdict -Verdicts $verdicts.ToArray()
    $access = Get-CheckContextValue -Context $Context -Name 'Access'
    Write-IntegrationCheckReport -Definition $Definition -OutputPath $OutputPath -Verdict $verdict -Access $access -Sections $sections
    $phrase = Get-IntegrationCheckVerdictText -Verdict $verdict
    $color = switch ($verdict) { 'Pass' { 'Green' } 'Fail' { 'Red' } default { 'Yellow' } }
    Write-Host ''
    Write-Host $phrase -ForegroundColor $color
    Write-Host 'Nothing in the tenant was changed.'
    Write-Host "Wrote $OutputPath"
    foreach ($result in @($results)) {
        if (@($result.Missing).Count -eq 0) { continue }
        Write-Host "$($result.Title) is missing:" -ForegroundColor $color
        $shown = 0
        foreach ($item in @($result.Missing)) {
            $shown++
            if ($shown -gt 20) { break }
            Write-Host " $item"
        }
        $rest = @($result.Missing).Count - $shown
        if ($rest -gt 0) { Write-Host " and $rest more in the report" }
    }
    if (Get-Command Write-IntegrationProjectLogLine -ErrorAction SilentlyContinue) {
        Write-IntegrationProjectLogLine -Level 'INFO' -Message "Check verdict ${verdict}: $OutputPath"
    }
    if ($verdict -eq 'Fail' -and $Context['NonInteractive']) {
        throw "Existing identity does not meet the requirements. Report: $OutputPath. Nothing was changed."
    }
    return @{ Verdict = $verdict; Path = $OutputPath }
}

Export-ModuleMember -Function @(
    'Set-IntegrationCheckInvoker'
    'Get-RequirementToken'
    'Get-IamPolicyGrantSet'
    'Compare-IntegrationRequirementCoverage'
    'Get-CheckLinesForAccess'
    'Get-IntegrationCheckSubject'
    'Get-IntegrationCheckVerdict'
    'Read-AwsIamRoleGrants'
    'Read-EntraApplicationGrants'
    'Read-GcpServiceAccountGrants'
    'Invoke-IntegrationRequirementCheck'
)