modules/ISC.AwsCiemConnector.psm1
|
#Requires -Version 5.1 Set-StrictMode -Version Latest function Initialize-AwsCiemConnectorData { $script:CiemActions = @( 'bedrock-agentcore:GetAgentRuntime' 'bedrock-agentcore:GetGateway' 'bedrock-agentcore:GetGatewayTarget' 'bedrock-agentcore:ListAgentRuntimeEndpoints' 'bedrock-agentcore:ListAgentRuntimes' 'bedrock-agentcore:ListAgentRuntimeVersions' 'bedrock-agentcore:ListGateways' 'bedrock-agentcore:ListGatewayTargets' 'bedrock:GetAgent' 'bedrock:GetAgentAlias' 'bedrock:GetKnowledgeBase' 'bedrock:ListAgentActionGroups' 'bedrock:ListAgentAliases' 'bedrock:ListAgentKnowledgeBases' 'bedrock:ListAgents' 'bedrock:ListAgentVersions' 'cloudtrail:DescribeTrails' 'cloudtrail:GetEventSelectors' 'cloudtrail:GetTrailStatus' 'cloudtrail:ListTags' 'cloudtrail:LookupEvents' 'cloudwatch:Describe*' 'cloudwatch:ListTagsForResource' 'dynamodb:DescribeGlobalTable' 'dynamodb:DescribeTable' 'dynamodb:DescribeTimeToLive' 'dynamodb:ListGlobalTables' 'dynamodb:ListStreams' 'dynamodb:ListTables' 'dynamodb:ListTagsOfResource' 'ec2:Describe*' 'ec2:GetManagedPrefixListAssociations' 'ec2:GetManagedPrefixListEntries' 'ec2:GetTransitGatewayAttachmentPropagations' 'ec2:GetTransitGatewayMulticastDomainAssociations' 'ec2:GetTransitGatewayPrefixListReferences' 'ec2:GetTransitGatewayRouteTableAssociations' 'ec2:GetTransitGatewayRouteTablePropagations' 'elasticloadbalancing:Describe*' 'iam:GenerateCredentialReport' 'iam:GenerateServiceLastAccessedDetails' 'iam:Get*' 'iam:List*' 'iam:SimulateCustomPolicy' 'iam:SimulatePrincipalPolicy' 'kms:Describe*' 'kms:Get*' 'kms:List*' 'lambda:GetFunctionConfiguration' 'lambda:GetFunctionEventInvokeConfig' 'lambda:GetLayerVersionPolicy' 'lambda:GetPolicy' 'lambda:List*' 'organizations:Describe*' 'organizations:List*' 'rds:Describe*' 'rds:ListTagsForResource' 's3:GetAccessPoint' 's3:GetAccessPointPolicy' 's3:GetAccessPointPolicyStatus' 's3:GetAccountPublicAccessBlock' 's3:GetAnalyticsConfiguration' 's3:GetBucket*' 's3:GetEncryptionConfiguration' 's3:GetInventoryConfiguration' 's3:GetObjectAcl' 's3:GetObjectVersionAcl' 's3:GetReplicationConfiguration' 's3:ListAccessPoints' 's3:ListAllMyBuckets' 'sns:GetTopicAttributes' 'sns:ListSubscriptions' 'sns:ListSubscriptionsByTopic' 'sns:ListTagsForResource' 'sns:ListTopics' 'sqs:GetQueueAttributes' 'sqs:ListDeadLetterSourceQueues' 'sqs:ListQueueTags' 'sqs:ListQueues' 'tag:GetResources' 'tag:GetTagKeys' ) $script:CiemIdentityCenterPolicies = [ordered]@{ SailPointCIEMAuditICReadOnlyPolicy = @( 'identitystore:ListUsers' 'identitystore:ListGroupMemberships' 'identitystore:ListGroups' 'identitystore:GetGroupMembershipId' 'identitystore:GetUserId' 'sso:DescribePermissionSet' 'sso:GetInlinePolicyForPermissionSet' 'sso:GetPermissionsBoundaryForPermissionSet' 'sso:ListAccountAssignments' 'sso:ListAccountsForProvisionedPermissionSet' 'sso:ListCustomerManagedPolicyReferencesInPermissionSet' 'sso:ListInstances' 'sso:ListManagedPoliciesInPermissionSet' 'sso:ListPermissionSets' ) SailPointCIEMAuditICProvisionPolicy = @( 'identitystore:CreateGroupMembership' 'identitystore:CreateUser' 'identitystore:DeleteGroupMembership' 'identitystore:DeleteUser' 'identitystore:UpdateUser' 'sso:CreateAccountAssignment' 'sso:DeleteAccountAssignment' ) } # Identity Center provisioning actions the documented policy requires but no SailPoint template grants, # so both the org activity stack and the inventory-only inline path need them added separately. # https://documentation.sailpoint.com/saas/help/ciem/aws/config/aws_permission_sets.html $script:CiemIdentityCenterProvisionSupplementName = 'SailPointCIEMAuditICProvisionSupplementPolicy' $script:CiemIdentityCenterProvisionSupplement = @( 'sso:ProvisionPermissionSet' 'iam:CreateSAMLProvider' 'iam:GetSAMLProvider' 'iam:UpdateSAMLProvider' 'iam:DeleteSAMLProvider' 'iam:PutRolePolicy' ) $script:EmbeddedCiemFeaturePack = @{ Label = 'CIEM - Enable Cloud Infrastructure Entitlement Management, inventory and effective access' PolicyName = 'SPCiemPolicy' } } function Get-CiemTemplateFileName { param( [Parameter(Mandatory)][string]$Purpose, [Parameter(Mandatory)][ValidateSet('Commercial', 'GovCloud')][string]$Cloud ) $prefix = if ($Cloud -eq 'GovCloud') { 'gov' } else { 'commercial' } switch ($Purpose) { 'Inventory' { return "${prefix}-inventory-collection.json" } 'OrganizationManagement' { return "${prefix}-activity-collection.json" } 'ExistingCloudTrail' { return "${prefix}-activity-collection-existing-cloudtrail.json" } 'NewCloudTrailExistingBucket' { return "${prefix}-activity-collection-new-cloudtrail-and-existing-bucket.json" } 'NewCloudTrailAndBucket' { return "${prefix}-activity-collection-new-cloudtrail-and-bucket.json" } default { throw "Unknown CIEM template purpose: $Purpose" } } } function Get-CiemTemplateUrl { param( [Parameter(Mandatory)][string]$FileName, [Parameter(Mandatory)][ValidateSet('Commercial', 'GovCloud')][string]$Cloud ) $folder = if ($Cloud -eq 'GovCloud') { 'gov_cloud' } else { 'commercial' } return "https://documentation.sailpoint.com/saas/help/ciem/aws/config/assets/$folder/$FileName" } function Get-CiemTemplateDocument { param( [Parameter(Mandatory)][string]$Purpose, [Parameter(Mandatory)][ValidateSet('Commercial', 'GovCloud')][string]$Cloud ) $fileName = Get-CiemTemplateFileName -Purpose $Purpose -Cloud $Cloud $url = Get-CiemTemplateUrl -FileName $fileName -Cloud $Cloud Write-Info "Downloading $fileName" try { return (Invoke-WebRequest -Uri $url -UseBasicParsing -ErrorAction Stop).Content } catch { throw "Could not download SailPoint template from $url. Configure manually: https://documentation.sailpoint.com/saas/help/ciem/aws/config/config_aws_auto.html. $($_.Exception.Message)" } } function ConvertTo-CfnParameterList { param( [Parameter(Mandatory)]$TemplateObject, [Parameter(Mandatory)][hashtable]$Values ) $list = [System.Collections.Generic.List[object]]::new() foreach ($name in $TemplateObject.Parameters.PSObject.Properties.Name) { if (-not $Values.ContainsKey($name)) { continue } $value = $Values[$name] if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string]$value)) { continue } $list.Add([PSCustomObject]@{ ParameterKey = $name; ParameterValue = [string]$value }) } return @($list) } function Get-CiemCfnStackIfExists { param( [Parameter(Mandatory)][string]$StackName, [Parameter(Mandatory)][string]$RegionName ) try { $stack = @(Get-CFNStack -StackName $StackName -Region $RegionName -ErrorAction Stop | Select-Object -First 1) if ($stack.Count -eq 0) { return $null } return $stack[0] } catch { $msg = [string]$_ if ($msg -match 'does not exist') { return $null } throw } } function Test-CiemCfnStackNeedsDeleteBeforeRedeploy { param([string]$StackStatus) if ([string]::IsNullOrWhiteSpace($StackStatus)) { return $false } return $StackStatus -in @( 'ROLLBACK_COMPLETE' 'ROLLBACK_FAILED' 'CREATE_FAILED' 'DELETE_FAILED' 'UPDATE_ROLLBACK_COMPLETE' 'UPDATE_ROLLBACK_FAILED' ) } function Select-CiemCfnStackFailureLines { param( [object[]]$Events, [int]$MaxLines = 8 ) $candidates = [System.Collections.Generic.List[string]]::new() foreach ($event in @($Events)) { $st = [string]$event.ResourceStatus $reason = [string]$event.ResourceStatusReason if ([string]::IsNullOrWhiteSpace($reason)) { continue } if ($st -notmatch 'FAILED|ROLLBACK') { continue } $candidates.Add("$($event.LogicalResourceId) ($st): $reason") } if ($candidates.Count -eq 0) { return @('No detailed stack failure events were returned.') } $specific = @($candidates | Where-Object { $_ -notmatch 'Call DescribeEvents' }) $use = if ($specific.Count -gt 0) { $specific } else { @($candidates) } return @($use | Select-Object -First $MaxLines) } function Get-CiemCfnStackFailureLines { param( [Parameter(Mandatory)][string]$StackName, [Parameter(Mandatory)][string]$RegionName ) try { $events = @(Get-CFNStackEvent -StackName $StackName -Region $RegionName -ErrorAction Stop | Sort-Object -Property Timestamp) } catch { return @("Could not read stack events: $($_.Exception.Message)") } return @(Select-CiemCfnStackFailureLines -Events $events) } function Wait-CiemCfnStackDeleted { param( [Parameter(Mandatory)][string]$StackName, [Parameter(Mandatory)][string]$RegionName ) $deadline = (Get-Date).AddMinutes(15) while ((Get-Date) -lt $deadline) { $stack = Get-CiemCfnStackIfExists -StackName $StackName -RegionName $RegionName if (-not $stack) { return } $status = [string]$stack.StackStatus if ($status -match 'DELETE_IN_PROGRESS') { Start-Sleep -Seconds 10 continue } if ($status -eq 'DELETE_COMPLETE') { return } throw "CloudFormation stack $StackName could not be deleted (status $status)." } throw "Timed out waiting for CloudFormation stack $StackName to delete." } function Clear-CiemCfnStackForRedeploy { [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory)][string]$StackName, [Parameter(Mandatory)][string]$RegionName ) $stack = Get-CiemCfnStackIfExists -StackName $StackName -RegionName $RegionName if (-not $stack) { return } if (-not (Test-CiemCfnStackNeedsDeleteBeforeRedeploy -StackStatus ([string]$stack.StackStatus))) { return } Write-Info "Removing failed CloudFormation stack $StackName ($($stack.StackStatus)) before redeploy." if (-not $PSCmdlet.ShouldProcess($StackName, 'Delete failed CloudFormation stack')) { return } Remove-CFNStack -StackName $StackName -Region $RegionName -Force -ErrorAction Stop | Out-Null Wait-CiemCfnStackDeleted -StackName $StackName -RegionName $RegionName Write-Ok "Deleted failed stack $StackName" } function Wait-CiemCfnStack { param( [Parameter(Mandatory)][string]$StackName, [Parameter(Mandatory)][string]$RegionName ) $deadline = (Get-Date).AddMinutes(30) while ((Get-Date) -lt $deadline) { $stack = Get-CiemCfnStackIfExists -StackName $StackName -RegionName $RegionName if (-not $stack) { Start-Sleep -Seconds 5 continue } $status = [string]$stack.StackStatus if ($status -match 'IN_PROGRESS') { Start-Sleep -Seconds 10 continue } if ($status -in @('CREATE_COMPLETE', 'UPDATE_COMPLETE', 'IMPORT_COMPLETE')) { return $stack } $detail = (Get-CiemCfnStackFailureLines -StackName $StackName -RegionName $RegionName) -join '; ' throw "CloudFormation stack $StackName failed with status $status. $detail" } throw "Timed out waiting for CloudFormation stack $StackName" } function Get-CiemStackSetOperationId { param($Response) # AWS Tools for PowerShell returns the operation ID as a string (not { OperationId = ... }). if ($null -eq $Response) { return $null } if ($Response -is [string]) { return [string]$Response } if ($Response.PSObject.Properties.Name -contains 'OperationId' -and $Response.OperationId) { return [string]$Response.OperationId } return [string]$Response } function Wait-CiemStackSetOperation { param( [Parameter(Mandatory)][string]$StackSetName, [Parameter(Mandatory)][string]$OperationId, [Parameter(Mandatory)][string]$RegionName ) $deadline = (Get-Date).AddMinutes(45) while ((Get-Date) -lt $deadline) { $op = Get-CFNStackSetOperation -StackSetName $StackSetName -OperationId $OperationId -Region $RegionName -ErrorAction Stop $status = [string]$op.Status if ($status -eq 'SUCCEEDED') { return $op } if ($status -in @('FAILED', 'STOPPED', 'STOPPING')) { throw "StackSet operation $OperationId on $StackSetName failed with status $status" } Start-Sleep -Seconds 15 } throw "Timed out waiting for StackSet operation $OperationId on $StackSetName" } function Get-CiemStackOutputs { param( [Parameter(Mandatory)][string]$StackName, [Parameter(Mandatory)][string]$RegionName ) $stack = @(Get-CFNStack -StackName $StackName -Region $RegionName -ErrorAction Stop | Select-Object -First 1) if ($stack.Count -eq 0) { return @{} } $map = @{} foreach ($output in @($stack.Outputs)) { if ($output.OutputKey) { $map[$output.OutputKey] = [string]$output.OutputValue } } return $map } function Deploy-CiemCloudFormationStack { [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory)][string]$StackName, [Parameter(Mandatory)][string]$TemplateBody, [Parameter(Mandatory)][object[]]$Parameters, [Parameter(Mandatory)][string]$RegionName, [Parameter(Mandatory)][string]$ActionLabel ) $caps = @('CAPABILITY_NAMED_IAM') Clear-CiemCfnStackForRedeploy -StackName $StackName -RegionName $RegionName $existing = Get-CiemCfnStackIfExists -StackName $StackName -RegionName $RegionName $canUpdate = $existing -and ([string]$existing.StackStatus -notmatch '^DELETE') if ($canUpdate) { if (-not $PSCmdlet.ShouldProcess($StackName, "Update CloudFormation stack ($ActionLabel)")) { return } try { Update-CFNStack -StackName $StackName -TemplateBody $TemplateBody -Parameter $Parameters ` -Capabilities $caps -Region $RegionName | Out-Null } catch { # Re-running with identical template and parameters is a valid no-op, not a failure. if ([string]$_ -notmatch 'No updates are to be performed') { throw } Write-Ok "Stack $StackName already matches the template" return Get-CiemStackOutputs -StackName $StackName -RegionName $RegionName } } else { if (-not $PSCmdlet.ShouldProcess($StackName, "Create CloudFormation stack ($ActionLabel)")) { return } New-CFNStack -StackName $StackName -TemplateBody $TemplateBody -Parameter $Parameters ` -Capabilities $caps -Region $RegionName -OnFailure ROLLBACK | Out-Null } Wait-CiemCfnStack -StackName $StackName -RegionName $RegionName | Out-Null Write-Ok "Stack $StackName is ready" return Get-CiemStackOutputs -StackName $StackName -RegionName $RegionName } function Enable-CiemStackSetOrganizationsAccess { [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory)][string]$RegionName, $AwsParams ) # A SERVICE_MANAGED StackSet fails with "You must enable organizations access to operate a service # managed stack set" until CloudFormation trusted access is active in the management account. $status = $null try { $status = [string](Get-CFNOrganizationsAccess -CallAs SELF -Region $RegionName -ErrorAction Stop) } catch { Write-Verbose "Could not read CloudFormation Organizations access: $($_.Exception.Message)" } if ($status -eq 'ENABLED') { Write-Ok 'CloudFormation StackSets trusted access for AWS Organizations is active' return $true } if (-not $PSCmdlet.ShouldProcess('AWS Organizations', 'Activate CloudFormation StackSets trusted access')) { return $false } try { Enable-CFNOrganizationsAccess -Region $RegionName -Force -ErrorAction Stop | Out-Null Write-Ok 'Activated CloudFormation StackSets trusted access for AWS Organizations' return $true } catch { Write-Verbose "ActivateOrganizationsAccess failed: $($_.Exception.Message)" } try { Enable-ORGAWSServiceAccess -ServicePrincipal 'stacksets.cloudformation.amazonaws.com' -Region $RegionName -ErrorAction Stop | Out-Null Write-Ok 'Enabled Organizations trusted access for stacksets.cloudformation.amazonaws.com' return $true } catch { Write-Warning "Could not enable Organizations trusted access for CloudFormation StackSets: $($_.Exception.Message)" Write-Warning 'Run this in the management account with Organizations admin permissions, or enable it in the CloudFormation console under StackSets > Activate trusted access.' return $false } } function Deploy-CiemInventoryStackSet { [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory)][string]$StackSetName, [Parameter(Mandatory)][string]$TemplateBody, [Parameter(Mandatory)][object[]]$Parameters, [Parameter(Mandatory)][string]$RegionName, [Parameter(Mandatory)][string]$RootOrganizationalUnitId, $AwsParams ) $caps = @('CAPABILITY_NAMED_IAM') $opPrefs = @{ FailureToleranceCount = 10; MaxConcurrentCount = 10 } $existing = $null try { $existing = Get-CFNStackSet -StackSetName $StackSetName -Region $RegionName -ErrorAction Stop } catch { } if ($existing) { if (-not $PSCmdlet.ShouldProcess($StackSetName, 'Update CloudFormation StackSet (CIEM inventory)')) { return } try { $updateId = Get-CiemStackSetOperationId -Response ( Update-CFNStackSet -StackSetName $StackSetName -TemplateBody $TemplateBody -Parameter $Parameters ` -Capability $caps -PermissionModel SERVICE_MANAGED ` -AutoDeployment_Enabled $true -AutoDeployment_RetainStacksOnAccountRemoval $true ` -OperationPreference $opPrefs -Region $RegionName -ErrorAction Stop ) if ($updateId) { Wait-CiemStackSetOperation -StackSetName $StackSetName -OperationId $updateId -RegionName $RegionName | Out-Null } } catch { if ([string]$_ -notmatch 'No updates are to be performed') { throw } Write-Ok "StackSet $StackSetName already matches the template" } } else { if (-not $PSCmdlet.ShouldProcess($StackSetName, 'Create CloudFormation StackSet (CIEM inventory)')) { return } New-CFNStackSet -StackSetName $StackSetName -TemplateBody $TemplateBody -Parameter $Parameters ` -Capability $caps -PermissionModel SERVICE_MANAGED ` -AutoDeployment_Enabled $true -AutoDeployment_RetainStacksOnAccountRemoval $true ` -Region $RegionName -ErrorAction Stop | Out-Null } $instances = @() try { $instances = @(Get-CFNStackInstanceList -StackSetName $StackSetName -Region $RegionName -ErrorAction Stop) } catch { } if ($instances.Count -eq 0) { if (-not $PSCmdlet.ShouldProcess($StackSetName, 'Create CloudFormation StackSet instances (CIEM inventory)')) { return } $createId = Get-CiemStackSetOperationId -Response ( New-CFNStackInstance -StackSetName $StackSetName -StackInstanceRegion @($RegionName) ` -DeploymentTargets_OrganizationalUnitId @($RootOrganizationalUnitId) ` -OperationPreference $opPrefs -Region $RegionName -ErrorAction Stop ) if (-not $createId) { throw "New-CFNStackInstance did not return an operation ID for $StackSetName" } Wait-CiemStackSetOperation -StackSetName $StackSetName -OperationId $createId -RegionName $RegionName | Out-Null } Write-Ok "StackSet $StackSetName deployed to organization accounts" } function Get-CiemStackInstanceStackName { param([Parameter(Mandatory)][string]$StackId) # arn:aws:cloudformation:us-east-1:123456789012:stack/StackSetName-guid/abc-def if ($StackId -notmatch ':stack/') { return $null } $afterStack = ($StackId -split ':stack/', 2)[1] if (-not $afterStack) { return $null } return ($afterStack -split '/')[0] } function Get-CiemStackSetRoleArn { param( [Parameter(Mandatory)][string]$StackSetName, [Parameter(Mandatory)][string]$AccountId, [Parameter(Mandatory)][string]$RegionName ) $instances = @() try { $instances = @( Get-CFNStackInstanceList -StackSetName $StackSetName -Region $RegionName ` -StackInstanceAccount $AccountId -StackInstanceRegion $RegionName -ErrorAction Stop ) } catch { Write-Verbose "Could not list StackSet instances for ${StackSetName}: $($_.Exception.Message)" return $null } $match = @( $instances | Where-Object { $_.Account -eq $AccountId -and $_.Region -eq $RegionName -and $_.StackInstanceStatus -notin @('CANCELLED', 'INOPERABLE', 'FAILED') } | Select-Object -First 1 ) if ($match.Count -eq 0) { return $null } $physicalName = Get-CiemStackInstanceStackName -StackId $match.StackId if (-not $physicalName) { return $null } $outputs = Get-CiemStackOutputs -StackName $physicalName -RegionName $RegionName if ($outputs['SailPointCIEMRoleARN']) { return [string]$outputs['SailPointCIEMRoleARN'] } return $null } function Get-CiemCloudTrailArns { param( [Parameter(Mandatory)][string]$HomeRegion, [string]$BucketName, [int]$MaxCount = 150, # Return only trails that log to $BucketName, instead of every trail in the account. [switch]$BucketOnly ) if ($BucketOnly -and [string]::IsNullOrWhiteSpace($BucketName)) { throw 'Get-CiemCloudTrailArns -BucketOnly requires -BucketName.' } $arns = [System.Collections.Generic.List[string]]::new() $bucketMatches = [System.Collections.Generic.List[string]]::new() $regions = @($HomeRegion) try { $regions = @( Get-EC2Region -Region $HomeRegion -ErrorAction Stop | ForEach-Object { [string]$_.RegionName } | Sort-Object -Unique ) } catch { Write-Verbose "Could not enumerate EC2 regions; scanning CloudTrail in ${HomeRegion} only." } $scopeNote = if ($BucketOnly) { " for bucket $BucketName" } else { '' } Write-Info "Scanning CloudTrail in $($regions.Count) region(s)$scopeNote (max $MaxCount ARN(s))" foreach ($region in $regions) { if ($arns.Count -ge $MaxCount) { break } try { foreach ($trail in @(Get-CTTrail -Region $region -ErrorAction Stop)) { $trailArn = [string]$trail.TrailARN if (-not $trailArn) { continue } $matchesBucket = $BucketName -and [string]$trail.S3BucketName -eq $BucketName if ($matchesBucket) { if (-not $bucketMatches.Contains($trailArn)) { $bucketMatches.Add($trailArn) } } elseif (-not $BucketOnly -and -not $arns.Contains($trailArn)) { $arns.Add($trailArn) } if (($arns.Count + $bucketMatches.Count) -ge $MaxCount) { break } } } catch { Write-Verbose "Could not list CloudTrail trails in ${region}: $($_.Exception.Message)" } } $ordered = [System.Collections.Generic.List[string]]::new() foreach ($arn in $bucketMatches) { if (-not $ordered.Contains($arn)) { $ordered.Add($arn) } } foreach ($arn in $arns) { if (-not $ordered.Contains($arn)) { $ordered.Add($arn) } } if ($ordered.Count -gt $MaxCount) { return @($ordered | Select-Object -First $MaxCount) } return @($ordered) } function Get-NormalizedCloudTrailArnList { param([string[]]$Value) $list = [System.Collections.Generic.List[string]]::new() foreach ($entry in @($Value)) { foreach ($part in @("$entry".Split(@(','), [System.StringSplitOptions]::RemoveEmptyEntries))) { $trimmed = $part.Trim() if ($trimmed -and -not $list.Contains($trimmed)) { $list.Add($trimmed) } } } return @($list) } function Select-CloudTrailLogBucketOption { param( [object[]]$Options, [string]$PreferredBucket ) $list = @($Options) if ($list.Count -eq 0) { return $null } if (-not [string]::IsNullOrWhiteSpace($PreferredBucket)) { $match = @($list | Where-Object { $_.BucketName -eq $PreferredBucket } | Select-Object -First 1) if ($match.Count -eq 1) { return $match[0] } } if ($list.Count -eq 1) { return $list[0] } return $null } function Get-CloudTrailLogBucketOptions { param( [Parameter(Mandatory)][string]$HomeRegion, [ValidateSet('HomeRegion', 'AllRegions')] [string]$ScanScope = 'HomeRegion' ) if (-not (Get-Command Get-CTTrail -ErrorAction SilentlyContinue)) { Ensure-AwsToolsModules -ExtraModules @( 'AWS.Tools.CloudTrail' 'AWS.Tools.EC2' ) } $byBucket = @{} $regions = @($HomeRegion) if ($ScanScope -eq 'AllRegions') { try { $regions = @( Get-EC2Region -Region $HomeRegion -ErrorAction Stop | ForEach-Object { [string]$_.RegionName } | Sort-Object -Unique ) } catch { Write-Verbose "Could not enumerate EC2 regions; scanning CloudTrail in ${HomeRegion} only." } Write-Info "Scanning CloudTrail in $($regions.Count) region(s)..." } else { Write-Info "Scanning CloudTrail in $HomeRegion..." } foreach ($region in $regions) { try { foreach ($trail in @(Get-CTTrail -Region $region -ErrorAction Stop)) { $bucketName = [string]$trail.S3BucketName if ([string]::IsNullOrWhiteSpace($bucketName)) { continue } if (-not $byBucket.ContainsKey($bucketName)) { $byBucket[$bucketName] = [System.Collections.Generic.List[string]]::new() } $trailArn = [string]$trail.TrailARN if ($trailArn -and -not $byBucket[$bucketName].Contains($trailArn)) { $byBucket[$bucketName].Add($trailArn) } } } catch { Write-Verbose "Could not list CloudTrail trails in ${region}: $($_.Exception.Message)" } } $options = [System.Collections.Generic.List[object]]::new() foreach ($bucketName in @($byBucket.Keys | Sort-Object)) { $options.Add([PSCustomObject]@{ BucketName = $bucketName TrailArns = @($byBucket[$bucketName]) }) } return @($options) } function Get-SaasCiemCloudTrailDeployPlan { param( [Parameter(Mandatory)][string]$BucketName, [Parameter(Mandatory)][string]$TrailName, [Parameter(Mandatory)][string]$RegionName ) Ensure-AwsToolsModules -ExtraModules @( 'AWS.Tools.S3' 'AWS.Tools.CloudTrail' ) $bucketExists = Test-AwsS3BucketExistsInAccount -BucketName $BucketName -RegionName $RegionName if (-not $bucketExists) { return [PSCustomObject]@{ Purpose = 'NewCloudTrailAndBucket' Message = 'Create a new S3 bucket and CloudTrail trail.' } } $trailArns = @(Get-CiemCloudTrailArns -HomeRegion $RegionName -BucketName $BucketName -MaxCount 5 -BucketOnly) if ($trailArns.Count -gt 0) { return [PSCustomObject]@{ Purpose = 'UseExisting' Message = "Reuse existing bucket $BucketName and $($trailArns.Count) CloudTrail trail(s)." TrailArns = $trailArns } } return [PSCustomObject]@{ Purpose = 'NewCloudTrailExistingBucket' Message = "Bucket $BucketName already exists; create a new CloudTrail trail in that bucket." } } function Deploy-SaasCiemCloudTrailResources { [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory)][ValidateSet('Commercial', 'GovCloud')][string]$Cloud, [Parameter(Mandatory)][string]$RegionName, [Parameter(Mandatory)][string]$ExternalId, [Parameter(Mandatory)][string]$BucketName, [string]$TrailName = 'sailpoint-ciem-cloud-trail', [string]$StackName = 'SailPointSaasCiemCloudTrail', [string]$ActivityRoleName = 'SailPointSaasCiemTrailRole', $AwsParams ) $bucketFailure = Test-AwsS3BucketName $BucketName if ($bucketFailure) { throw $bucketFailure } Ensure-AwsToolsModules -ExtraModules @( 'AWS.Tools.CloudFormation' 'AWS.Tools.CloudTrail' 'AWS.Tools.EC2' 'AWS.Tools.S3' ) Write-Step 'Creating CloudTrail and S3 bucket for AWS SaaS CIEM' Write-Info 'ISC still uses your SailPointAWSRole. SailPoint CloudFormation may create an auxiliary activity role that the SaaS source does not use.' $deployPlan = Get-SaasCiemCloudTrailDeployPlan -BucketName $BucketName -TrailName $TrailName -RegionName $RegionName Write-Info $deployPlan.Message $cloudTrailArns = [System.Collections.Generic.List[string]]::new() if ($deployPlan.Purpose -eq 'UseExisting') { foreach ($arn in @($deployPlan.TrailArns)) { if ($arn -and -not $cloudTrailArns.Contains($arn)) { $cloudTrailArns.Add($arn) } } Write-Ok "Using existing CloudTrail resources in bucket $BucketName" } else { if ($deployPlan.Purpose -eq 'NewCloudTrailExistingBucket') { $partition = (Get-SailPointTrustConfig -Cloud $Cloud).Partition $accountId = (Get-STSCallerIdentity -Region $RegionName -ErrorAction Stop).Account Write-Info "Applying CloudTrail write permissions on existing bucket $BucketName (SailPoint existing-bucket template does not)." Set-CloudTrailDeliveryBucketPolicy -BucketName $BucketName -AccountId $accountId ` -Partition $partition -RegionName $RegionName } $activityBody = Get-CiemTemplateDocument -Purpose $deployPlan.Purpose -Cloud $Cloud $activityTemplate = $activityBody | ConvertFrom-Json $activityValues = @{ RoleName = $ActivityRoleName ExternalId = $ExternalId RolePolicyName = 'SailPointSaasCiemTrailPolicy' BucketName = $BucketName } if ($activityTemplate.Parameters.PSObject.Properties.Name -contains 'TrailName') { $activityValues['TrailName'] = $TrailName } $activityParams = ConvertTo-CfnParameterList -TemplateObject $activityTemplate -Values $activityValues $outputs = Deploy-CiemCloudFormationStack -StackName $StackName -TemplateBody $activityBody ` -Parameters $activityParams -RegionName $RegionName -ActionLabel 'SaaS CIEM CloudTrail' if ($outputs['SailPointCIEMCloudTrailARN']) { $cloudTrailArns.Add([string]$outputs['SailPointCIEMCloudTrailARN']) } } # The stack output already names the trail it just created. Only fall back to a scan when the # template returned nothing, and even then keep it to trails that log to this bucket: unrelated # trails elsewhere in the account are not what CIEM should read. if ($cloudTrailArns.Count -eq 0) { foreach ($discovered in @(Get-CiemCloudTrailArns -HomeRegion $RegionName -BucketName $BucketName -BucketOnly)) { if (-not $cloudTrailArns.Contains($discovered)) { $cloudTrailArns.Add($discovered) } } } Write-Ok "CIEM will read $($cloudTrailArns.Count) CloudTrail ARN(s) from bucket $BucketName" return [PSCustomObject]@{ BucketName = $BucketName CloudTrailArns = @($cloudTrailArns) StackName = $StackName ActivityRoleName = $ActivityRoleName DeployPurpose = $deployPlan.Purpose } } function Get-SaasCiemCloudTrailArns { param( [Parameter(Mandatory)][string]$HomeRegion, [string]$BucketName, [string[]]$ExtraArn, [int]$MaxCount = 150, # Set when $ExtraArn is already authoritative, e.g. the trail this run just created. [switch]$SkipDiscovery ) $cloudTrailArns = [System.Collections.Generic.List[string]]::new() foreach ($arn in @(Get-NormalizedCloudTrailArnList -Value $ExtraArn)) { $cloudTrailArns.Add($arn) } if ($SkipDiscovery) { Write-Ok "Using $($cloudTrailArns.Count) CloudTrail ARN(s) from this run for Enable Cloud Infrastructure Entitlement Management" return @($cloudTrailArns) } # SPCloudTrailBucketPolicy grants s3 read on $BucketName only, so a trail logging anywhere else # is unreadable by ISC. Scope the scan to this bucket rather than listing the whole account. $bucketScoped = -not [string]::IsNullOrWhiteSpace($BucketName) Write-Step 'Discovering CloudTrail ARNs for AWS SaaS CIEM settings' foreach ($discovered in @(Get-CiemCloudTrailArns -HomeRegion $HomeRegion -BucketName $BucketName ` -MaxCount $MaxCount -BucketOnly:$bucketScoped)) { if (-not $cloudTrailArns.Contains($discovered)) { $cloudTrailArns.Add($discovered) } } if ($cloudTrailArns.Count -gt $MaxCount) { Write-Warning "More than $MaxCount CloudTrail ARNs were found; only the first $MaxCount are included in the output." return @($cloudTrailArns | Select-Object -First $MaxCount) } if ($cloudTrailArns.Count -eq 0) { Write-Warning "No CloudTrail trail logs into $BucketName. Paste the ARN from the CloudTrail console into ISC Additional Settings, or re-run with -CloudTrailArn." } else { Write-Ok "Included $($cloudTrailArns.Count) CloudTrail ARN(s) for Enable Cloud Infrastructure Entitlement Management" } return @($cloudTrailArns) } function Clear-CiemUnmanagedIdentityCenterPolicy { param( [Parameter(Mandatory)][string]$RoleName, [Parameter(Mandatory)][string]$RegionName, [string]$StackName ) # CloudFormation cannot create AWS::IAM::Policy resources whose names already exist as # unmanaged inline policies (from an earlier Set-CiemIdentityCenterPolicy run). $owned = @() if ($StackName) { try { $owned = @( Get-CFNStackResourceList -StackName $StackName -Region $RegionName -ErrorAction Stop | ForEach-Object { [string]$_.LogicalResourceId } ) } catch { } } $params = Get-IamCmdletParams -RegionName $RegionName foreach ($policyName in $script:CiemIdentityCenterPolicies.Keys) { if ($owned -contains $policyName) { continue } try { Remove-IAMRolePolicy @params -RoleName $RoleName -PolicyName $policyName -Force -ErrorAction Stop Write-Info "Removed unmanaged $policyName from $RoleName so the activity stack can create it" } catch { Write-Verbose "No unmanaged $policyName on ${RoleName}: $($_.Exception.Message)" } } } function Set-CiemIdentityCenterPolicy { param( [Parameter(Mandatory)][string]$RoleArn, [Parameter(Mandatory)][string]$RegionName, [switch]$IncludeReadOnly, [switch]$IncludeProvision, # The org activity stack owns the two template policies; only the supplement is ours to set. [switch]$SupplementOnly ) $roleName = ($RoleArn -split '/')[-1] $params = Get-IamCmdletParams -RegionName $RegionName $supplementName = $script:CiemIdentityCenterProvisionSupplementName $actions = [ordered]@{} $wanted = @{} if (-not $SupplementOnly) { foreach ($policyName in $script:CiemIdentityCenterPolicies.Keys) { $actions[$policyName] = $script:CiemIdentityCenterPolicies[$policyName] } $wanted['SailPointCIEMAuditICReadOnlyPolicy'] = [bool]$IncludeReadOnly $wanted['SailPointCIEMAuditICProvisionPolicy'] = [bool]$IncludeProvision } $actions[$supplementName] = $script:CiemIdentityCenterProvisionSupplement $wanted[$supplementName] = [bool]$IncludeProvision foreach ($policyName in $actions.Keys) { try { if ($wanted[$policyName]) { $document = New-StarPolicyDocument -Actions $actions[$policyName] Write-IAMRolePolicy @params -RoleName $roleName -PolicyName $policyName -PolicyDocument $document Write-Ok "Attached $policyName to $roleName" } else { Remove-IAMRolePolicy @params -RoleName $roleName -PolicyName $policyName -Force Write-Info "Removed $policyName from $roleName" } } catch { if ($wanted[$policyName]) { Write-Warning "Could not attach $policyName to ${roleName}: $($_.Exception.Message)" } else { Write-Verbose "No $policyName to remove from ${roleName}: $($_.Exception.Message)" } } } } function Test-CiemRoleTrust { param( [Parameter(Mandatory)][string]$RoleArn, [Parameter(Mandatory)][string[]]$ExpectedPrincipal, [Parameter(Mandatory)][string]$ExpectedExternalId, [Parameter(Mandatory)][string]$RegionName ) $roleName = ($RoleArn -split '/')[-1] $params = Get-IamCmdletParams -RegionName $RegionName $role = Get-IAMRole @params -RoleName $roleName $trust = ConvertFrom-IamPolicyDocument -Document $role.AssumeRolePolicyDocument $statement = @($trust.Statement | Where-Object { $_.Effect -eq 'Allow' } | Select-Object -First 1) if (-not $statement) { throw "CIEM role $roleName has no Allow statement in its trust policy." } $principal = $statement.Principal.AWS $actualPrincipals = @() if ($principal -is [System.Collections.IEnumerable] -and $principal -isnot [string]) { $actualPrincipals = @($principal) } elseif ($principal) { $actualPrincipals = @([string]$principal) } foreach ($expected in $ExpectedPrincipal) { if ($actualPrincipals -notcontains $expected) { Write-Warning "CIEM role trust policy does not include principal $expected." } } $externalId = $statement.Condition.StringEquals.'sts:ExternalId' if ($externalId -ne $ExpectedExternalId) { throw "CIEM role trust policy ExternalId mismatch. Expected '$ExpectedExternalId', got '$externalId'." } Write-Ok "Verified CIEM role trust policy on $roleName." } function Sync-CiemRoleTrust { param( [Parameter(Mandatory)][string]$RoleArn, [Parameter(Mandatory)][string[]]$PrincipalArn, [Parameter(Mandatory)][string]$ExternalIdValue, [Parameter(Mandatory)][string]$RegionName ) # The published templates trust one CIEM principal. Tenants whose connector runtime assumes from # another SailPoint account fail Test Connection with "Failed to get account authorization details". if (-not $PrincipalArn -or $PrincipalArn.Count -eq 0) { return } $roleName = ($RoleArn -split '/')[-1] $trustDoc = New-TrustPolicyDocument -PrincipalArn $PrincipalArn -ExternalIdValue $ExternalIdValue $params = Get-IamCmdletParams -RegionName $RegionName try { Update-IAMAssumeRolePolicy @params -RoleName $roleName -PolicyDocument $trustDoc Write-Ok "Trust policy on $roleName allows: $($PrincipalArn -join ', ')" } catch { Write-Warning "Could not update the trust policy on ${roleName}: $($_.Exception.Message)" Write-Warning "The role keeps the template trust (CIEM only). Add the missing principal manually if Test Connection fails." } } function New-CloudTrailBucketPolicyDocument { param( [Parameter(Mandatory)][string]$BucketName, [Parameter(Mandatory)][string]$Partition ) $bucketArn = "arn:${Partition}:s3:::$BucketName" return ConvertTo-IamJson @{ Version = '2012-10-17' Statement = @( @{ Sid = 'CloudTrailBucket' Effect = 'Allow' Action = @('s3:GetBucketLocation', 's3:ListBucket') Resource = $bucketArn } @{ Sid = 'CloudTrailObjects' Effect = 'Allow' Action = 's3:GetObject' Resource = "$bucketArn/*" } ) } } function New-CloudTrailDeliveryBucketPolicyDocument { param( [Parameter(Mandatory)][string]$BucketName, [Parameter(Mandatory)][string]$AccountId, [Parameter(Mandatory)][string]$Partition ) # Same statements as SailPoint commercial-activity-collection-new-cloudtrail-and-bucket.json. # The existing-bucket template creates the trail but does not attach this S3 resource policy. $bucketArn = "arn:${Partition}:s3:::$BucketName" return ConvertTo-IamJson @{ Version = '2012-10-17' Statement = @( @{ Sid = 'AWSCloudTrailAclCheck' Effect = 'Allow' Principal = @{ Service = 'cloudtrail.amazonaws.com' } Action = 's3:GetBucketAcl' Resource = $bucketArn } @{ Sid = 'AWSCloudTrailWrite' Effect = 'Allow' Principal = @{ Service = 'cloudtrail.amazonaws.com' } Action = 's3:PutObject' Resource = "$bucketArn/AWSLogs/$AccountId/*" Condition = @{ StringEquals = @{ 's3:x-amz-acl' = 'bucket-owner-full-control' } } } ) } } function Merge-CloudTrailDeliveryBucketPolicy { param( [AllowEmptyString()][string]$ExistingDocument, [Parameter(Mandatory)][string]$DeliveryDocument ) $replaceSids = @( 'AWSCloudTrailAclCheck' 'AWSCloudTrailWrite' 'AWSCloudTrailAclCheck20150319' 'AWSCloudTrailWrite20150319' ) $kept = [System.Collections.Generic.List[object]]::new() if (-not [string]::IsNullOrWhiteSpace($ExistingDocument)) { $existingText = ConvertTo-NormalizedS3BucketPolicyDocument -Document $ExistingDocument if (-not [string]::IsNullOrWhiteSpace($existingText)) { $existing = ConvertFrom-IamPolicyDocument -Document $existingText foreach ($stmt in @($existing.Statement)) { if ($replaceSids -contains [string]$stmt.Sid) { continue } $kept.Add($stmt) } } } $delivery = $DeliveryDocument | ConvertFrom-Json foreach ($stmt in @($delivery.Statement)) { $kept.Add($stmt) } return ConvertTo-IamJson @{ Version = '2012-10-17' Statement = @($kept) } } function Set-CloudTrailDeliveryBucketPolicy { param( [Parameter(Mandatory)][string]$BucketName, [Parameter(Mandatory)][string]$AccountId, [Parameter(Mandatory)][string]$Partition, [Parameter(Mandatory)][string]$RegionName ) Ensure-AwsToolsModules -ExtraModules @('AWS.Tools.S3') $bucketRegion = Resolve-AwsS3BucketRegion -BucketName $BucketName -FallbackRegion $RegionName if ($bucketRegion -ne $RegionName) { Write-Info "Bucket $BucketName is in $bucketRegion (session region is $RegionName)." } $delivery = New-CloudTrailDeliveryBucketPolicyDocument -BucketName $BucketName ` -AccountId $AccountId -Partition $Partition $existing = $null try { $existing = Get-S3BucketPolicy -BucketName $BucketName -Region $bucketRegion -Select Policy -ErrorAction Stop } catch { $msg = [string]$_.Exception.Message if ($msg -notmatch 'NoSuchBucketPolicy|does not exist|The bucket policy does not exist') { throw } } $existingText = ConvertTo-NormalizedS3BucketPolicyDocument -Document $existing $merged = Merge-CloudTrailDeliveryBucketPolicy -ExistingDocument $existingText -DeliveryDocument $delivery Write-S3BucketPolicy -BucketName $BucketName -Policy $merged -Region $bucketRegion -ErrorAction Stop Write-Ok "CloudTrail delivery policy applied on bucket $BucketName" } function Apply-DedicatedCiemSourceDeployment { param( [Parameter(Mandatory)][ValidateSet('Commercial', 'GovCloud')][string]$Cloud, [Parameter(Mandatory)][string]$RegionName, [Parameter(Mandatory)][ValidateSet('CurrentAccount', 'Organization')][string]$Scope, [Parameter(Mandatory)][string]$RoleName, [Parameter(Mandatory)][string]$ExternalId, [Parameter(Mandatory)][string]$BucketName, [Parameter(Mandatory)][ValidateSet('None', 'OrganizationManagement', 'ExistingCloudTrail', 'NewCloudTrailExistingBucket', 'NewCloudTrailAndBucket')][string]$ActivityMode, [Parameter(Mandatory)][string]$InventoryStackName, [Parameter(Mandatory)][string]$ActivityStackName, [Parameter(Mandatory)][string]$EnableIdentityStoreReadOnlyFlag, [Parameter(Mandatory)][string]$EnableIdentityStoreProvisionFlag, [Parameter(Mandatory)][string]$TrailNameValue, [string[]]$ExtraCloudTrailArn, [string[]]$TrustPrincipalList, $AwsParams, [Parameter(Mandatory)][string]$CurrentAccountId, [Parameter(Mandatory)][string]$ManagementAccountId, [switch]$IdentityCenterRequested ) $trustConfig = Get-SailPointTrustConfig -Cloud $Cloud $partition = $trustConfig.Partition $defaultPrincipals = @($trustConfig.Principals) $principals = Resolve-TrustPrincipal -Value $(if ($TrustPrincipalList) { $TrustPrincipalList } else { $defaultPrincipals }) -Partition $partition $baseValues = @{ RoleName = $RoleName ExternalId = $ExternalId RolePolicyName = 'SailPointCIEMAuditPolicy' BucketName = $BucketName } $roleArn = $null $trailFromStack = $false $cloudTrailArns = [System.Collections.Generic.List[string]]::new() foreach ($arn in @($ExtraCloudTrailArn)) { if ($arn -and -not $cloudTrailArns.Contains($arn)) { $cloudTrailArns.Add($arn) } } if ($Scope -eq 'Organization') { Write-Step 'Checking CloudFormation StackSets trusted access' Enable-CiemStackSetOrganizationsAccess -RegionName $RegionName -AwsParams $AwsParams | Out-Null Write-Step 'Deploying CIEM inventory StackSet (all organization accounts)' $inventoryBody = Get-CiemTemplateDocument -Purpose 'Inventory' -Cloud $Cloud $inventoryTemplate = $inventoryBody | ConvertFrom-Json $inventoryParams = ConvertTo-CfnParameterList -TemplateObject $inventoryTemplate -Values $baseValues $root = Get-ORGRoot @AwsParams try { Deploy-CiemInventoryStackSet -StackSetName $InventoryStackName -TemplateBody $inventoryBody ` -Parameters $inventoryParams -RegionName $RegionName -RootOrganizationalUnitId $root.Id -AwsParams $AwsParams } catch { Write-Warning $_.Exception.Message Write-Warning 'StackSet deployment requires CloudFormation StackSets trusted access for AWS Organizations in the management account.' throw } if ($ActivityMode -eq 'OrganizationManagement') { Write-Step 'Deploying CIEM activity stack (management account)' Clear-CiemUnmanagedIdentityCenterPolicy -RoleName $RoleName -RegionName $RegionName -StackName $ActivityStackName $activityBody = Get-CiemTemplateDocument -Purpose 'OrganizationManagement' -Cloud $Cloud $activityTemplate = $activityBody | ConvertFrom-Json $activityValues = $baseValues.Clone() $activityValues['EnableIdentityStoreReadOnly'] = $EnableIdentityStoreReadOnlyFlag $activityValues['EnableIdentityStoreProvision'] = $EnableIdentityStoreProvisionFlag $activityParams = ConvertTo-CfnParameterList -TemplateObject $activityTemplate -Values $activityValues $outputs = Deploy-CiemCloudFormationStack -StackName $ActivityStackName -TemplateBody $activityBody ` -Parameters $activityParams -RegionName $RegionName -ActionLabel 'CIEM activity' if ($outputs['SailPointCIEMRoleARN']) { $roleArn = $outputs['SailPointCIEMRoleARN'] } if ($outputs['SailPointCIEMCloudTrailARN']) { $cloudTrailArns.Add($outputs['SailPointCIEMCloudTrailARN']) $trailFromStack = $true } } else { $resolvedArn = Get-CiemStackSetRoleArn -StackSetName $InventoryStackName ` -AccountId $ManagementAccountId -RegionName $RegionName if ($resolvedArn) { $roleArn = $resolvedArn Write-Ok "Role ARN from management-account StackSet instance: $roleArn" } else { $roleArn = "arn:${partition}:iam::${ManagementAccountId}:role/${RoleName}" Write-Warning "Could not read SailPointCIEMRoleARN from the StackSet instance in $ManagementAccountId; using constructed Role ARN." } } } else { if ($ActivityMode -eq 'None') { Write-Step 'Deploying CIEM inventory stack (single account)' $inventoryBody = Get-CiemTemplateDocument -Purpose 'Inventory' -Cloud $Cloud $inventoryTemplate = $inventoryBody | ConvertFrom-Json $inventoryParams = ConvertTo-CfnParameterList -TemplateObject $inventoryTemplate -Values $baseValues $outputs = Deploy-CiemCloudFormationStack -StackName $InventoryStackName -TemplateBody $inventoryBody ` -Parameters $inventoryParams -RegionName $RegionName -ActionLabel 'CIEM inventory' if ($outputs['SailPointCIEMRoleARN']) { $roleArn = $outputs['SailPointCIEMRoleARN'] } } else { Write-Step "Deploying CIEM activity stack ($ActivityMode)" $activityBody = Get-CiemTemplateDocument -Purpose $ActivityMode -Cloud $Cloud $activityTemplate = $activityBody | ConvertFrom-Json $activityValues = $baseValues.Clone() if ($activityTemplate.Parameters.PSObject.Properties.Name -contains 'TrailName') { $activityValues['TrailName'] = $TrailNameValue } if ($ActivityMode -eq 'NewCloudTrailExistingBucket') { Write-Info "Applying CloudTrail write permissions on existing bucket $BucketName (SailPoint existing-bucket template does not)." Set-CloudTrailDeliveryBucketPolicy -BucketName $BucketName -AccountId $CurrentAccountId ` -Partition $partition -RegionName $RegionName } $activityParams = ConvertTo-CfnParameterList -TemplateObject $activityTemplate -Values $activityValues $outputs = Deploy-CiemCloudFormationStack -StackName $ActivityStackName -TemplateBody $activityBody ` -Parameters $activityParams -RegionName $RegionName -ActionLabel "CIEM $ActivityMode" if ($outputs['SailPointCIEMRoleARN']) { $roleArn = $outputs['SailPointCIEMRoleARN'] } if ($outputs['SailPointCIEMCloudTrailARN']) { $cloudTrailArns.Add($outputs['SailPointCIEMCloudTrailARN']) $trailFromStack = $true } } } if (-not $roleArn) { $roleArn = "arn:${partition}:iam::${CurrentAccountId}:role/${RoleName}" } Write-Step 'Reconciling CIEM role trust policy' if ($Scope -eq 'Organization' -and -not $TrustPrincipalList) { Write-Info "Only the role in $CurrentAccountId is updated here; StackSet member accounts keep the template trust." } Sync-CiemRoleTrust -RoleArn $roleArn -PrincipalArn $principals -ExternalIdValue $ExternalId -RegionName $RegionName Test-CiemRoleTrust -RoleArn $roleArn -ExpectedPrincipal $principals -ExpectedExternalId $ExternalId -RegionName $RegionName # Identity Center is Organization-only. Single Account (Account instance) on the CIEM AWS source # cannot enable it. Only the org activity template has Identity Store parameters; other org paths # attach the equivalent inline policies when the operator asked for Identity Center. $templateAppliesIdentityCenter = ($Scope -eq 'Organization' -and $ActivityMode -eq 'OrganizationManagement') $icReadOnly = $false $icProvision = $false if ($Scope -eq 'Organization') { $icReadOnly = ($EnableIdentityStoreReadOnlyFlag -eq 'true') $icProvision = ($EnableIdentityStoreProvisionFlag -eq 'true') if ($templateAppliesIdentityCenter) { # The stack creates the two template policies, which are short of the documented # provisioning permissions, so the supplement is attached on top of them. Write-Step 'Applying Identity Center provisioning supplement' Set-CiemIdentityCenterPolicy -RoleArn $roleArn -RegionName $RegionName ` -IncludeProvision:$icProvision -SupplementOnly } else { $icReadOnly = $icReadOnly -and $IdentityCenterRequested $icProvision = $icProvision -and $IdentityCenterRequested Write-Step 'Applying Identity Center permissions' if (-not ($icReadOnly -or $icProvision)) { Write-Info 'Identity Center is off for this deployment. Leave Provision Identity Center disabled on the CIEM source.' } Set-CiemIdentityCenterPolicy -RoleArn $roleArn -RegionName $RegionName ` -IncludeReadOnly:$icReadOnly -IncludeProvision:$icProvision } } else { Write-Info 'Identity Center is off: the CIEM AWS source does not support it with Single Account (Account instance).' Set-CiemIdentityCenterPolicy -RoleArn $roleArn -RegionName $RegionName } # The trail the activity stack reported is the one CIEM reads. Scan only when no stack named one, # and keep the scan to trails logging into this bucket. if (-not $trailFromStack) { foreach ($discovered in @(Get-CiemCloudTrailArns -HomeRegion $RegionName -BucketName $BucketName -BucketOnly)) { if (-not $cloudTrailArns.Contains($discovered)) { $cloudTrailArns.Add($discovered) } } } if ($cloudTrailArns.Count -gt 150) { Write-Warning "More than 150 CloudTrail ARNs were found; only the first 150 are included in the output." $cloudTrailArns = @($cloudTrailArns | Select-Object -First 150) } if ($cloudTrailArns.Count -eq 0) { Write-Warning "No CloudTrail trail logs into $BucketName. Paste the ARN from the CloudTrail console into the CIEM AWS source, or re-run with -CloudTrailArn." } return [PSCustomObject]@{ RoleArn = $roleArn RoleName = $RoleName ExternalId = $ExternalId CloudTrailArns = @($cloudTrailArns) BucketName = $BucketName SingleAccount = ($Scope -eq 'CurrentAccount') ProvisionIdentityCenter = $icProvision ReadIdentityCenter = $icReadOnly InventoryStackName = $InventoryStackName ActivityStackName = if ($ActivityMode -ne 'None') { $ActivityStackName } else { '' } } } function Get-CiemPolicyActions { return @($script:CiemActions) } function Get-CiemIdentityCenterRequirementActions { Initialize-AwsCiemConnectorData $readOnly = @($script:CiemIdentityCenterPolicies.SailPointCIEMAuditICReadOnlyPolicy) $readWrite = @( $script:CiemIdentityCenterPolicies.SailPointCIEMAuditICProvisionPolicy + $script:CiemIdentityCenterProvisionSupplement ) return [PSCustomObject]@{ ReadOnly = $readOnly; ReadWrite = $readWrite } } function Test-EmbeddedCiemSelected { param([string[]]$FeatureNames) return @($FeatureNames) -contains 'Ciem' } function Test-AwsSaasCiemFeature { param([string[]]$FeatureNames) return Test-EmbeddedCiemSelected -FeatureNames $FeatureNames } function Get-EmbeddedCiemFeaturePack { return $script:EmbeddedCiemFeaturePack } function Apply-EmbeddedCiemPrerequisites { param($Context) if (-not (Test-EmbeddedCiemSelected -FeatureNames $Context.FeatureNames)) { return } if ([string]::IsNullOrWhiteSpace($Context.CloudTrailBucket)) { throw 'AwsSaas -Feature Ciem requires -CloudTrailBucket so SPCloudTrailBucketPolicy and CIEM Additional Settings can be filled.' } $bucketFailure = Test-AwsS3BucketName $Context.CloudTrailBucket if ($bucketFailure) { throw $bucketFailure } if ([string]::IsNullOrWhiteSpace($Context.CloudTrailBucketAccountId)) { $Context.CloudTrailBucketAccountId = $Context.SessionAccountId } $bucketAccountFailure = Test-AwsAccountId $Context.CloudTrailBucketAccountId if ($bucketAccountFailure) { throw $bucketAccountFailure } Ensure-AwsToolsModules -ExtraModules @( 'AWS.Tools.CloudTrail' 'AWS.Tools.EC2' ) } function Get-CiemContextFlag { param( $Context, [Parameter(Mandatory)][string]$Name ) if ($Context -is [System.Collections.IDictionary]) { return [bool]$Context[$Name] } if ($Context.PSObject.Properties.Name -contains $Name) { return [bool]$Context.$Name } return $false } function Build-EmbeddedCiemConnectionSettings { param($Context) if (-not (Test-EmbeddedCiemSelected -FeatureNames $Context.FeatureNames)) { return [ordered]@{} } $skipDiscovery = Get-CiemContextFlag -Context $Context -Name 'SkipCloudTrailDiscovery' $saasCloudTrailArns = @(Get-SaasCiemCloudTrailArns -HomeRegion $Context.Region -BucketName $Context.CloudTrailBucket ` -ExtraArn $Context.CloudTrailArn -SkipDiscovery:$skipDiscovery) $saasCloudTrailList = if ($saasCloudTrailArns.Count -gt 0) { $saasCloudTrailArns -join ', ' } else { '' } return [ordered]@{ 'Enable Cloud Infrastructure Entitlement Management' = 'Yes' 'CloudTrail ARN(s)' = $saasCloudTrailList 'CloudTrail bucket account ID' = $Context.CloudTrailBucketAccountId 'CloudTrail bucket' = $Context.CloudTrailBucket } } function Build-DedicatedCiemConnectionSettings { param( [Parameter(Mandatory)]$DeploymentResult, [Parameter(Mandatory)][string]$SessionAccountId ) $cloudTrailList = if ($DeploymentResult.CloudTrailArns.Count -gt 0) { $DeploymentResult.CloudTrailArns -join ', ' } else { '' } return [PSCustomObject]@{ Fields = [ordered]@{ 'Role ARN' = $DeploymentResult.RoleArn 'External ID' = $DeploymentResult.ExternalId 'CloudTrail ARN(s)' = $cloudTrailList 'CloudTrail bucket account ID' = $SessionAccountId 'Single Account' = if ($DeploymentResult.SingleAccount) { 'Yes' } else { 'No' } 'Provision Identity Center' = if ($DeploymentResult.ProvisionIdentityCenter) { 'Yes' } else { 'No' } 'Identity Center read permissions' = if ($DeploymentResult.ReadIdentityCenter) { 'Yes' } else { 'No' } 'CloudTrail bucket' = $DeploymentResult.BucketName } CloudTrailList = $cloudTrailList } } Export-ModuleMember -Function @( 'Initialize-AwsCiemConnectorData', 'Test-EmbeddedCiemSelected', 'Test-AwsSaasCiemFeature', 'Get-EmbeddedCiemFeaturePack', 'Apply-EmbeddedCiemPrerequisites', 'Build-EmbeddedCiemConnectionSettings', 'Get-NormalizedCloudTrailArnList', 'Get-CiemCloudTrailArns', 'Get-CloudTrailLogBucketOptions', 'Select-CloudTrailLogBucketOption', 'Deploy-SaasCiemCloudTrailResources', 'Get-SaasCiemCloudTrailArns', 'Get-CiemTemplateFileName', 'Get-CiemTemplateUrl', 'Get-CiemTemplateDocument', 'ConvertTo-CfnParameterList', 'Get-CiemCfnStackIfExists', 'Test-CiemCfnStackNeedsDeleteBeforeRedeploy', 'Select-CiemCfnStackFailureLines', 'Get-CiemCfnStackFailureLines', 'Wait-CiemCfnStackDeleted', 'Clear-CiemCfnStackForRedeploy', 'Wait-CiemCfnStack', 'Get-SaasCiemCloudTrailDeployPlan', 'Get-CiemStackSetOperationId', 'Wait-CiemStackSetOperation', 'Get-CiemStackOutputs', 'Deploy-CiemCloudFormationStack', 'Enable-CiemStackSetOrganizationsAccess', 'Deploy-CiemInventoryStackSet', 'Get-CiemStackInstanceStackName', 'Get-CiemStackSetRoleArn', 'Apply-DedicatedCiemSourceDeployment', 'Build-DedicatedCiemConnectionSettings', 'New-CloudTrailBucketPolicyDocument', 'New-CloudTrailDeliveryBucketPolicyDocument', 'Merge-CloudTrailDeliveryBucketPolicy', 'Set-CloudTrailDeliveryBucketPolicy', 'Sync-CiemRoleTrust', 'Test-CiemRoleTrust', 'Set-CiemIdentityCenterPolicy', 'Clear-CiemUnmanagedIdentityCenterPolicy', 'Get-CiemPolicyActions' 'Get-CiemIdentityCenterRequirementActions' ) |