modules/ISC.AwsConnector.psm1

#Requires -Version 5.1
Set-StrictMode -Version Latest

function Initialize-AwsConnectorData {
    $script:CreatedRoleArn = $null

$script:SailPointTrust = @{
    # CIEM = documented commercial principal. ISC SaaS = connector runtime that actually
    # calls sts:AssumeRole for some tenants (not an AWS region mapping).
    Commercial = @{
        Partition  = 'aws'
        Principals = @(
            'arn:aws:iam::874540850173:role/ciem_universal'
            'arn:aws:iam::706944607044:role/ciem_universal'
        )
    }
    GovCloud = @{
        Partition  = 'aws-us-gov'
        Principals = @(
            'arn:aws-us-gov:iam::229634586956:role/ciem_universal'
        )
    }
}
}

function Get-AwsCreatedRoleArn {
    return $script:CreatedRoleArn
}

function Set-AwsCreatedRoleArn {
    param([string]$Arn)
    $script:CreatedRoleArn = $Arn
}

function Get-SailPointTrustConfig {
    param(
        [Parameter(Mandatory)]
        [ValidateSet('Commercial', 'GovCloud')]
        [string]$Cloud
    )

    return [PSCustomObject]@{
        Partition  = $script:SailPointTrust[$Cloud].Partition
        Principals = @($script:SailPointTrust[$Cloud].Principals)
    }
}

function Get-DefaultCiemRoleName {
    return 'SailPointCIEMAuditRole'
}

function Get-DefaultCiemCloudTrailBucketName {
    param([Parameter(Mandatory)][string]$AccountId)
    return "sailpoint-ciem-$AccountId"
}

function Get-DefaultSaasRoleName {
    return 'SailPointAWSRole'
}

function Get-ConnectionSettingsOutput {
    param(
        [Parameter(Mandatory)][ValidateSet('AwsSaas', 'CiemAws')][string]$Type,
        [string]$Directory
    )

    if ([string]::IsNullOrWhiteSpace($Directory)) {
        $subDir = if ($Type -eq 'CiemAws') { 'aws-ciem' } else { 'aws-isc' }
        $Directory = Join-Path (Get-Location) (Join-Path 'wizard-output' $subDir)
    }
    $fileName = if ($Type -eq 'CiemAws') { 'sailpoint-ciem-aws-connection-settings.txt' } else { 'sailpoint-aws-connection-settings.txt' }
    return [PSCustomObject]@{
        Directory = $Directory
        FilePath  = Join-Path $Directory $fileName
        FileName  = $fileName
    }
}

function Test-AwsS3BucketName {
    param([Parameter(Mandatory)][string]$Name)

    if ($Name.Length -lt 3 -or $Name.Length -gt 63) {
        return 'S3 bucket names must be 3-63 characters (lowercase letters, numbers, dots, and hyphens; e.g. sailpoint-ciem-123456789012).'
    }
    if ($Name -cnotmatch '^[a-z0-9][a-z0-9.-]*[a-z0-9]$') {
        return 'S3 bucket names must be lowercase DNS names (no uppercase or underscores). Example: sailpoint-ciem-123456789012.'
    }
    if ($Name -match '\.\.' -or $Name -match '^\d+\.\d+\.\d+\.\d+$') {
        return 'S3 bucket names cannot contain consecutive dots or look like an IPv4 address.'
    }
    return $null
}

function Test-AwsS3BucketExistsInAccount {
    param(
        [Parameter(Mandatory)][string]$BucketName,
        [string]$RegionName = 'us-east-1'
    )

    Ensure-AwsToolsModules -ExtraModules @('AWS.Tools.S3')
    try {
        Get-S3Bucket -BucketName $BucketName -Region $RegionName -ErrorAction Stop | Out-Null
        return $true
    }
    catch {
        $msg = [string]$_.Exception.Message
        if ($msg -match 'NotFound|NoSuchBucket|does not exist|404') { return $false }
        throw
    }
}

function Get-AwsS3BucketNames {
    param([string]$RegionName = 'us-east-1')

    if (-not (Get-Command Get-S3Bucket -ErrorAction SilentlyContinue)) {
        Ensure-AwsToolsModules -ExtraModules @('AWS.Tools.S3')
    }
    try {
        return @(
            Get-S3Bucket -Region $RegionName -ErrorAction Stop |
                ForEach-Object { [string]$_.BucketName } |
                Where-Object { $_ } |
                Sort-Object
        )
    }
    catch {
        Write-Verbose "Could not list S3 buckets: $($_.Exception.Message)"
        return @()
    }
}

function ConvertTo-AwsRegionNameFromS3Location {
    param(
        $Location,
        [string]$FallbackRegion = 'us-east-1'
    )

    $text = ''
    if ($null -eq $Location) {
        $text = ''
    }
    elseif ($Location -is [string]) {
        $text = $Location.Trim()
    }
    elseif ($Location.PSObject.Properties['Value'] -and $null -ne $Location.Value) {
        $text = [string]$Location.Value
    }
    elseif ($Location.PSObject.Properties['LocationConstraint'] -and $null -ne $Location.LocationConstraint) {
        $constraint = $Location.LocationConstraint
        $text = if ($constraint.PSObject.Properties['Value']) { [string]$constraint.Value } else { [string]$constraint }
    }
    else {
        $text = [string]$Location
    }
    $text = $text.Trim()
    if ($text -eq 'None' -or $text -eq 'Amazon.S3.S3Region') {
        $text = ''
    }
    if ($text.StartsWith('<')) {
        try {
            $xml = [xml]$text
            $node = $xml.SelectSingleNode('//*[local-name()="LocationConstraint"]')
            if ($node) { $text = [string]$node.InnerText }
        }
        catch {
            $text = ''
        }
        $text = $text.Trim()
    }

    if ([string]::IsNullOrWhiteSpace($text)) {
        if ($FallbackRegion -match 'gov') { return $FallbackRegion }
        return 'us-east-1'
    }
    if ($text -eq 'EU') { return 'eu-west-1' }
    return $text
}

function ConvertTo-NormalizedS3BucketPolicyDocument {
    param(
        [AllowNull()]
        [AllowEmptyString()]
        $Document
    )

    if ($null -eq $Document) { return '' }

    $text = ''
    if ($Document -is [string]) {
        $text = $Document
    }
    elseif ($Document -is [System.Xml.XmlNode]) {
        $text = $Document.OuterXml
    }
    elseif ($Document.PSObject.Properties['Policy'] -and $null -ne $Document.Policy) {
        $policy = $Document.Policy
        $text = if ($policy -is [System.Xml.XmlNode]) { $policy.InnerText } else { [string]$policy }
    }
    else {
        $text = [string]$Document
    }
    $text = $text.Trim()
    if (-not $text -or $text -eq 'Amazon.S3.Model.GetBucketPolicyResponse') { return '' }

    if ($text -match '^[\{\[]') { return $text }
    if ($text -notmatch '^[\{\[]' -and $text -match '%7[Bb]') {
        $decoded = [System.Uri]::UnescapeDataString($text).Trim()
        if ($decoded -match '^[\{\[]') { return $decoded }
        $text = $decoded
    }

    if ($text -match '(?is)^<(?:!DOCTYPE\s+html|html)\b') {
        throw 'S3 bucket policy response was HTML, not JSON. Confirm the bucket region and that Get-S3BucketPolicy is not hitting an HTTP error page.'
    }

    if ($text.StartsWith('<')) {
        $xml = $null
        try { $xml = [xml]$text } catch { $xml = $null }
        if ($null -ne $xml) {
            $codeNode = $xml.SelectSingleNode('//*[local-name()="Code"]')
            $messageNode = $xml.SelectSingleNode('//*[local-name()="Message"]')
            $code = if ($codeNode) { [string]$codeNode.InnerText } else { '' }
            $message = if ($messageNode) { [string]$messageNode.InnerText } else { '' }
            if ($code -match 'NoSuchBucketPolicy') { return '' }
            if ($code) {
                $detail = if ($message) { "${code}: $message" } else { $code }
                throw "S3 bucket policy request failed ($detail)."
            }
            $policyNode = $xml.SelectSingleNode('//*[local-name()="Policy"]')
            if ($policyNode) {
                $inner = [string]$policyNode.InnerText
                if (-not $inner) { $inner = [string]$policyNode.InnerXml }
                $inner = $inner.Trim()
                if ($inner -match '^[\{\[]') { return $inner }
            }
        }
        $start = $text.IndexOf('{')
        $end = $text.LastIndexOf('}')
        if ($start -ge 0 -and $end -gt $start) {
            return $text.Substring($start, $end - $start + 1)
        }
        throw 'S3 bucket policy response was XML, not JSON.'
    }

    return $text
}

function Resolve-AwsS3BucketRegion {
    param(
        [Parameter(Mandatory)][string]$BucketName,
        [string]$FallbackRegion = 'us-east-1'
    )

    Ensure-AwsToolsModules -ExtraModules @('AWS.Tools.S3')
    $candidates = [System.Collections.Generic.List[string]]::new()
    foreach ($region in @($FallbackRegion, 'us-east-1', 'us-gov-west-1')) {
        if ([string]::IsNullOrWhiteSpace($region)) { continue }
        if (-not $candidates.Contains($region)) { $candidates.Add($region) }
    }
    $lastError = $null
    foreach ($region in $candidates) {
        try {
            $location = Get-S3BucketLocation -BucketName $BucketName -Region $region -ErrorAction Stop
            return (ConvertTo-AwsRegionNameFromS3Location -Location $location -FallbackRegion $FallbackRegion)
        }
        catch {
            $lastError = $_
        }
    }
    if ($lastError) { throw $lastError }
    return $FallbackRegion
}

function Test-AwsAccountId {
    param([string]$Value)

    if ($Value -notmatch '^\d{12}$') {
        return 'Use a 12-digit AWS account ID.'
    }
    return $null
}

function Connect-AwsSession {
    param(
        [string]$RequestedProfile,
        [string]$RequestedRegion
    )

    if ($RequestedProfile) {
        Set-AWSCredential -ProfileName $RequestedProfile
    }

    $identity = $null
    try {
        $identity = Get-STSCallerIdentity -Region $RequestedRegion -ErrorAction Stop
    }
    catch {
        throw "AWS authentication failed. Configure a profile (aws configure / aws sso login) or pass -ProfileName. $($_.Exception.Message)"
    }

    Write-Ok "Connected as $($identity.Arn)"
    Write-Ok "Account: $($identity.Account)"
    Write-Ok "Region: $RequestedRegion"

    return [PSCustomObject]@{
        Account = $identity.Account
        Arn     = $identity.Arn
        UserId  = $identity.UserId
        Region  = $RequestedRegion
    }
}

function ConvertTo-IamJson {
    param([Parameter(Mandatory)]$Document)
    return ($Document | ConvertTo-Json -Depth 12 -Compress)
}

function ConvertFrom-IamPolicyDocument {
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Document)

    # IAM returns policy documents percent-encoded, so AssumeRolePolicyDocument arrives as '%7B...'.
    $text = "$Document".Trim()
    if (-not $text) { throw 'IAM returned an empty policy document.' }
    if ($text -notmatch '^[\{\[]') {
        $text = [System.Uri]::UnescapeDataString($text)
    }
    if ($text.StartsWith('<')) {
        throw 'Policy document is XML or HTML, not JSON.'
    }
    return ($text | ConvertFrom-Json)
}

function New-StarPolicyDocument {
    param([Parameter(Mandatory)][string[]]$Actions)

    $unique = @($Actions | Sort-Object -Unique)
    return ConvertTo-IamJson @{
        Version   = '2012-10-17'
        Statement = @(
            @{
                Sid      = 'SailPointIsc'
                Effect   = 'Allow'
                Action   = $unique
                Resource = '*'
            }
        )
    }
}

function Resolve-TrustPrincipal {
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Value,
        [Parameter(Mandatory)][string]$Partition
    )

    $resolved = [System.Collections.Generic.List[string]]::new()
    foreach ($entry in $Value) {
        $trimmed = "$entry".Trim()
        if (-not $trimmed) { continue }

        if ($trimmed -match '^\d{12}$') {
            $resolved.Add("arn:${Partition}:iam::${trimmed}:role/ciem_universal")
        }
        elseif ($trimmed -match '^arn:[^:]+:iam::\d{12}:(role|user)/') {
            $resolved.Add($trimmed)
        }
        else {
            throw "Invalid -TrustPrincipal '$trimmed'. Use a 12-digit AWS account ID or a full IAM role ARN."
        }
    }

    return @($resolved | Select-Object -Unique)
}

function New-TrustPolicyDocument {
    param(
        [Parameter(Mandatory)][string[]]$PrincipalArn,
        [Parameter(Mandatory)][string]$ExternalIdValue,
        [switch]$AllowTagSession
    )

    # IAM stores a lone principal as a string; keep the single-principal document identical to the docs.
    $awsPrincipal = if ($PrincipalArn.Count -eq 1) { $PrincipalArn[0] } else { @($PrincipalArn) }

    # Activity Insights assumes the role with session tags, so it needs sts:TagSession as well.
    # https://documentation.sailpoint.com/connectors/saas/aws/help/saas_connectivity/aws/activity_insights.html
    $action = if ($AllowTagSession) { @('sts:AssumeRole', 'sts:TagSession') } else { 'sts:AssumeRole' }

    return ConvertTo-IamJson @{
        Version   = '2012-10-17'
        Statement = @(
            @{
                Effect    = 'Allow'
                Principal = @{ AWS = $awsPrincipal }
                Action    = $action
                Condition = @{
                    StringEquals = @{ 'sts:ExternalId' = $ExternalIdValue }
                }
            }
        )
    }
}

function Get-OrganizationAccountIds {
    param($AwsParams)

    $ids = [System.Collections.Generic.List[string]]::new()
    $accounts = @(Get-ORGAccountList @AwsParams)
    foreach ($account in $accounts) {
        if ($account.Status -eq 'ACTIVE') { $ids.Add($account.Id) }
    }
    return @($ids)
}

function Get-AwsOrganizationContext {
    param(
        [Parameter(Mandatory)][string]$CurrentAccountId,
        $AwsParams
    )

    try {
        $org = Get-ORGOrganization @AwsParams
    }
    catch {
        return [PSCustomObject]@{
            Available       = $false
            NotInUse        = ($_.Exception.Message -match 'AWSOrganizationsNotInUse|not a member of an organization')
            MasterAccountId = $null
            IsManagement    = $false
            AccountIds      = @()
            Error           = $_.Exception.Message
        }
    }

    $ids = @()
    try {
        $ids = @(Get-OrganizationAccountIds -AwsParams $AwsParams)
    }
    catch {
        return [PSCustomObject]@{
            Available       = $true
            NotInUse        = $false
            MasterAccountId = $org.MasterAccountId
            IsManagement    = ($org.MasterAccountId -eq $CurrentAccountId)
            AccountIds      = @()
            Error           = $_.Exception.Message
        }
    }

    return [PSCustomObject]@{
        Available       = $true
        NotInUse        = $false
        MasterAccountId = $org.MasterAccountId
        IsManagement    = ($org.MasterAccountId -eq $CurrentAccountId)
        AccountIds      = $ids
        Error           = $null
    }
}

function Get-MemberCredentials {
    param(
        [Parameter(Mandatory)][string]$AccountId,
        [Parameter(Mandatory)][string]$RoleNameToAssume,
        [Parameter(Mandatory)][string]$Partition,
        [Parameter(Mandatory)][string]$RegionName
    )

    $arn = "arn:${Partition}:iam::${AccountId}:role/${RoleNameToAssume}"
    $assumed = Use-STSRole -RoleArn $arn -RoleSessionName 'SailPointIscAwsSetup' -Region $RegionName -ErrorAction Stop
    return $assumed.Credentials
}

function Get-IamCmdletParams {
    param($Credential, [string]$RegionName)

    $params = @{ Region = $RegionName; ErrorAction = 'Stop' }
    if ($Credential) { $params.Credential = $Credential }
    return $params
}

Export-ModuleMember -Function @(
    'Initialize-AwsConnectorData',
    'Get-DefaultCiemRoleName',
    'Get-DefaultCiemCloudTrailBucketName',
    'Get-DefaultSaasRoleName',
    'Get-ConnectionSettingsOutput',
    'Test-AwsS3BucketName',
    'Test-AwsS3BucketExistsInAccount',
    'Get-AwsS3BucketNames',
    'ConvertTo-AwsRegionNameFromS3Location',
    'ConvertTo-NormalizedS3BucketPolicyDocument',
    'Resolve-AwsS3BucketRegion',
    'Test-AwsAccountId',
    'Connect-AwsSession',
    'ConvertTo-IamJson',
    'ConvertFrom-IamPolicyDocument',
    'New-StarPolicyDocument',
    'Resolve-TrustPrincipal',
    'New-TrustPolicyDocument',
    'Get-AwsOrganizationContext',
    'Get-OrganizationAccountIds',
    'Get-MemberCredentials',
    'Get-IamCmdletParams',
    'Get-AwsCreatedRoleArn',
    'Set-AwsCreatedRoleArn',
    'Get-SailPointTrustConfig'
)