modules/ISC.GoogleWorkspaceConnector.psm1
|
#Requires -Version 5.1 Set-StrictMode -Version Latest $script:OpenSslPath = $null function Initialize-GoogleWorkspaceConnectorData { param([string]$OpenSslPath) $script:OpenSslPath = $OpenSslPath $script:CreatedServiceAccount = $null # ----------------------------------------------------------------------------- # Scopes - SailPoint Google Workspace SaaS service-account table # https://documentation.sailpoint.com/connectors/saas/googleworkspace/help/saas_connectivity/google_workspace/prereqs_for_oauth_2_0.html # ----------------------------------------------------------------------------- $script:CoreScopes = @( @{ Value = 'https://www.googleapis.com/auth/admin.directory.group'; Purpose = 'Group aggregation and provisioning' } @{ Value = 'https://www.googleapis.com/auth/admin.directory.user'; Purpose = 'User aggregation and provisioning' } @{ Value = 'https://www.googleapis.com/auth/apps.groups.settings'; Purpose = 'Group Settings API' } @{ Value = 'https://www.googleapis.com/auth/admin.directory.rolemanagement'; Purpose = 'Role create / assign' } @{ Value = 'https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly'; Purpose = 'Role aggregation' } ) $script:FeaturePacks = [ordered]@{ GmailDelegates = @{ Label = 'Gmail delegates' Apis = @('gmail.googleapis.com') Scopes = @( 'https://www.googleapis.com/auth/gmail.settings.sharing' 'https://www.googleapis.com/auth/gmail.settings.basic' 'https://mail.google.com/' 'https://www.googleapis.com/auth/gmail.modify' 'https://www.googleapis.com/auth/gmail.readonly' ) } DeltaAggregation = @{ Label = 'Delta aggregation (Reports audit)' Apis = @() Scopes = @('https://www.googleapis.com/auth/admin.reports.audit.readonly') } DomainManagement = @{ Label = 'Manage domain as a GCP account type' Apis = @() Scopes = @('https://www.googleapis.com/auth/admin.directory.domain') } ActivityInsights = @{ Label = 'Activity Insights (audit and usage reports)' Apis = @() Scopes = @( 'https://www.googleapis.com/auth/admin.reports.audit.readonly' 'https://www.googleapis.com/auth/admin.reports.usage.readonly' ) } } $script:CoreApis = @( 'admin.googleapis.com' 'groupssettings.googleapis.com' ) } function Invoke-GCloud { param( [Parameter(Mandatory)][string[]]$GcloudArgs, [switch]$ExpectJson ) $gcloud = Ensure-GCloudCommand $errorFile = [System.IO.Path]::GetTempFileName() $previousPrompt = $env:CLOUDSDK_CORE_DISABLE_PROMPTS $env:CLOUDSDK_CORE_DISABLE_PROMPTS = '1' try { $stdout = & $gcloud @GcloudArgs 2>$errorFile $code = $LASTEXITCODE $stderr = '' if (Test-Path $errorFile) { $stderr = [System.IO.File]::ReadAllText($errorFile) } if ($code -ne 0) { $detail = (@($stderr, $stdout) | Where-Object { $_ } ) -join ' ' throw ("gcloud {0} failed: {1}" -f ($GcloudArgs -join ' '), $detail.Trim()) } if (-not $ExpectJson) { if ($null -eq $stdout) { return '' } return ($stdout | Out-String).Trim() } $text = if ($stdout -is [array]) { $stdout -join "`n" } else { [string]$stdout } if ([string]::IsNullOrWhiteSpace($text)) { return $null } return $text | ConvertFrom-Json } finally { if ($null -eq $previousPrompt) { Remove-Item Env:CLOUDSDK_CORE_DISABLE_PROMPTS -ErrorAction SilentlyContinue } else { $env:CLOUDSDK_CORE_DISABLE_PROMPTS = $previousPrompt } Remove-Item -LiteralPath $errorFile -Force -ErrorAction SilentlyContinue } } function Test-GCloudAuth { $account = Invoke-GCloud -GcloudArgs @('config', 'get-value', 'account', '--quiet') if ([string]::IsNullOrWhiteSpace($account) -or $account -eq '(unset)') { return $null } return $account } function Connect-GoogleCloud { Write-Step 'Google Cloud SDK' $null = Ensure-GCloudCommand Write-Ok 'gcloud is on PATH' $account = Test-GCloudAuth if ($account) { $reuse = Read-YesNo -Prompt "Already signed in as $account. Reuse this session?" -Default $true if ($reuse) { Write-Ok "Using $account" return $account } } Write-Info 'Opening the Google sign-in flow...' Invoke-GCloud -GcloudArgs @('auth', 'login', '--brief', '--quiet') | Out-Null $account = Test-GCloudAuth if (-not $account) { throw 'gcloud auth login did not produce an active account.' } Write-Ok "Signed in as $account" return $account } # Set-StrictMode turns a missing property into a terminating error, so optional JSON fields are # read through the property bag instead. function Get-JsonProperty { param( [Parameter(Mandatory)][AllowNull()]$InputObject, [Parameter(Mandatory)][string]$Name ) if ($null -eq $InputObject) { return $null } $property = $InputObject.PSObject.Properties[$Name] if (-not $property) { return $null } return $property.Value } function Test-OpenSslTraditionalFlag { param([Parameter(Mandatory)][string]$OpenSsl) # LibreSSL (macOS /usr/bin/openssl) treats unknown flags as cipher names: # "Invalid cipher 'traditional'". OpenSSL 3 documents -traditional as a real flag. $help = & $OpenSsl rsa -help 2>&1 | Out-String return [bool]($help -match '(?m)^\s*-traditional\b') } function ConvertTo-EncryptedRsaPem { param( [Parameter(Mandatory)][string]$JsonKeyPath, [Parameter(Mandatory)][string]$PemPath, [Parameter(Mandatory)][string]$Passphrase ) $openssl = Ensure-OpenSslCommand -OpenSslPath $script:OpenSslPath $json = Get-Content -LiteralPath $JsonKeyPath -Raw | ConvertFrom-Json $privateKey = Get-JsonProperty -InputObject $json -Name 'private_key' if (-not $privateKey) { throw "JSON key $JsonKeyPath does not contain private_key." } $pkcs8Path = [System.IO.Path]::ChangeExtension($PemPath, '.pkcs8.pem') $pkcs8 = $privateKey -replace '\\n', "`n" [System.IO.File]::WriteAllText($pkcs8Path, $pkcs8.Trim() + "`n") $previous = $env:SP_GWS_PEM_PASS $env:SP_GWS_PEM_PASS = $Passphrase try { $rsaArgs = @( 'rsa' '-aes-256-cbc' '-in', $pkcs8Path '-out', $PemPath '-passout', 'env:SP_GWS_PEM_PASS' ) if (Test-OpenSslTraditionalFlag -OpenSsl $openssl) { $rsaArgs += '-traditional' } $errorFile = [System.IO.Path]::GetTempFileName() $null = & $openssl @rsaArgs 2>$errorFile $code = $LASTEXITCODE $stderr = if (Test-Path $errorFile) { [System.IO.File]::ReadAllText($errorFile) } else { '' } Remove-Item -LiteralPath $errorFile -Force -ErrorAction SilentlyContinue if ($code -ne 0 -or -not (Test-Path -LiteralPath $PemPath)) { throw "openssl failed to convert the key to traditional RSA PEM: $stderr" } $pemText = [System.IO.File]::ReadAllText($PemPath) if ($pemText -notmatch 'BEGIN RSA PRIVATE KEY') { throw "openssl wrote a key that is not traditional RSA PEM (expected BEGIN RSA PRIVATE KEY). Install OpenSSL 3+ or pass -OpenSslPath. Output started with: $($pemText.Substring(0, [Math]::Min(40, $pemText.Length)))" } } finally { if ($null -eq $previous) { Remove-Item Env:SP_GWS_PEM_PASS -ErrorAction SilentlyContinue } else { $env:SP_GWS_PEM_PASS = $previous } Remove-Item -LiteralPath $pkcs8Path -Force -ErrorAction SilentlyContinue } return $json } function New-KeyPassword { $bytes = New-Object byte[] 24 $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() $rng.GetBytes($bytes) $rng.Dispose() return [Convert]::ToBase64String($bytes) } function Get-SelectedScopes { param( [string[]]$FeatureNames, [string]$Access = '' ) if ($Access -eq 'ReadOnly' -and -not (Get-Module -Name 'IW.Access')) { Import-Module (Join-Path $PSScriptRoot 'IW.Access.psm1') -Force -ErrorAction Stop } $values = [System.Collections.Generic.List[string]]::new() foreach ($item in $script:CoreScopes) { $scope = $item.Value if ($Access -eq 'ReadOnly') { $scope = ConvertTo-ReadOnlyGoogleOAuthScope -Scope $scope if (-not $scope) { continue } if (-not (Test-RequirementIncludedForAccess -Kind 'GoogleOAuthScope' -Name $scope -Access 'ReadOnly')) { continue } } if (-not $values.Contains($scope)) { $values.Add($scope) } } foreach ($scope in @(Get-GoogleCiemScopes -FeatureNames $FeatureNames)) { if (-not $values.Contains($scope)) { $values.Add($scope) } } foreach ($name in @($FeatureNames)) { if (-not $script:FeaturePacks.Contains($name)) { continue } $pack = $script:FeaturePacks[$name] if ($pack.ContainsKey('Scopes')) { foreach ($scope in @($pack.Scopes)) { if ($Access -eq 'ReadOnly') { if (-not (Test-RequirementIncludedForAccess -Kind 'GoogleOAuthScope' -Name $scope -Access 'ReadOnly')) { continue } } if (-not $values.Contains($scope)) { $values.Add($scope) } } } } return $values.ToArray() } function Get-SelectedApis { param([string[]]$FeatureNames) $values = [System.Collections.Generic.List[string]]::new() foreach ($api in $script:CoreApis) { $values.Add($api) } foreach ($api in @(Get-GoogleCiemApis -FeatureNames $FeatureNames)) { if ($api -and -not $values.Contains($api)) { $values.Add($api) } } foreach ($name in @($FeatureNames)) { if (-not $script:FeaturePacks.Contains($name)) { continue } foreach ($api in @($script:FeaturePacks[$name].Apis)) { if ($api -and -not $values.Contains($api)) { $values.Add($api) } } } return $values.ToArray() } function Get-CustomRolePermissions { param( [string[]]$FeatureNames, [switch]$SkipGcpWrite ) return @(Get-GoogleCustomRolePermissions -FeatureNames $FeatureNames -SkipGcpWrite:$SkipGcpWrite) } function ConvertTo-RoleYaml { param( [Parameter(Mandatory)][string[]]$Permissions, [Parameter(Mandatory)][string]$Title ) $lines = [System.Collections.Generic.List[string]]::new() $lines.Add("title: $Title") $lines.Add('description: SailPoint Identity Security Cloud Google Workspace SaaS connector') $lines.Add('stage: GA') $lines.Add('includedPermissions:') foreach ($p in $Permissions) { $lines.Add("- $p") } return ($lines -join "`n") + "`n" } function Get-ServiceAccountEmail { param( [Parameter(Mandatory)][string]$AccountId, [Parameter(Mandatory)][string]$Project ) return "$AccountId@$Project.iam.gserviceaccount.com" } function Get-ExistingServiceAccount { param( [Parameter(Mandatory)][string]$Email, [Parameter(Mandatory)][string]$Project ) try { return Invoke-GCloud -ExpectJson -GcloudArgs @( 'iam', 'service-accounts', 'describe', $Email, "--project=$Project", '--format=json', '--quiet' ) } catch { return $null } } function Set-OrganizationCustomRole { param( [Parameter(Mandatory)][string]$OrgId, [Parameter(Mandatory)][string[]]$Permissions ) $yamlPath = Join-Path ([System.IO.Path]::GetTempPath()) "sailpoint-gws-role-$OrgId.yaml" [System.IO.File]::WriteAllText($yamlPath, (ConvertTo-RoleYaml -Permissions $Permissions -Title 'SailPoint ISC Google Workspace')) $customRoleId = (Get-GoogleCiemCatalog).CustomRoleId $roleName = "organizations/$OrgId/roles/$customRoleId" try { $exists = $false try { Invoke-GCloud -GcloudArgs @('iam', 'roles', 'describe', $customRoleId, "--organization=$OrgId", '--quiet') | Out-Null $exists = $true } catch { } if ($exists) { Invoke-GCloud -GcloudArgs @( 'iam', 'roles', 'update', $customRoleId, "--organization=$OrgId", "--file=$yamlPath", '--quiet' ) | Out-Null Write-Ok "Updated organization role $roleName" } else { Invoke-GCloud -GcloudArgs @( 'iam', 'roles', 'create', $customRoleId, "--organization=$OrgId", "--file=$yamlPath", '--quiet' ) | Out-Null Write-Ok "Created organization role $roleName" } return $roleName } finally { Remove-Item -LiteralPath $yamlPath -Force -ErrorAction SilentlyContinue } } function Add-IamPolicyBinding { param( [Parameter(Mandatory)][ValidateSet('organizations', 'projects')][string]$ResourceKind, [Parameter(Mandatory)][string]$ResourceId, [Parameter(Mandatory)][string]$Member, [Parameter(Mandatory)][string]$Role ) $bindArgs = @( $ResourceKind, 'add-iam-policy-binding', $ResourceId, "--member=$Member", "--role=$Role", '--condition=None', '--quiet' ) try { Invoke-GCloud -GcloudArgs $bindArgs | Out-Null } catch { Invoke-GCloud -GcloudArgs @( $ResourceKind, 'add-iam-policy-binding', $ResourceId, "--member=$Member", "--role=$Role", '--quiet' ) | Out-Null } } # ----------------------------------------------------------------------------- # OAuth 2.0 authorization-code flow (Client Credentials grant type) # ----------------------------------------------------------------------------- $script:GoogleAuthUri = 'https://accounts.google.com/o/oauth2/v2/auth' $script:GoogleTokenUri = 'https://oauth2.googleapis.com/token' $script:PlaygroundRedirect = 'https://developers.google.com/oauthplayground' $script:AuthCodeTimeoutSeconds = 300 function Get-GoogleAuthorizationUrl { param( [Parameter(Mandatory)][string]$OAuthClientId, [Parameter(Mandatory)][string]$Redirect, [Parameter(Mandatory)][string[]]$Scopes ) $query = @( "client_id=$([uri]::EscapeDataString($OAuthClientId))" "redirect_uri=$([uri]::EscapeDataString($Redirect))" 'response_type=code' 'access_type=offline' 'prompt=consent' 'include_granted_scopes=true' "scope=$([uri]::EscapeDataString(($Scopes -join ' ')))" ) -join '&' return "$script:GoogleAuthUri`?$query" } # Blocks on the loopback redirect until Google sends the code back, so the operator never has to # copy anything out of the address bar. function Wait-GoogleAuthorizationCode { param([Parameter(Mandatory)][string]$Redirect) $prefix = $Redirect.TrimEnd('/') + '/' $listener = [System.Net.HttpListener]::new() $listener.Prefixes.Add($prefix) try { $listener.Start() } catch { throw "Could not listen on $prefix. Choose a free port with -RedirectUri, or use the OAuth Playground redirect. $($_.Exception.Message)" } try { Write-Info "Waiting for the Google redirect on $prefix (Ctrl+C to cancel) ..." # A consent screen that blocks the sign-in never redirects here, so the wait is bounded # rather than hanging until Ctrl+C. Polling also keeps Ctrl+C responsive. $pending = $listener.GetContextAsync() $deadline = (Get-Date).AddSeconds($script:AuthCodeTimeoutSeconds) while (-not $pending.Wait(500)) { if ((Get-Date) -gt $deadline) { $waited = if ($script:AuthCodeTimeoutSeconds -ge 120) { "$([int]($script:AuthCodeTimeoutSeconds / 60)) minutes" } else { "$script:AuthCodeTimeoutSeconds seconds" } throw ("No redirect arrived on $prefix within $waited. " + "If the browser showed 'Access blocked - has not completed the Google verification process', the OAuth client's audience does not allow that account: " + 'add it under Audience > Test users, or set User type to Internal, then retry.') } } $context = $pending.Result $code = $context.Request.QueryString['code'] $failure = $context.Request.QueryString['error'] $body = if ($code) { '<html><body style="font-family:sans-serif"><h3>Authorization complete</h3><p>Return to the PowerShell window.</p></body></html>' } else { "<html><body style='font-family:sans-serif'><h3>Authorization failed</h3><p>$failure</p></body></html>" } $bytes = [System.Text.Encoding]::UTF8.GetBytes($body) $context.Response.ContentType = 'text/html' $context.Response.ContentLength64 = $bytes.Length $context.Response.OutputStream.Write($bytes, 0, $bytes.Length) $context.Response.OutputStream.Close() if (-not $code) { throw "Google returned an error instead of an authorization code: $failure" } return $code } finally { $listener.Stop() $listener.Close() } } function Invoke-GoogleTokenExchange { param( [Parameter(Mandatory)][string]$OAuthClientId, [Parameter(Mandatory)][string]$OAuthClientSecret, [Parameter(Mandatory)][string]$Redirect, [Parameter(Mandatory)][string]$Code ) try { return Invoke-RestMethod -Method Post -Uri $script:GoogleTokenUri -ErrorAction Stop -Body @{ code = $Code client_id = $OAuthClientId client_secret = $OAuthClientSecret redirect_uri = $Redirect grant_type = 'authorization_code' } } catch { $detail = $_.ErrorDetails $body = if ($detail) { $detail.Message } else { $_.Exception.Message } throw "Google rejected the token exchange: $body" } } function Get-GoogleOAuthToken { param( [Parameter(Mandatory)][string]$OAuthClientId, [Parameter(Mandatory)][string]$OAuthClientSecret, [Parameter(Mandatory)][string]$Redirect, [Parameter(Mandatory)][string[]]$Scopes, [Parameter(Mandatory)][string]$SignInHint ) $authUrl = Get-GoogleAuthorizationUrl -OAuthClientId $OAuthClientId -Redirect $Redirect -Scopes $Scopes $isLoopback = $Redirect -match '^http://(localhost|127\.0\.0\.1)' Write-Host '' Write-Host 'Authorize the OAuth client at:' -ForegroundColor White Write-Host $authUrl -ForegroundColor Yellow Write-Host '' if (-not (Open-Url -Url $authUrl)) { Write-Info 'Could not open a browser automatically; copy the URL above.' } Write-Info $SignInHint $code = if ($isLoopback) { Wait-GoogleAuthorizationCode -Redirect $Redirect } else { Write-Info 'The OAuth Playground shows the authorization code under "Step 2 - Exchange authorization code for tokens".' Read-InputString -Prompt 'Authorization code' -Required } return Invoke-GoogleTokenExchange -OAuthClientId $OAuthClientId -OAuthClientSecret $OAuthClientSecret ` -Redirect $Redirect -Code $code } function Get-GoogleRefreshToken { param( [Parameter(Mandatory)][string]$OAuthClientId, [Parameter(Mandatory)][string]$OAuthClientSecret, [Parameter(Mandatory)][string]$Redirect, [Parameter(Mandatory)][string[]]$Scopes ) $response = Get-GoogleOAuthToken -OAuthClientId $OAuthClientId -OAuthClientSecret $OAuthClientSecret ` -Redirect $Redirect -Scopes $Scopes ` -SignInHint 'Sign in as the Workspace user whose roles the connector should use, and accept every scope.' $refresh = Get-JsonProperty -InputObject $response -Name 'refresh_token' if (-not $refresh) { throw 'Google returned an access token without a refresh token. Remove the app under https://myaccount.google.com/permissions and authorize again so consent is re-prompted.' } return [string]$refresh } function Invoke-AdminSdk { param( [Parameter(Mandatory)][string]$AccessToken, [Parameter(Mandatory)][string]$Method, [Parameter(Mandatory)][string]$Uri, $Body ) $headers = @{ Authorization = "Bearer $AccessToken" } $params = @{ Method = $Method Uri = $Uri Headers = $headers ErrorAction = 'Stop' } if ($null -ne $Body) { $params.ContentType = 'application/json' $params.Body = ($Body | ConvertTo-Json -Compress -Depth 6) } try { return Invoke-RestMethod @params } catch { $detail = $_.ErrorDetails $message = if ($detail) { $detail.Message } else { $_.Exception.Message } throw "Admin SDK $Method $Uri failed: $message" } } function Get-AdminSdkRoles { param([Parameter(Mandatory)][string]$AccessToken) $roles = [System.Collections.Generic.List[object]]::new() $uri = 'https://admin.googleapis.com/admin/directory/v1/customer/my_customer/roles?maxResults=100' while ($uri) { $page = Invoke-AdminSdk -AccessToken $AccessToken -Method Get -Uri $uri foreach ($role in @((Get-JsonProperty -InputObject $page -Name 'items'))) { if ($role) { $roles.Add($role) } } $token = Get-JsonProperty -InputObject $page -Name 'nextPageToken' if ($token) { $uri = "https://admin.googleapis.com/admin/directory/v1/customer/my_customer/roles?maxResults=100&pageToken=$([uri]::EscapeDataString([string]$token))" } else { $uri = $null } } return $roles.ToArray() } function Resolve-WorkspaceAdminRole { param( [Parameter(Mandatory)]$Roles, [Parameter(Mandatory)][string]$RoleName, [Parameter(Mandatory)][string]$Description ) foreach ($role in @($Roles)) { $name = [string](Get-JsonProperty -InputObject $role -Name 'roleName') $desc = [string](Get-JsonProperty -InputObject $role -Name 'roleDescription') if ($name -eq $RoleName -or $desc -eq $Description) { return $role } } return $null } function Test-WorkspaceRoleAssigned { param( [Parameter(Mandatory)][string]$AccessToken, [Parameter(Mandatory)][string]$UserId, [Parameter(Mandatory)][string]$RoleId ) $uri = "https://admin.googleapis.com/admin/directory/v1/customer/my_customer/roleassignments?userKey=$([uri]::EscapeDataString($UserId))&maxResults=100" while ($uri) { $page = Invoke-AdminSdk -AccessToken $AccessToken -Method Get -Uri $uri foreach ($assignment in @((Get-JsonProperty -InputObject $page -Name 'items'))) { if (-not $assignment) { continue } if ([string](Get-JsonProperty -InputObject $assignment -Name 'roleId') -eq $RoleId) { return $true } } $token = Get-JsonProperty -InputObject $page -Name 'nextPageToken' if ($token) { $uri = "https://admin.googleapis.com/admin/directory/v1/customer/my_customer/roleassignments?userKey=$([uri]::EscapeDataString($UserId))&maxResults=100&pageToken=$([uri]::EscapeDataString([string]$token))" } else { $uri = $null } } return $false } function Get-WorkspaceAdminAccounts { param([Parameter(Mandatory)][string]$AccessToken) try { $response = Invoke-AdminSdk -AccessToken $AccessToken -Method Get ` -Uri 'https://admin.googleapis.com/admin/directory/v1/users?customer=my_customer&query=isAdmin%3Dtrue&maxResults=25&projection=basic' $users = Get-JsonProperty -InputObject $response -Name 'users' return @($users | ForEach-Object { [string](Get-JsonProperty -InputObject $_ -Name 'primaryEmail') } | Where-Object { $_ }) } catch { return @() } } function Add-WorkspaceAdminRoles { param( [Parameter(Mandatory)][string]$AccessToken, [Parameter(Mandatory)][string]$UserEmail, [Parameter(Mandatory)][string[]]$WantedDescriptions, [hashtable]$RoleNameByDescription ) Write-Step "Assigning Workspace admin roles to $UserEmail" # A mistyped address reaches this point looking like any other failure, so name the cause and # show the admins that do exist rather than returning a raw 404. try { $user = Invoke-AdminSdk -AccessToken $AccessToken -Method Get ` -Uri "https://admin.googleapis.com/admin/directory/v1/users/$([uri]::EscapeDataString($UserEmail))" } catch { if ([string]$_.Exception.Message -notmatch '404|notFound|Resource Not Found') { throw } Write-Warning "This Workspace has no user $UserEmail. A typo in the address or the domain is the usual cause." $admins = @(Get-WorkspaceAdminAccounts -AccessToken $AccessToken) if ($admins.Count) { Write-Info 'Admin accounts that do exist here:' foreach ($admin in $admins) { Write-Host " $admin" -ForegroundColor White } } throw "The impersonate user $UserEmail does not exist, so no roles can be assigned. Re-run with -ImpersonateUser set to the correct address; Connection Settings carry the same address and need it too." } $userId = [string](Get-JsonProperty -InputObject $user -Name 'id') if (-not $userId) { throw "Admin SDK did not return an id for $UserEmail." } $roles = @(Get-AdminSdkRoles -AccessToken $AccessToken) foreach ($description in $WantedDescriptions) { $roleName = $RoleNameByDescription[$description] $role = Resolve-WorkspaceAdminRole -Roles $roles -RoleName $roleName -Description $description if (-not $role) { Write-Warning "Workspace role '$description' ($roleName) was not found in this customer." continue } $roleId = [string](Get-JsonProperty -InputObject $role -Name 'roleId') if (Test-WorkspaceRoleAssigned -AccessToken $AccessToken -UserId $userId -RoleId $roleId) { Write-Ok "$description is already assigned" continue } Invoke-AdminSdk -AccessToken $AccessToken -Method Post ` -Uri 'https://admin.googleapis.com/admin/directory/v1/customer/my_customer/roleassignments' ` -Body @{ roleId = $roleId assignedTo = $userId scopeType = 'CUSTOMER' } | Out-Null Write-Ok "Assigned $description" } } function Show-CopyPasteValue { param( [Parameter(Mandatory)][string]$Label, [Parameter(Mandatory)][string]$Value, [Parameter(Mandatory)][string]$Instruction, [switch]$HideValue ) if (-not $HideValue) { Write-Info "${Label}:" Write-Host " $Value" -ForegroundColor White } if (Copy-ToClipboard -Text $Value) { Write-Ok "$Instruction Copied to the clipboard as well." } else { Write-Warning 'No clipboard tool is available. Copy from the value above.' Write-Ok $Instruction } } function Write-DomainWideDelegationValues { param( [Parameter(Mandatory)][string]$ClientIdValue, [Parameter(Mandatory)][string]$ScopesValue ) Write-Info 'Client ID:' Write-Host " $ClientIdValue" -ForegroundColor White Write-Info 'OAuth scopes to add (comma-delimited):' Write-Host " $ScopesValue" -ForegroundColor White foreach ($scope in ($ScopesValue -split ',')) { if (-not [string]::IsNullOrWhiteSpace($scope)) { Write-Host " $scope" -ForegroundColor DarkGray } } } function Wait-Continue { param([Parameter(Mandatory)][string]$Prompt) if (Test-OperatorNonInteractive) { return } $null = Read-TypedLine $Prompt } function Get-OAuthClientCreateUrl { param([Parameter(Mandatory)][string]$Project) return "https://console.cloud.google.com/auth/clients/create?project=$Project" } function Get-OAuthAudienceUrl { param([Parameter(Mandatory)][string]$Project) return "https://console.cloud.google.com/auth/audience?project=$Project" } function Write-OAuthClientSteps { param( [Parameter(Mandatory)][string]$Redirect, [Parameter(Mandatory)][string]$ClientName ) Write-Host '' Write-Host ' 1. Application type: Web application' -ForegroundColor White Write-Host " 2. Name: $ClientName" -ForegroundColor White Write-Host " 3. Authorized redirect URIs → Add URI → $Redirect" -ForegroundColor White Write-Host ' 4. Create, then copy the Client ID and Client secret from the dialog' -ForegroundColor White Write-Host '' Write-Info 'Google shows the client secret only once, in that dialog. Copy it before closing.' Write-Info 'If the console asks you to configure Google Auth Platform first, do that, then come back to Clients.' Write-Info 'User type Internal is only offered when the project belongs to a Workspace organization. Otherwise choose External.' } # An External app in Testing rejects every account that is not a test user, with # "Access blocked - has not completed the Google verification process". The account has to be # listed before the sign-in, so this runs while the browser is still on the console. function Invoke-OAuthAudienceWalkthrough { param( [Parameter(Mandatory)][string]$Project, [string]$SignInAccount ) if (Test-OperatorNonInteractive) { return } Write-Info 'Audience: an External app in Testing only lets accounts listed as test users sign in.' Write-Info 'Internal apps (Workspace organization projects) allow everyone in the organization, so they need nothing here.' if (-not (Read-YesNo -Prompt 'Open the Audience page to add the signing-in account as a test user?' -Default $true)) { return } $audienceUrl = Get-OAuthAudienceUrl -Project $Project if (Open-Url -Url $audienceUrl) { Write-Ok "Opened $audienceUrl" } else { Write-Info "Open $audienceUrl" } Write-Info 'Under Test users: Add users → the Super Admin account you will sign in with → Save.' if ($SignInAccount) { Show-CopyPasteValue -Label 'Likely test user' -Value $SignInAccount ` -Instruction 'Add this account if it is the one that will sign in. Otherwise add the Super Admin you use.' } Wait-Continue 'Press Enter once the account is saved as a test user' } # Google has no API for creating a consent-screen OAuth client: gcloud iam oauth-clients manages # Workforce Identity Federation clients, and IAP clients are locked to IAP redirect URIs. The # Admin SDK only accepts a user token, so the console is the only way to get one. This walkthrough # opens the create page, copies the redirect URI, and collects the pair. Returns $null when the # operator opts out; Esc on the client ID rethrows so the caller can re-offer. function Invoke-OAuthClientWalkthrough { param( [Parameter(Mandatory)][string]$Project, [Parameter(Mandatory)][string]$Redirect, [Parameter(Mandatory)][string]$Reason, [string]$ClientName = 'SailPoint ISC setup script', [string]$SignInAccount ) if (Test-OperatorNonInteractive) { return $null } Write-Step 'OAuth client (Cloud Console — Google has no API for this)' Write-Info $Reason Write-Info 'The client is only used for this sign-in. Delete it afterwards if you prefer.' if (-not (Read-YesNo -Prompt 'Create the OAuth client now (No assigns the roles in the Admin console instead)?' -Default $true)) { return $null } $createUrl = Get-OAuthClientCreateUrl -Project $Project if (Open-Url -Url $createUrl) { Write-Ok "Opened $createUrl" } else { Write-Info "Open $createUrl" } Write-OAuthClientSteps -Redirect $Redirect -ClientName $ClientName Show-CopyPasteValue -Label 'Authorized redirect URI' -Value $Redirect ` -Instruction 'Paste under Authorized redirect URIs. It must match exactly.' $clientIdValue = $null $clientSecretValue = $null $phase = 0 while ($phase -lt 2) { try { if ($phase -eq 0) { $clientIdValue = Read-InputString -Prompt 'Client ID from the dialog' -Required $phase = 1 } else { $clientSecretValue = Read-InputString -Prompt 'Client secret from the dialog' -Required -Mask $phase = 2 } } catch { if (-not (Test-PromptBack $_)) { throw } if ($phase -eq 0) { throw } $phase = 0 } } Invoke-OAuthAudienceWalkthrough -Project $Project -SignInAccount $SignInAccount return [PSCustomObject]@{ ClientId = $clientIdValue; ClientSecret = $clientSecretValue } } # Google does not publish an API for domain-wide delegation. This walkthrough opens the Admin # console page, prints Client ID and OAuth scopes first, then copies each field so the Super # Admin only has to click Add new. Values are not secrets; they stay on screen if the clipboard # is overwritten. Esc on a wait returns to the previous field; Esc on Client ID returns to Yes/No. function Invoke-DomainWideDelegationWalkthrough { param( [Parameter(Mandatory)][string]$ClientIdValue, [Parameter(Mandatory)][string]$ScopesValue ) Write-Step 'Domain-wide delegation (Admin console — Google has no API for this)' Write-Info 'Only a Super Admin can authorize the service account. The console page will open; this script copies each field in order.' Write-Info 'Esc goes back to the previous field. Client ID and scopes stay on screen if the clipboard is overwritten.' $dwdUrl = 'https://admin.google.com/ac/owl/domainwidedelegation' if (Open-Url -Url $dwdUrl) { Write-Ok "Opened $dwdUrl" } else { Write-Info "Open $dwdUrl" } Write-DomainWideDelegationValues -ClientIdValue $ClientIdValue -ScopesValue $ScopesValue $phase = 0 while ($phase -lt 2) { try { if ($phase -eq 0) { Show-CopyPasteValue -Label 'Client ID' -Value $ClientIdValue -HideValue ` -Instruction 'In the Admin console: Add new → paste into Client ID.' Wait-Continue 'Press Enter after the Client ID is pasted (Esc goes back)' $phase = 1 } else { Show-CopyPasteValue -Label 'OAuth scopes' -Value $ScopesValue -HideValue ` -Instruction 'Paste into OAuth scopes (comma-delimited), then Authorize.' Wait-Continue 'Press Enter after Authorize succeeds (Esc goes back to Client ID)' $phase = 2 } } catch { if (-not (Test-PromptBack $_)) { throw } if ($phase -eq 0) { throw } $phase = 0 } } } function Test-ConnectionSettingIsSecret { param([Parameter(Mandatory)][string]$Name) return @( 'Private Key' 'Private Key Password' 'Client Secret' 'Refresh Token' ) -contains $Name } function Add-ConnectorIamBinding { param( [Parameter(Mandatory)][string]$OrgId, [Parameter(Mandatory)][string]$ProjectId, [Parameter(Mandatory)][string]$Member, [Parameter(Mandatory)][string]$Role ) try { Add-IamPolicyBinding -ResourceKind organizations -ResourceId $OrgId -Member $Member -Role $Role return 'organization' } catch { $message = $_.Exception.Message if ($message -notmatch 'not supported for this resource') { throw } Add-IamPolicyBinding -ResourceKind projects -ResourceId $ProjectId -Member $Member -Role $Role return 'project' } } # The connector only queries the regions configured on the source, so a region list that misses # where the agents actually run aggregates zero agents without reporting an error. Cloud Asset # answers this org-wide in one call; it needs cloudasset.googleapis.com enabled on the operator's # current project, which is not guaranteed this early, so callers fall back on an empty result. function Get-GoogleVertexAgentRegions { param([Parameter(Mandatory)][string]$OrgId) $names = @() try { $names = @(Invoke-GCloud -GcloudArgs @( 'asset', 'search-all-resources', "--scope=organizations/$OrgId", '--asset-types=aiplatform.googleapis.com/ReasoningEngine', '--format=value(name)', '--quiet' )) } catch { Write-Verbose "Could not search for Vertex AI agents: $($_.Exception.Message)" return @() } $regions = [System.Collections.Generic.List[string]]::new() foreach ($name in $names) { if ([string]$name -match '/locations/([^/]+)/') { $region = $Matches[1] if (-not $regions.Contains($region)) { $regions.Add($region) } } } return @($regions | Sort-Object) } function Get-GoogleWorkspaceCatalog { $packs = [ordered]@{} foreach ($key in $script:FeaturePacks.Keys) { $packs[$key] = $script:FeaturePacks[$key] } $gcpScopes = @() $nhiRoles = @() $customRoleId = 'sailpointGoogleWorkspace' if (Get-Command Get-GoogleCiemFeaturePacks -ErrorAction SilentlyContinue) { foreach ($key in (Get-GoogleCiemFeaturePacks).Keys) { $packs[$key] = (Get-GoogleCiemFeaturePacks)[$key] } if (Get-Command Get-GoogleCiemCatalog -ErrorAction SilentlyContinue) { $ciemCatalog = Get-GoogleCiemCatalog $gcpScopes = @($ciemCatalog.GcpScopes) $nhiRoles = @($ciemCatalog.NhiBuiltInRoles) $customRoleId = $ciemCatalog.CustomRoleId } } $sorted = [ordered]@{} foreach ($key in @($packs.Keys | Sort-Object { [string]$packs[$_].Label })) { $sorted[$key] = $packs[$key] } return [PSCustomObject]@{ FeaturePacks = $sorted CoreScopes = $script:CoreScopes GcpScopes = $gcpScopes CoreApis = $script:CoreApis NhiBuiltInRoles = $nhiRoles PlaygroundRedirect = $script:PlaygroundRedirect CustomRoleId = $customRoleId } } function Get-GoogleCreatedServiceAccount { return $script:CreatedServiceAccount } function Set-GoogleCreatedServiceAccount { param([Parameter(Mandatory)][string]$Email) $script:CreatedServiceAccount = $Email } Export-ModuleMember -Function @( 'Initialize-GoogleWorkspaceConnectorData' 'Get-GoogleWorkspaceCatalog' 'Get-GoogleCreatedServiceAccount' 'Set-GoogleCreatedServiceAccount' 'Invoke-GCloud' 'Test-GCloudAuth' 'Connect-GoogleCloud' 'ConvertTo-EncryptedRsaPem' 'Get-JsonProperty' 'New-KeyPassword' 'Get-SelectedScopes' 'Get-SelectedApis' 'Get-CustomRolePermissions' 'Get-GoogleVertexAgentRegions' 'ConvertTo-RoleYaml' 'Get-ServiceAccountEmail' 'Get-ExistingServiceAccount' 'Set-OrganizationCustomRole' 'Add-IamPolicyBinding' 'Get-GoogleAuthorizationUrl' 'Wait-GoogleAuthorizationCode' 'Invoke-GoogleTokenExchange' 'Get-GoogleOAuthToken' 'Get-GoogleRefreshToken' 'Invoke-AdminSdk' 'Get-AdminSdkRoles' 'Resolve-WorkspaceAdminRole' 'Test-WorkspaceRoleAssigned' 'Get-WorkspaceAdminAccounts' 'Add-WorkspaceAdminRoles' 'Show-CopyPasteValue' 'Write-DomainWideDelegationValues' 'Wait-Continue' 'Get-OAuthClientCreateUrl' 'Get-OAuthAudienceUrl' 'Write-OAuthClientSteps' 'Invoke-OAuthAudienceWalkthrough' 'Invoke-OAuthClientWalkthrough' 'Invoke-DomainWideDelegationWalkthrough' 'Test-ConnectionSettingIsSecret' 'Add-ConnectorIamBinding' ) |