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' ) |