modules/Legacy/GoogleCloud.Implementation.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    Creates or updates the GCP service account used by the SailPoint Identity Security Cloud
    Google Workspace SaaS connector.

.DESCRIPTION
    Covers both grant types the connector supports.

    Service Account (default, and the only grant type CIEM and NHI Discovery support): uses the
    Google Cloud SDK (gcloud) to create or update a service account, enable the APIs SailPoint
    documents, attach an organization-level custom IAM role for GCP / CIEM features, issue a JSON
    key, and convert it to the encrypted traditional RSA PEM the ISC source expects. Domain-wide
    delegation and Google Workspace admin roles still have to be granted in the Admin console; the
    script prints the client ID, OAuth scopes, and impersonate-user roles to paste there.

    Client Credentials: enables the same APIs, then runs the OAuth 2.0 authorization-code flow for
    an existing OAuth client (loopback redirect or the documented OAuth Playground redirect) and
    exchanges the code for the offline refresh token the source needs.

    Both modes end with copy-paste-ready Connection Settings values, written to files and offered
    for clipboard copy one field at a time.

    Existing service accounts with the same ID in the project are updated in place rather than duplicated.

    Reference:
    https://documentation.sailpoint.com/connectors/saas/googleworkspace/help/saas_connectivity/google_workspace/introduction.html
    https://documentation.sailpoint.com/connectors/saas/googleworkspace/help/saas_connectivity/google_workspace/prerequisites.html
    https://documentation.sailpoint.com/connectors/saas/googleworkspace/help/saas_connectivity/google_workspace/prereqs_for_oauth_2_0.html
    https://documentation.sailpoint.com/connectors/saas/googleworkspace/help/saas_connectivity/google_workspace/connection_settings.html

.PARAMETER GrantType
    ServiceAccount - service account, domain-wide delegation, impersonated admin (default).
    ClientCredentials - OAuth client ID, client secret, and offline refresh token.

.PARAMETER ClientId
    OAuth client ID for ClientCredentials, or for the Super Admin sign-in under
    -AssignWorkspaceRoles. Google has no API for creating OAuth clients, so the script walks
    you through the Cloud Console when this is omitted.

.PARAMETER ClientSecret
    OAuth client secret for ClientCredentials.

.PARAMETER RefreshToken
    Existing refresh token. Skips the authorization-code flow.

.PARAMETER RedirectUri
    Redirect URI registered on the OAuth client. Default: http://localhost:8088 (loopback listener).
    Pass https://developers.google.com/oauthplayground to paste the code by hand instead.

.PARAMETER ConsentUser
    Workspace user who authorizes the OAuth client and holds the admin roles under
    ClientCredentials. Used for GCP role binding; defaults to the signed-in gcloud account.

.PARAMETER OrganizationId
    GCP organization ID (numeric). Prompts when omitted.

.PARAMETER ProjectId
    GCP project ID that will own the service account. Prompts when omitted.

.PARAMETER CreateProject
    Create ProjectId under the organization when it does not exist.

.PARAMETER BillingAccountId
    Billing account to link when -CreateProject is used.

.PARAMETER ServiceAccountId
    Service account ID (the part before @). Default: sailpoint-isc-gws.

.PARAMETER DisplayName
    Service account display name. Default: SailPoint ISC Google Workspace.

.PARAMETER ImpersonateUser
    Google Workspace admin email the connector impersonates.

.PARAMETER Feature
    Optional documented feature packs. See the README.

.PARAMETER AggregationOnly
    Omit GCP write permissions (setIamPolicy, IAM role CRUD, service account create).
    Workspace OAuth scopes stay as SailPoint documents them for aggregation and test connection.

.PARAMETER RotateKey
    When updating an existing service account, issue a new JSON key and RSA PEM so the
    script can print Private Key and Private Key Password for Connection Settings.
    Google cannot retrieve an existing key. Omit this only when you already have those
    values from a previous run.

.PARAMETER KeyPassword
    Passphrase for the encrypted RSA private key. Generated when omitted.

.PARAMETER OutputDirectory
    Directory for the JSON key, RSA PEM, and scopes file. Default: ./wizard-output/google-workspace-isc

.PARAMETER SkipDomainWideDelegationWalkthrough
    Do not open the Admin console or copy the domain-wide delegation Client ID and scopes.

.PARAMETER AssignWorkspaceRoles
    After Service Account setup, sign in as a Super Admin and assign User Management Admin
    and Groups Admin to the impersonate user via the Admin SDK. Super Admin is only assigned
    when DomainManagement is selected (with confirmation). The Admin SDK needs a user token,
    so this needs an OAuth client: pass -ClientId and -ClientSecret, or let the script walk
    you through creating one in the Cloud Console. Domain-wide delegation itself has no
    public API and still uses the Admin console walkthrough.

.PARAMETER OpenSslPath
    openssl executable. Uses PATH when omitted.

.PARAMETER NonInteractive
    Fail instead of prompting when required values are missing.

.EXAMPLE
    .\Google Workspace.ps1

.EXAMPLE
    .\Google Workspace.ps1 -ProjectId 'sailpoint-isc' -ImpersonateUser 'admin@contoso.com' -Feature Gcp,Ciem -NonInteractive

.EXAMPLE
    .\Google Workspace.ps1 -GrantType ClientCredentials -ProjectId 'sailpoint-isc' -ClientId '...apps.googleusercontent.com' -ClientSecret 'GOCSPX-...'

.NOTES
    Sign in with gcloud as a user who can create service accounts and, for GCP/CIEM packs,
    organization custom roles and IAM bindings. The RSA passphrase is displayed once.
#>

[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
param(
    [Parameter()]
    [ValidateSet('ServiceAccount', 'ClientCredentials')]
    [string]$GrantType,

    [Parameter()]
    [string]$ClientId,

    [Parameter()]
    [string]$ClientSecret,

    [Parameter()]
    [string]$RefreshToken,

    [Parameter()]
    [string]$RedirectUri,

    [Parameter()]
    [string]$ConsentUser,

    [Parameter()]
    [string]$OrganizationId,

    [Parameter()]
    [string[]]$GcpRegions,

    [Parameter()]
    [string]$ProjectId,

    [Parameter()]
    [switch]$CreateProject,

    [Parameter()]
    [string]$BillingAccountId,

    [Parameter()]
    [string]$ServiceAccountId = 'sailpoint-isc-gws',

    [Parameter()]
    [string]$DisplayName = 'SailPoint ISC Google Workspace',

    [Parameter()]
    [string]$ImpersonateUser,

    [Parameter()]
    [ValidateSet('ActivityInsights', 'AgentDiscovery', 'Ciem', 'DeltaAggregation', 'DomainManagement',
        'Gcp', 'GmailDelegates', 'NhiDiscovery')]
    [string[]]$Feature,

    [Parameter()]
    [switch]$AggregationOnly,

    [Parameter()]
    [switch]$RotateKey,

    [Parameter()]
    [string]$KeyPassword,

    [Parameter()]
    [string]$OutputDirectory,

    [Parameter()]
    [switch]$SkipDomainWideDelegationWalkthrough,

    [Parameter()]
    [switch]$AssignWorkspaceRoles,

    [Parameter()]
    [string]$OpenSslPath,

    [Parameter()]
    [switch]$NonInteractive,

    [Parameter()]
    [ValidateSet('ReadOnly', 'ReadWrite')]
    [string]$Access
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$script:ModuleRoot = Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) 'modules'
Import-Module (Join-Path $script:ModuleRoot 'ISC.OperatorConsole.psm1') -Force -WarningAction SilentlyContinue
Import-IscModule -Path (Join-Path $script:ModuleRoot 'ISC.OperatorToolchain.psm1') -Force
Import-IscModule -Path (Join-Path $script:ModuleRoot 'ISC.GoogleWorkspaceConnector.psm1') -Force
Import-IscModule -Path (Join-Path $script:ModuleRoot 'ISC.GoogleCiemConnector.psm1') -Force
Import-IscModule -Path (Join-Path $script:ModuleRoot 'ISC.GoogleSourceSetup.psm1') -Force
Initialize-OperatorConsole -NonInteractive:$NonInteractive
Initialize-GoogleSourceSetup -ModuleRoot $script:ModuleRoot -NonInteractive:$NonInteractive
Initialize-GoogleWorkspaceConnectorData -OpenSslPath $OpenSslPath
$catalog = Get-GoogleWorkspaceCatalog



# -----------------------------------------------------------------------------
# Console helpers
# -----------------------------------------------------------------------------

function Write-Banner {
    Write-Host ''
    Write-Host ' SailPoint ISC - Google Workspace SaaS source connection setup' -ForegroundColor Cyan
    Write-Host ' Creates or updates the GCP service account used by the Google Workspace connector.' -ForegroundColor DarkCyan
    Write-Host ''
}

# Main
# -----------------------------------------------------------------------------

Write-Banner

try {
    $account = Connect-GoogleCloud

    $askGrantType = -not $GrantType
    $askOrganizationId = -not $OrganizationId
    $askProjectId = -not $ProjectId
    $askCreateProject = -not $PSBoundParameters.ContainsKey('CreateProject')
    $askBillingAccountId = -not $BillingAccountId
    $askRedirectUri = -not $RedirectUri
    $askFeature = -not $PSBoundParameters.ContainsKey('Feature')
    $askGcpRegions = -not $PSBoundParameters.ContainsKey('GcpRegions')
    $askAggregationOnly = -not $PSBoundParameters.ContainsKey('AggregationOnly') -and -not $NonInteractive
    $askRotateKey = -not $PSBoundParameters.ContainsKey('RotateKey') -and -not $NonInteractive
    $askKeyPassword = -not $KeyPassword

    $wizardComplete = $false
    while (-not $wizardComplete) {
        Start-WizardPass
        try {
            Write-Step 'Grant type'
            if (Enter-WizardPrompt) {
                if ($askGrantType) {
                    $GrantType = Read-Choice -Prompt 'Grant Type used by the ISC source' `
                        -Options @('ServiceAccount', 'ClientCredentials') `
                        -Labels @(
                            'Service Account - service account key + impersonated admin (required for CIEM and NHI Discovery)'
                            'Client Credentials - OAuth client ID, secret, and refresh token'
                        ) `
                        -Default $(if ($GrantType) { $GrantType } else { 'ServiceAccount' })
                }
                Complete-WizardPrompt
            }
            $isServiceAccount = $GrantType -eq 'ServiceAccount'
            Write-Ok $(if ($isServiceAccount) { 'Service Account' } else { 'Client Credentials' })

            if ($isServiceAccount) {
                $null = Ensure-OpenSslCommand -OpenSslPath $OpenSslPath
                Write-Ok 'openssl is available for RSA conversion'
            }

            Write-Step 'Organization'
            $orgs = @()
            try {
                $orgs = @(Invoke-GCloud -ExpectJson -GcloudArgs @('organizations', 'list', '--format=json', '--quiet'))
            }
            catch {
                Write-Warning "Could not list organizations: $($_.Exception.Message)"
            }
            if (Enter-WizardPrompt) {
                if ($orgs.Count -eq 1 -and $askOrganizationId -and -not $OrganizationId) {
                    $OrganizationId = [string]$orgs[0].name.Replace('organizations/', '')
                    Write-Ok "Using organization $($orgs[0].displayName) ($OrganizationId)"
                }
                elseif ($orgs.Count -gt 1 -and $askOrganizationId) {
                    $orgIds = @($orgs | ForEach-Object { [string]$_.name.Replace('organizations/', '') })
                    $orgLabels = @(
                        for ($i = 0; $i -lt $orgs.Count; $i++) {
                            '{0} ({1})' -f $orgs[$i].displayName, $orgIds[$i]
                        }
                    )
                    $OrganizationId = Read-Choice -Prompt 'GCP organization' -Options $orgIds -Labels $orgLabels -Default $(if ($OrganizationId) { $OrganizationId } else { $orgIds[0] })
                }
                elseif ($orgs.Count -eq 0 -and $askOrganizationId) {
                    $OrganizationId = Read-InputString -Prompt 'GCP organization ID (blank to skip GCP/CIEM org roles)' -Default $OrganizationId
                }
                Complete-WizardPrompt
            }
            if ($OrganizationId) {
                Write-Ok "Organization $OrganizationId"
            }
            else {
                Write-Info 'No organization selected. GCP / CIEM / NHI packs will not be available.'
            }

            Write-Step 'Project'
            $configuredProject = Invoke-GCloud -GcloudArgs @('config', 'get-value', 'project', '--quiet')
            if ($configuredProject -eq '(unset)') { $configuredProject = '' }
            if (Enter-WizardPrompt) {
                if ($askProjectId) {
                    $ProjectId = Read-InputString -Prompt 'GCP project ID' -Default $(if ($ProjectId) { $ProjectId } else { $configuredProject }) -Required
                }
                Complete-WizardPrompt
            }

            $projectExists = $false
            try {
                Invoke-GCloud -GcloudArgs @('projects', 'describe', $ProjectId, '--quiet') | Out-Null
                $projectExists = $true
                Write-Ok "Project $ProjectId exists"
            }
            catch {
                Write-Info "Project $ProjectId was not found"
            }

            if (Enter-WizardPrompt) {
                if (-not $projectExists) {
                    if ($askCreateProject) {
                        $CreateProject = [switch](Read-YesNo -Prompt "Create project $ProjectId?" -Default $true)
                    }
                    if (-not $CreateProject) {
                        throw "Project $ProjectId does not exist. Re-run with -CreateProject or pass an existing -ProjectId."
                    }
                    if (-not $OrganizationId) {
                        throw 'Creating a project requires -OrganizationId.'
                    }
                    if ($askBillingAccountId) {
                        $billing = @()
                        try {
                            $billing = @(Invoke-GCloud -ExpectJson -GcloudArgs @('billing', 'accounts', 'list', '--format=json', '--quiet'))
                        }
                        catch { }
                        $open = @($billing | Where-Object { $_.open -eq $true })
                        if ($open.Count -gt 0) {
                            $billIds = @($open | ForEach-Object { [string]$_.name.Replace('billingAccounts/', '') })
                            $billLabels = @($open | ForEach-Object { $_.displayName })
                            $BillingAccountId = Read-Choice -Prompt 'Billing account' -Options $billIds -Labels $billLabels -Default $(if ($BillingAccountId) { $BillingAccountId } else { $billIds[0] })
                        }
                        else {
                            $BillingAccountId = Read-InputString -Prompt 'Billing account ID' -Default $BillingAccountId -Required
                        }
                    }
                }
                Complete-WizardPrompt
            }

            if (Enter-WizardPrompt) {
                if ($isServiceAccount) {
                    $ServiceAccountId = Read-InputString -Prompt 'Service account ID' -Default $ServiceAccountId -Required -Validate {
                        param($v)
                        if ($v -notmatch '^[a-z][a-z0-9-]{4,28}[a-z0-9]$') {
                            return 'Use 6-30 characters: lowercase letters, digits, hyphens; must start with a letter.'
                        }
                    }
                }
                Complete-WizardPrompt
            }
            if (Enter-WizardPrompt) {
                if ($isServiceAccount) {
                    $DisplayName = Read-InputString -Prompt 'Service account display name' -Default $DisplayName -Required
                }
                Complete-WizardPrompt
            }
            if (Enter-WizardPrompt) {
                if ($isServiceAccount) {
                    # A consumer account has no Admin console, so it can be neither the impersonate
                    # user nor the Super Admin who authorizes delegation. Warn rather than block:
                    # only the operator knows which domain their Workspace tenant uses.
                    while ($true) {
                        $ImpersonateUser = Read-InputString -Prompt 'Email of Workspace user to impersonate' -Default $ImpersonateUser -Required -Validate {
                            param($v)
                            if ($v -notmatch '^[^@]+@[^@]+\.[^@]+$') { return 'Enter a full email address.' }
                            if ($v -match '@(gmail|googlemail)\.com$' -and $v -match '^[^@]*[^a-zA-Z0-9.+@]') {
                                return 'Gmail addresses only contain letters, digits, and dots, so this address cannot exist. Check for a typo.'
                            }
                        }
                        if ($ImpersonateUser -notmatch '@(gmail|googlemail)\.com$') { break }
                        Write-Warning 'That is a consumer Google account. The connector impersonates a Workspace admin in your managed domain, and only that domain has the Admin console where delegation and admin roles are granted.'
                        if ($NonInteractive -or (Read-YesNo -Prompt 'Use it anyway?' -Default $false)) { break }
                    }
                }
                Complete-WizardPrompt
            }
            if (Enter-WizardPrompt) {
                    if (-not $isServiceAccount) {
                    Write-Step 'OAuth client'
                    Write-Info 'Google has no API for creating OAuth clients, so this one is created in the Cloud Console.'
                    if ($askRedirectUri) {
                        $RedirectUri = Read-Choice -Prompt 'Redirect URI registered on that OAuth client' `
                            -Options @('http://localhost:8088', $catalog.PlaygroundRedirect) `
                            -Labels @(
                                'http://localhost:8088 - this script catches the code automatically'
                                "$($catalog.PlaygroundRedirect) - documented flow, paste the code by hand"
                            ) `
                            -Default $(if ($RedirectUri) { $RedirectUri } else { 'http://localhost:8088' })
                    }
                    if ($ClientId -and $ClientSecret) {
                        Write-Info "Make sure $RedirectUri is registered under Authorized redirect URIs on that client."
                    }
                    else {
                        Write-OAuthClientSteps -Redirect $RedirectUri -ClientName 'SailPoint ISC Google Workspace'
                        if (Read-YesNo -Prompt 'Open the Cloud Console page to create it?' -Default $true) {
                            $oauthCreateUrl = Get-OAuthClientCreateUrl -Project $ProjectId
                            if (Open-Url -Url $oauthCreateUrl) {
                                Write-Ok "Opened $oauthCreateUrl"
                            }
                            else {
                                Write-Info "Open $oauthCreateUrl"
                            }
                            Show-CopyPasteValue -Label 'Authorized redirect URI' -Value $RedirectUri `
                                -Instruction 'Paste under Authorized redirect URIs. It must match exactly.'
                        }
                    }
                    Write-Info 'An External app left in Testing expires refresh tokens after 7 days, which breaks the source. Publish it, or use an Internal app in a Workspace organization.'
                    Invoke-OAuthAudienceWalkthrough -Project $ProjectId -SignInAccount $(if ($ConsentUser) { $ConsentUser } else { $account })
                }
                Complete-WizardPrompt
            }
            if (Enter-WizardPrompt) {
                if (-not $isServiceAccount) {
                    $ClientId = Read-InputString -Prompt 'OAuth client ID' -Default $ClientId -Required
                }
                Complete-WizardPrompt
            }
            if (Enter-WizardPrompt) {
                if (-not $isServiceAccount) {
                    $ClientSecret = Read-InputString -Prompt 'OAuth client secret' -Default $ClientSecret -Required -Mask
                }
                Complete-WizardPrompt
            }
            if (Enter-WizardPrompt) {
                if (-not $isServiceAccount) {
                    $ConsentUser = Read-InputString -Prompt 'Workspace user that authorizes the client' -Default $(if ($ConsentUser) { $ConsentUser } else { $account }) -Required -Validate {
                        param($v)
                        if ($v -notmatch '^[^@]+@[^@]+\.[^@]+$') { return 'Enter a full email address.' }
                    }
                }
                Complete-WizardPrompt
            }

            if (Enter-WizardPrompt) {
                if ($askFeature) {
                    $packNames = @($catalog.FeaturePacks.Keys)
                    $packLabels = @($packNames | ForEach-Object { $catalog.FeaturePacks[$_].Label })
                    $Feature = @(Read-MultiChoice -Prompt 'Optional feature packs (Enter for none)' -Options $packNames -Labels $packLabels)
                }
                Complete-WizardPrompt
            }
            $Feature = @($Feature | Where-Object { $_ })

            if (Enter-WizardPrompt) {
                if (@($Feature) -contains 'AgentDiscovery' -and $askGcpRegions) {
                    $discovered = @()
                    if ($OrganizationId) {
                        $discovered = @(Get-GoogleVertexAgentRegions -OrgId $OrganizationId)
                    }
                    if ($discovered.Count) {
                        Write-Ok "Deployed Vertex AI agents found in: $($discovered -join ', ')"
                    }
                    else {
                        Write-Info 'No deployed Vertex AI agents were found in this organization. The connector reports no error when a region has none, so set the regions your agents will run in.'
                    }
                    $regionDefault = if ($GcpRegions -and $GcpRegions.Count) { $GcpRegions -join ', ' }
                        elseif ($discovered.Count) { $discovered -join ', ' }
                        else { 'us-central1' }
                    $regionInput = Read-InputString -Prompt 'GCP regions for Vertex AI agents (comma-separated)' `
                        -Default $regionDefault -Required
                    $GcpRegions = @($regionInput -split '[,\s]+' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
                }
                Complete-WizardPrompt
            }

            if (Enter-WizardPrompt) {
                if ($askAggregationOnly) {
                    $AggregationOnly = [switch](Read-YesNo -Prompt 'Aggregation only (skip GCP write permissions)?' -Default $false)
                }
                Complete-WizardPrompt
            }

            $needsGcp = Test-NeedsGcp -FeatureNames $Feature
            if ($needsGcp -and -not $OrganizationId) {
                throw 'Gcp, Ciem, NhiDiscovery, and AgentDiscovery require a GCP organization. Pass -OrganizationId.'
            }

            $saEmail = $null
            $isUpdate = $false
            if ($isServiceAccount) {
                $saEmail = Get-ServiceAccountEmail -AccountId $ServiceAccountId -Project $ProjectId
                $existing = $null
                if ($projectExists) {
                    $existing = Get-ExistingServiceAccount -Email $saEmail -Project $ProjectId
                }
                $isUpdate = $null -ne $existing

                if (Enter-WizardPrompt) {
                    if ($isUpdate) {
                        Write-Ok "Service account $saEmail already exists (update)"
                        if ($askRotateKey) {
                            Write-Host ''
                            Write-Info 'Google cannot show an existing private key again.'
                            Write-Info 'Y - issue a new key and print Private Key + Private Key Password for Connection Settings.'
                            Write-Info 'N - update APIs and roles only. Paste the PEM and password from a previous run.'
                            $RotateKey = [switch](Read-YesNo -Prompt 'Print a new Private Key and password for Connection Settings?' -Default $false)
                        }
                    }
                    else {
                        $RotateKey = [switch]$true
                        Write-Info "Will create $saEmail and print a new Private Key and password"
                    }
                    Complete-WizardPrompt
                }

                if (Enter-WizardPrompt) {
                    if ($RotateKey -and $askKeyPassword) {
                        if ($NonInteractive) {
                            $KeyPassword = New-KeyPassword
                        }
                        else {
                            $entered = Read-InputString -Prompt 'Private Key Password for the ISC source (blank to generate)' -Mask
                            $KeyPassword = if ($entered) { $entered } else { New-KeyPassword }
                        }
                    }
                    Complete-WizardPrompt
                }
            }
            else {
                if (Enter-WizardPrompt) { Complete-WizardPrompt }
                if (Enter-WizardPrompt) { Complete-WizardPrompt }
            }

            if (Enter-WizardPrompt) {
                if (-not $OutputDirectory) {
                    $OutputDirectory = Join-Path (Get-Location) (Join-Path 'wizard-output' 'google-workspace-isc')
                }
                $OutputDirectory = Read-InputString -Prompt 'Output directory for key files' -Default $OutputDirectory -Required
                Complete-WizardPrompt
            }

            $scopes = Get-SelectedScopes -FeatureNames $Feature -Access $Access
            $apis = Get-SelectedApis -FeatureNames $Feature
            $scopeCsv = $scopes -join ','

            Write-Host ''
            Write-Host " Signed in : $account"
            Write-Host " Grant type : $(if ($isServiceAccount) { 'Service Account' } else { 'Client Credentials' })"
            Write-Host " Organization : $(if ($OrganizationId) { $OrganizationId } else { '(none)' })"
            Write-Host " Project : $ProjectId"
            if ($isServiceAccount) {
                Write-Host " Service account : $saEmail"
                Write-Host " Impersonate user : $ImpersonateUser"
                Write-Host " Issue key : $RotateKey"
            }
            else {
                Write-Host " OAuth client ID : $ClientId"
                Write-Host " Redirect URI : $RedirectUri"
                Write-Host " Authorizing user : $ConsentUser"
                Write-Host " Refresh token : $(if ($RefreshToken) { 'supplied' } else { 'authorization-code flow' })"
            }
            Write-Host " Features : $(if ($Feature.Count) { $Feature -join ', ' } else { '(none)' })"
            if (@($Feature) -contains 'AgentDiscovery') {
                Write-Host " GCP regions : $(if ($GcpRegions -and $GcpRegions.Count) { $GcpRegions -join ', ' } else { '(not set)' })"
            }
            Write-Host " Aggregation only : $AggregationOnly"
            Write-Host ''
            if ($isServiceAccount) {
                Write-Info 'After this script, a Super Admin must add domain-wide delegation in Admin console (Security → API controls).'
            }
            else {
                Write-Info 'The authorizing user needs the Workspace admin roles for the operations the source performs.'
            }

            if (Enter-WizardPrompt) {
                if (-not (Read-YesNo -Prompt 'Proceed?' -Default $true)) {
                    Write-Host 'Cancelled.' -ForegroundColor Yellow
                    return
                }
                Complete-WizardPrompt
            }

            $wizardComplete = $true
        }
        catch {
            if (-not (Test-PromptBack $_)) { throw }
            if (-not (Move-WizardBack)) {
                Write-Host ''
                Write-Host 'Cancelled.' -ForegroundColor Yellow
                return
            }
        }
    }

    $target = if ($isServiceAccount) { $saEmail } else { $ProjectId }
    $action = if (-not $isServiceAccount) { "Configure Client Credentials for $ProjectId" }
        elseif ($isUpdate) { "Update service account $saEmail" }
        else { "Create service account $saEmail" }
    if (-not $PSCmdlet.ShouldProcess($target, $action)) { return }

    if (-not $projectExists -and $CreateProject) {
        Write-Step "Creating project $ProjectId"
        Invoke-GCloud -GcloudArgs @(
            'projects', 'create', $ProjectId,
            "--organization=$OrganizationId",
            "--name=$DisplayName",
            '--quiet'
        ) | Out-Null
        Invoke-GCloud -GcloudArgs @(
            'billing', 'projects', 'link', $ProjectId,
            "--billing-account=$BillingAccountId",
            '--quiet'
        ) | Out-Null
        Write-Ok "Created and billed $ProjectId"
        $projectExists = $true
    }

    Invoke-GCloud -GcloudArgs @('config', 'set', 'project', $ProjectId, '--quiet') | Out-Null

    Write-Step 'Enabling APIs'
    Invoke-GCloud -GcloudArgs (@('services', 'enable') + $apis + @("--project=$ProjectId", '--quiet')) | Out-Null
    Write-Ok ($apis -join ', ')

    $delegationClientId = $null
    if ($isServiceAccount) {
        Write-Step $(if ($isUpdate) { 'Updating service account' } else { 'Creating service account' })
        if (-not $isUpdate) {
            Invoke-GCloud -GcloudArgs @(
                'iam', 'service-accounts', 'create', $ServiceAccountId,
                "--display-name=$DisplayName",
                "--description=SailPoint ISC Google Workspace SaaS connector",
                "--project=$ProjectId",
                '--quiet'
            ) | Out-Null
            Set-GoogleCreatedServiceAccount -Email $saEmail
            Write-Ok "Created $saEmail"
        }
        else {
            Invoke-GCloud -GcloudArgs @(
                'iam', 'service-accounts', 'update', $saEmail,
                "--display-name=$DisplayName",
                "--project=$ProjectId",
                '--quiet'
            ) | Out-Null
            Write-Ok "Updated display name on $saEmail"
        }

        $sa = Get-ExistingServiceAccount -Email $saEmail -Project $ProjectId
        if (-not $sa) { throw "Service account $saEmail was not found after create/update." }
        $delegationClientId = [string]$sa.uniqueId
        Write-Ok "Client ID (domain-wide delegation): $delegationClientId"
    }

    $customRoleName = $null
    if ($needsGcp) {
        # Service Account signs the JWT as the service account but sets the impersonate user as
        # its subject, so Google evaluates GCP IAM against that user — the service account's own
        # bindings do not apply to delegated calls. Both get the roles: the connector's Workspace
        # calls carry the subject, other GCP calls may run as the service account itself.
        # Client Credentials calls Google as the consenting Workspace user.
        $members = if ($isServiceAccount) {
            @("serviceAccount:$saEmail", "user:$ImpersonateUser")
        }
        else {
            @("user:$ConsentUser")
        }

        Write-Step 'Organization custom IAM role'
        $permissions = Get-CustomRolePermissions -FeatureNames $Feature -SkipGcpWrite:$AggregationOnly
        $customRoleName = Set-OrganizationCustomRole -OrgId $OrganizationId -Permissions $permissions

        Write-Step 'Binding custom role at organization scope'
        foreach ($member in $members) {
            Add-IamPolicyBinding -ResourceKind organizations -ResourceId $OrganizationId -Member $member -Role $customRoleName
            Write-Ok "$member <- $customRoleName"
        }

        if (@($Feature) -contains 'NhiDiscovery') {
            Write-Step 'Binding NHI discovery built-in roles'
            foreach ($member in $members) {
                Write-Info $member
                foreach ($role in $catalog.NhiBuiltInRoles) {
                    try {
                        $scope = Add-ConnectorIamBinding -OrgId $OrganizationId -ProjectId $ProjectId -Member $member -Role $role.Id
                        if ($scope -eq 'project') {
                            Write-Ok "$($role.Label) (project; not valid at organization)"
                        }
                        else {
                            Write-Ok $role.Label
                        }
                    }
                    catch {
                        Write-Warning "Could not bind $($role.Id) ($($role.Label)) to ${member}: $($_.Exception.Message)"
                    }
                }
            }
        }
    }

    if (-not (Test-Path -LiteralPath $OutputDirectory)) {
        New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
    }

    $jsonPath = $null
    $pemPath = $null
    $pemText = $null
    if ($isServiceAccount -and $RotateKey) {
        Write-Step 'Creating service account key and RSA PEM'
        $jsonPath = Join-Path $OutputDirectory 'sailpoint-gws-key.json'
        $pemPath = Join-Path $OutputDirectory 'sailpoint-gws-rsa.pem'
        Invoke-GCloud -GcloudArgs @(
            'iam', 'service-accounts', 'keys', 'create', $jsonPath,
            "--iam-account=$saEmail",
            "--project=$ProjectId",
            '--quiet'
        ) | Out-Null
        Write-Ok "JSON key: $jsonPath"
        $keyMeta = ConvertTo-EncryptedRsaPem -JsonKeyPath $jsonPath -PemPath $pemPath -Passphrase $KeyPassword
        $keyClientId = Get-JsonProperty -InputObject $keyMeta -Name 'client_id'
        if ($keyClientId) { $delegationClientId = [string]$keyClientId }
        $pemText = [System.IO.File]::ReadAllText($pemPath).Trim()
        Write-Ok "RSA PEM: $pemPath"
    }

    if (-not $isServiceAccount -and -not $RefreshToken) {
        Write-Step 'Authorizing the OAuth client'
        $RefreshToken = Get-GoogleRefreshToken -OAuthClientId $ClientId -OAuthClientSecret $ClientSecret `
            -Redirect $RedirectUri -Scopes $scopes
        Write-Ok 'Refresh token issued'
    }

    $fields = [ordered]@{}
    if ($isServiceAccount) {
        $fields['Grant Type'] = 'Service Account'
        $fields['Service Account Email Address'] = $saEmail
        $fields['Email Address of User to Impersonate'] = $ImpersonateUser
        $fields['Scopes'] = $scopeCsv
        if ($pemText) {
            $fields['Private Key'] = $pemText
            $fields['Private Key Password'] = $KeyPassword
        }
    }
    else {
        $fields['Grant Type'] = 'Client Credentials'
        $fields['Client ID'] = $ClientId
        $fields['Client Secret'] = $ClientSecret
        $fields['Refresh Token'] = $RefreshToken
    }

    $settingsPath = Join-Path $OutputDirectory 'sailpoint-gws-connection-settings.txt'
    Write-ConnectionSettings -Fields $fields -Title 'ISC source Connection Settings' -Path $settingsPath

    $copyFields = [ordered]@{}
    foreach ($name in $fields.Keys) { $copyFields[$name] = $fields[$name] }

    $delegationPending = $false
    $rolesPending = $false
    if ($isServiceAccount) {
        $delegation = [ordered]@{
            'Domain-wide delegation Client ID' = $delegationClientId
            'Domain-wide delegation OAuth Scopes' = $scopeCsv
        }
        $delegationPath = Join-Path $OutputDirectory 'sailpoint-gws-domain-wide-delegation.txt'
        Write-ConnectionSettings -Fields $delegation -Title 'Google Admin console - domain-wide delegation' -Path $delegationPath
        foreach ($name in $delegation.Keys) { $copyFields[$name] = $delegation[$name] }

        $runWalkthrough = -not $SkipDomainWideDelegationWalkthrough
        if (-not $PSBoundParameters.ContainsKey('SkipDomainWideDelegationWalkthrough') -and -not $NonInteractive) {
            $runWalkthrough = Read-YesNo -Prompt 'Open Admin console and copy Client ID then scopes for domain-wide delegation?' -Default $true
        }
        if ($NonInteractive -and -not $PSBoundParameters.ContainsKey('SkipDomainWideDelegationWalkthrough')) {
            $runWalkthrough = $false
        }
        while ($true) {
            try {
                if ($runWalkthrough) {
                    Invoke-DomainWideDelegationWalkthrough -ClientIdValue $delegationClientId -ScopesValue $scopeCsv
                }
                else {
                    $delegationPending = $true
                }
                break
            }
            catch {
                if (-not (Test-PromptBack $_)) { throw }
                $runWalkthrough = Read-YesNo -Prompt 'Open Admin console and copy Client ID then scopes for domain-wide delegation?' -Default $true
            }
        }

        $wantRoles = $AssignWorkspaceRoles
        $rolesSkipped = $false
        if (-not $PSBoundParameters.ContainsKey('AssignWorkspaceRoles') -and -not $NonInteractive) {
            $wantRoles = Read-YesNo -Prompt "Assign Workspace admin roles to $ImpersonateUser now?" -Default $true
        }
        if ($wantRoles) {
            $roleClientId = $ClientId
            $roleSecret = $ClientSecret
            $roleRedirect = if ($RedirectUri) { $RedirectUri } else { 'http://localhost:8088' }
            $rolesSkipped = $true
            $rolesManualHint = "Assign User Management Admin and Groups Admin to $ImpersonateUser at https://admin.google.com/ac/roles."

            while ($true) {
                if (-not $roleClientId -or -not $roleSecret) {
                    if ($NonInteractive) {
                        Write-Warning "Skipping Workspace role assignment: -AssignWorkspaceRoles needs -ClientId and -ClientSecret. $rolesManualHint"
                        break
                    }
                    $oauthClient = $null
                    try {
                        $oauthClient = Invoke-OAuthClientWalkthrough -Project $ProjectId -Redirect $roleRedirect `
                            -ClientName 'SailPoint ISC Workspace role assignment' `
                            -SignInAccount $ImpersonateUser `
                            -Reason 'The Admin SDK only accepts a Super Admin user token — gcloud credentials are rejected — so this sign-in needs an OAuth client in your project.'
                    }
                    catch {
                        if (-not (Test-PromptBack $_)) { throw }
                    }
                    if (-not $oauthClient) {
                        Write-Warning "Skipping Workspace role assignment. $rolesManualHint"
                        break
                    }
                    $roleClientId = $oauthClient.ClientId
                    $roleSecret = $oauthClient.ClientSecret
                }

                try {
                    $roleToken = Get-GoogleOAuthToken -OAuthClientId $roleClientId -OAuthClientSecret $roleSecret `
                        -Redirect $roleRedirect `
                        -Scopes @(
                            'https://www.googleapis.com/auth/admin.directory.rolemanagement'
                            'https://www.googleapis.com/auth/admin.directory.user.readonly'
                        ) `
                        -SignInHint 'Sign in as a Super Admin and accept Directory role-management access.'
                    $access = [string](Get-JsonProperty -InputObject $roleToken -Name 'access_token')
                    $wanted = [System.Collections.Generic.List[string]]::new()
                    $wanted.Add('User Management Admin')
                    $wanted.Add('Groups Admin')
                    if (@($Feature) -contains 'DomainManagement') {
                        $addSuper = $true
                        if (-not $NonInteractive) {
                            $addSuper = Read-YesNo -Prompt 'Also assign Super Admin (required for domain-as-account and Workspace role operations)?' -Default $true
                        }
                        if ($addSuper) { $wanted.Add('Super Admin') }
                    }
                    Add-WorkspaceAdminRoles -AccessToken $access -UserEmail $ImpersonateUser `
                        -WantedDescriptions $wanted.ToArray() `
                        -RoleNameByDescription @{
                            'User Management Admin' = '_USER_MANAGEMENT_ADMIN_ROLE'
                            'Groups Admin'          = '_GROUPS_ADMIN_ROLE'
                            'Super Admin'           = '_SEED_ADMIN_ROLE'
                        }
                    $rolesSkipped = $false
                    break
                }
                catch {
                    if (Test-PromptBack $_) {
                        Write-Warning "Skipping Workspace role assignment. $rolesManualHint"
                        break
                    }
                    $failure = [string]$_.Exception.Message
                    Write-Warning "Workspace role assignment failed: $failure"
                    if ($failure -match 'redirect_uri_mismatch') {
                        Write-Info "Add $roleRedirect under Authorized redirect URIs on that client, or re-run with -RedirectUri set to one that is registered."
                    }
                    # A blocked consent screen is the client's audience, not the client itself, so
                    # that retry keeps the same credentials and only revisits the Audience page.
                    $audienceBlocked = $failure -match 'access_denied|verification process|No redirect arrived'
                    if ($audienceBlocked) {
                        Write-Info "Google blocks accounts the app's audience does not cover. External apps in Testing only admit accounts listed under Test users; Internal apps admit the whole Workspace organization."
                    }
                    # Another OAuth client cannot fix an address that does not exist.
                    if ($NonInteractive -or $failure -match 'does not exist') {
                        Write-Warning $rolesManualHint
                        break
                    }

                    try {
                        if ($audienceBlocked) {
                            if (-not (Read-YesNo -Prompt 'Fix the audience and retry with the same OAuth client?' -Default $true)) {
                                Write-Warning $rolesManualHint
                                break
                            }
                            Invoke-OAuthAudienceWalkthrough -Project $ProjectId -SignInAccount $ImpersonateUser
                        }
                        else {
                            if (-not (Read-YesNo -Prompt 'Try again with a different OAuth client?' -Default $true)) {
                                Write-Warning $rolesManualHint
                                break
                            }
                            $roleClientId = $null
                            $roleSecret = $null
                        }
                    }
                    catch {
                        if (-not (Test-PromptBack $_)) { throw }
                        Write-Warning $rolesManualHint
                        break
                    }
                }
            }
        }
        else {
            $rolesSkipped = $true
        }
        $rolesPending = $rolesSkipped
    }

    $gcpConsoleUrl = "https://console.cloud.google.com/iam-admin/serviceaccounts?project=$ProjectId"
    $dwdUrl = 'https://admin.google.com/ac/owl/domainwidedelegation'
    $adminRolesUrl = 'https://admin.google.com/ac/roles'

    $situation = [System.Collections.Generic.List[string]]::new()
    $situation.Add('Google Workspace GCP setup is complete. Paste Connection Settings into ISC.')
    if ($isServiceAccount) {
        if (-not $pemText) {
            $situation.Add('Pending: no Private Key was issued this run. Re-run with -RotateKey or paste the PEM and password you stored earlier.')
        }
        else {
            $situation.Add('Paste the Private Key exactly as stored (including BEGIN/END lines). Store the password — Google cannot recover it.')
        }
        if ($delegationPending) {
            $situation.Add('Pending: a Super Admin must authorize domain-wide delegation in the Admin console (Client ID and OAuth scopes).')
        }
        if ($rolesPending) {
            $situation.Add("Pending: assign User Management Admin and Groups Admin to $ImpersonateUser in the Admin console.")
        }
        $situation.Add('Do not commit JSON keys or PEM files. Keep them in a vault.')
    }
    else {
        $situation.Add("The refresh token belongs to $ConsentUser. CIEM and NHI Discovery require the Service Account grant type.")
    }
    $situation.Add('In ISC: Connections > Sources > [your source] > Connection Settings.')
    foreach ($checklistItem in @(Get-GoogleIscFeatureChecklist -FeatureNames $Feature `
            -OrganizationId $OrganizationId -GcpRegions $GcpRegions `
            -GrantType $(if ($isServiceAccount) { 'ServiceAccount' } else { 'ClientCredentials' }))) {
        $situation.Add("ISC: $checklistItem")
    }
    $ciemCtx = @{
        FeatureNames   = $Feature
        OrganizationId = $OrganizationId
    }
    Apply-EmbeddedCiemPrerequisites -Context $ciemCtx
    foreach ($entry in (Build-EmbeddedCiemConnectionSettings -Context $ciemCtx).GetEnumerator()) {
        $situation.Add("ISC: $($entry.Key) — $($entry.Value)")
    }

    $completionItems = [System.Collections.Generic.List[object]]::new()
    foreach ($name in $copyFields.Keys) {
        $completionItems.Add([PSCustomObject]@{
            Label = $name
            Value = [string]$copyFields[$name]
            Kind  = 'Copy'
            Mask  = (Test-ConnectionSettingIsSecret -Name $name)
        })
    }
    if (@($Feature) -contains 'AgentDiscovery' -and $OrganizationId) {
        $completionItems.Add([PSCustomObject]@{
            Label = 'Google Organization ID (Machine Identity Governance)'
            Value = $OrganizationId
            Kind  = 'Copy'
            Mask  = $false
        })
    }
    if (@($Feature) -contains 'AgentDiscovery' -and $GcpRegions -and $GcpRegions.Count -gt 0) {
        $completionItems.Add([PSCustomObject]@{
            Label = 'GCP Regions (Machine Identity Governance)'
            Value = ($GcpRegions -join ', ')
            Kind  = 'Copy'
            Mask  = $false
        })
    }
    if ($isServiceAccount) {
        $completionItems.Add([PSCustomObject]@{ Label = 'Domain-wide delegation (Admin console)'; Value = $dwdUrl; Kind = 'Open'; Mask = $false })
        if ($rolesPending) {
            $completionItems.Add([PSCustomObject]@{ Label = 'Workspace admin roles (Admin console)'; Value = $adminRolesUrl; Kind = 'Open'; Mask = $false })
        }
    }
    $completionItems.Add([PSCustomObject]@{ Label = 'GCP service accounts'; Value = $gcpConsoleUrl; Kind = 'Open'; Mask = $false })

    Invoke-CompletionActionMenu -Title 'Next: complete ISC Connection Settings' `
        -Situation $situation.ToArray() `
        -Items $completionItems.ToArray() `
        -AllowSaveToDisk `
        -SavePath $settingsPath
}
catch {
    if (Test-CancelledNavigation $_) {
        Write-Host ''
        Write-Host 'Cancelled.' -ForegroundColor Yellow
        return
    }
    Write-Host ''
    if ($null -ne (Get-GoogleCreatedServiceAccount)) {
        Write-Warning "Service account $(Get-GoogleCreatedServiceAccount) was created before this failure. Re-run with the same project and service account ID to finish configuring it."
    }
    Write-Error $_
    exit 1
}