Public/Google/Initialize-IDBridgeGoogleServiceAccount.ps1
|
<# .SYNOPSIS Bootstraps the Google service account IDBridge needs — project, APIs, service account, key, and the Workspace admin role — then prints the manual finish steps. .DESCRIPTION Run once per district, in the DISTRICT's tenant, authenticated as their Google Workspace super admin. Everything it creates stays in the district's Google Cloud organization; SC Networks retains nothing. Pure Invoke-RestMethod — no external modules. Steps (each idempotent, so re-running is safe): 1. Bootstrap token, tried in order: -AccessToken parameter; gcloud (print-access-token, running 'gcloud auth login' if needed); OAuth Playground (browser opens; paste the token when prompted). 2. Finds the district's GCP organization (organizations:search). If none exists yet and -CreateProject is set, opens the Cloud console for the one-time terms acceptance that provisions it, and waits for the org to appear. 3. -CreateProject: creates the project (display name 'IDBridge') under the org. 4. Enables the APIs IDBridge uses: Admin SDK, Sheets, Enterprise License Manager, IAM. 5. Creates the service account and reads its uniqueId — the identity the admin role is assigned to. 6. Creates the JSON key and seeds it STRAIGHT INTO THE VAULT as 'GoogleAuth-ServiceAccount' — the key never touches disk. If key creation is blocked by the iam.disableServiceAccountKeyCreation org policy, the function self-grants the admin roles/orgpolicy.policyAdmin (a Workspace super admin may grant org roles), sets a project-level exemption, retries, and then revokes the temporary role. 7. Creates the 'IDBridge' custom Workspace admin role (user, org-unit, group, and license management privileges — discovered from privileges.list, not hardcoded) and assigns it to the service account. The service account then authenticates as ITSELF: no domain-wide delegation, no admin impersonation, no admin user account. These Admin SDK calls need the admin.directory.rolemanagement scope, which cloud-platform bootstrap tokens don't carry — when that happens the function prompts for a second OAuth Playground token with just that scope. 8. Prints the finish checklist: share the source/log sheets with the service account's email (it accesses Sheets as itself), then verify. Manual prerequisites (see docs/google-bootstrap.md): - The Google Cloud Platform additional service must be ON for the admin in the district's Google Admin console (auth fails with an admin-policy error otherwise). - Requires an initialized session (Initialize-IDBridge — safe on a fresh install: Google auth is acquired by Invoke-IDBridge at run time, not at initialization). .PARAMETER ProjectId Globally-unique GCP project ID (lowercase letters/digits/hyphens). With -CreateProject it defaults to 'idbridge-<random>'; without it, the ID of the existing project to use. .PARAMETER ProjectName Display name for a created project. Defaults to 'IDBridge'. .PARAMETER CreateProject Create the project (under the district's organization when one exists). .PARAMETER ServiceAccountName Service account ID (6-30 chars, lowercase). Defaults to 'idbridge'. .PARAMETER AccessToken A cloud-platform-scoped access token as a SecureString, skipping the interactive tiers (e.g. from 'gcloud auth print-access-token' on another machine). The admin-role steps need the admin.directory.rolemanagement scope, which cloud-platform tokens don't carry — expect a one-time prompt for a second OAuth Playground token during those steps. .EXAMPLE Initialize-IDBridgeGoogleServiceAccount -CreateProject Full bootstrap for a new district: browser sign-in, new 'IDBridge' project, service account, key seeded to the vault, checklist printed. .EXAMPLE Initialize-IDBridgeGoogleServiceAccount -ProjectId 'idbridge-123456' Re-run against an existing project (e.g. to re-create the key after a vault loss). .NOTES Version: 1.0 Author: Sam Cattanach Date: 2026-07-02 Purpose: One-command Google-side setup for new IDBridge deployments. .LINK https://developers.google.com/oauthplayground #> function Initialize-IDBridgeGoogleServiceAccount() { [cmdletbinding()] Param( [parameter(Mandatory=$false)] [ValidatePattern('^[a-z][a-z0-9-]{4,28}[a-z0-9]$')] [string]$ProjectId, [parameter(Mandatory=$false)] [string]$ProjectName = 'IDBridge', [parameter(Mandatory=$false)] [switch]$CreateProject, [parameter(Mandatory=$false)] [ValidatePattern('^[a-z][a-z0-9-]{4,28}[a-z0-9]$')] [string]$ServiceAccountName = 'idbridge', [parameter(Mandatory=$false)] [securestring]$AccessToken ) # Fail fast if the session isn't initialized — the vault seeding at the end needs it, # and by then the token prompt and Google-side creation have already happened. $null = Get-IDBridgeConfig if ($CreateProject -and -not $ProjectId) { $ProjectId = "idbridge-$(Get-Random -Minimum 100000 -Maximum 999999)" } if (-not $ProjectId) { Throw "Provide -ProjectId (an existing project) or -CreateProject to make a new one." } #region Helper functions # Invoke a Google API call with the bootstrap token, unwrapping Google's error JSON. function Invoke-BootstrapApi { param([string]$Method, [string]$Uri, $Body) $params = @{ Method = $Method; Uri = $Uri; Headers = $script:BootstrapHeaders; ErrorAction = 'Stop' } if ($null -ne $Body) { $params['Body'] = ($Body | ConvertTo-Json -Depth 10) $params['ContentType'] = 'application/json' } Invoke-RestMethod @params } # Poll a long-running operation URL until done (project creation, API enablement). function Wait-BootstrapOperation { param([string]$OperationUri) for ($i = 0; $i -lt 30; $i++) { $operation = Invoke-BootstrapApi -Method Get -Uri $OperationUri if ($operation.done) { if ($operation.error) { Throw "Operation failed: $($operation.error.message)" } return $operation } Start-Sleep -Seconds 2 } Throw "Timed out waiting for operation: $OperationUri" } # Best available error text from a failed Invoke-RestMethod (Google's JSON body when present). function Get-BootstrapError { param($ErrorRecord) if (-not [string]::IsNullOrWhiteSpace($ErrorRecord.ErrorDetails.Message)) { return $ErrorRecord.ErrorDetails.Message } return $ErrorRecord.Exception.Message } # Invoke an Admin SDK call, prompting once for a role-management-scoped token if the # bootstrap token can't carry it (cloud-platform tokens from gcloud/-AccessToken can't). function Invoke-BootstrapAdminApi { param([string]$Method, [string]$Uri, $Body) try { Invoke-BootstrapApi -Method $Method -Uri $Uri -Body $Body } catch { if ($_.ErrorDetails.Message -notmatch 'ACCESS_TOKEN_SCOPE_INSUFFICIENT|[Ii]nsufficient') { Throw } Write-Host "The bootstrap token does not carry the Admin SDK role-management scope." -ForegroundColor Yellow Write-Host "Opening the OAuth 2.0 Playground: authorize the scope" -ForegroundColor Cyan Write-Host " https://www.googleapis.com/auth/admin.directory.rolemanagement" -ForegroundColor Cyan Write-Host "(Step 1 -> Authorize APIs -> sign in as the district super admin -> Step 2 -> Exchange authorization code), then copy the access_token." -ForegroundColor Cyan Start-Process 'https://developers.google.com/oauthplayground/' $pastedToken = Read-Host -Prompt "Paste the access token" -AsSecureString $script:BootstrapHeaders = @{ Authorization = "Bearer $(ConvertFrom-SecureString -SecureString $pastedToken -AsPlainText)" } Invoke-BootstrapApi -Method $Method -Uri $Uri -Body $Body } } # Flatten the Admin SDK privilege tree (privileges nest via childPrivileges). function Expand-BootstrapPrivilege { param($Privileges) foreach ($privilege in $Privileges) { $privilege if ($privilege.childPrivileges) { Expand-BootstrapPrivilege -Privileges $privilege.childPrivileges } } } #endregion Helper functions #region Resolve the bootstrap token $plainToken = $null if ($AccessToken) { $plainToken = ConvertFrom-SecureString -SecureString $AccessToken -AsPlainText Write-Log -Message "Bootstrap: Using the supplied access token." -Level Trace } elseif (Get-Command gcloud -ErrorAction SilentlyContinue) { $plainToken = (& gcloud auth print-access-token 2>$null) if (-not $plainToken) { Write-Host "Opening a browser via 'gcloud auth login' - sign in as the DISTRICT's super admin." -ForegroundColor Cyan & gcloud auth login --quiet 2>$null | Out-Null $plainToken = (& gcloud auth print-access-token 2>$null) } if (-not $plainToken) { Throw "gcloud could not produce an access token. Run 'gcloud auth login' manually and retry." } Write-Log -Message "Bootstrap: Acquired token via gcloud." -Level Trace } else { Write-Host "Opening the OAuth 2.0 Playground: authorize BOTH scopes (space-separated, in 'Input your own scopes')" -ForegroundColor Cyan Write-Host " https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/admin.directory.rolemanagement" -ForegroundColor Cyan Write-Host "(the second scope covers the admin-role steps - one token does the whole bootstrap)" -ForegroundColor Cyan Write-Host "(Step 1 -> Authorize APIs -> sign in as the district super admin -> Step 2 -> Exchange authorization code), then copy the access_token." -ForegroundColor Cyan Start-Process 'https://developers.google.com/oauthplayground/' $pastedToken = Read-Host -Prompt "Paste the access token" -AsSecureString $plainToken = ConvertFrom-SecureString -SecureString $pastedToken -AsPlainText } $script:BootstrapHeaders = @{ Authorization = "Bearer $plainToken" } #endregion Resolve the bootstrap token #region Find the organization $orgName = $null try { $orgSearch = Invoke-BootstrapApi -Method Get -Uri 'https://cloudresourcemanager.googleapis.com/v3/organizations:search' if ($orgSearch.organizations) { $orgName = $orgSearch.organizations[0].name # 'organizations/<number>' Write-Log -Message "Bootstrap: Found organization $orgName ($($orgSearch.organizations[0].displayName))." } } catch { Write-Log -Message "Bootstrap: Organization search failed ($($_.Exception.Message)); continuing without an organization parent." -Level Warn } if (-not $orgName -and $CreateProject) { # A brand-new domain has no org until someone accepts the Cloud console terms — a # deliberate human step with no API. Open the console and wait for the org to appear. Write-Host "No Google Cloud organization exists for this domain yet." -ForegroundColor Yellow Write-Host "Opening the Cloud console - sign in as the district super admin and accept the terms, then leave this window open." -ForegroundColor Cyan Start-Process 'https://console.cloud.google.com/' for ($i = 0; $i -lt 30 -and -not $orgName; $i++) { Start-Sleep -Seconds 10 try { $orgSearch = Invoke-BootstrapApi -Method Get -Uri 'https://cloudresourcemanager.googleapis.com/v3/organizations:search' if ($orgSearch.organizations) { $orgName = $orgSearch.organizations[0].name } } catch { } } if ($orgName) { Write-Log -Message "Bootstrap: Organization provisioned: $orgName." } else { Write-Log -Message "Bootstrap: No organization appeared after 5 minutes; creating the project without a parent." -Level Warn } } #endregion Find the organization #region Create the project if ($CreateProject) { $projectBody = @{ projectId = $ProjectId; displayName = $ProjectName } if ($orgName) { $projectBody['parent'] = $orgName } try { $operation = Invoke-BootstrapApi -Method Post -Uri 'https://cloudresourcemanager.googleapis.com/v3/projects' -Body $projectBody $null = Wait-BootstrapOperation -OperationUri "https://cloudresourcemanager.googleapis.com/v3/$($operation.name)" Write-Log -Message "Bootstrap: Created project '$ProjectId' (display name '$ProjectName')." } catch { if ($_.ErrorDetails.Message -match 'ALREADY_EXISTS|alreadyExists') { Write-Log -Message "Bootstrap: Project '$ProjectId' already exists; continuing." -Level Warn } else { Throw "Error creating project '$ProjectId': $(Get-BootstrapError $_)" } } } #endregion Create the project #region Enable APIs $apiIds = @('admin.googleapis.com', 'sheets.googleapis.com', 'licensing.googleapis.com', 'iam.googleapis.com') try { $operation = Invoke-BootstrapApi -Method Post -Uri "https://serviceusage.googleapis.com/v1/projects/$ProjectId/services:batchEnable" -Body @{ serviceIds = $apiIds } if ($operation.name -and $operation.name -ne 'operations/noop') { $null = Wait-BootstrapOperation -OperationUri "https://serviceusage.googleapis.com/v1/$($operation.name)" } Write-Log -Message "Bootstrap: Enabled APIs on '$ProjectId': $($apiIds -join ', ')." } catch { Throw "Error enabling APIs on '$ProjectId': $(Get-BootstrapError $_)" } #endregion Enable APIs #region Create the service account $serviceAccountEmail = "$ServiceAccountName@$ProjectId.iam.gserviceaccount.com" try { $serviceAccount = Invoke-BootstrapApi -Method Post -Uri "https://iam.googleapis.com/v1/projects/$ProjectId/serviceAccounts" -Body @{ accountId = $ServiceAccountName serviceAccount = @{ displayName = 'IDBridge' } } Write-Log -Message "Bootstrap: Created service account $($serviceAccount.email)." } catch { if ($_.ErrorDetails.Message -match 'ALREADY_EXISTS|alreadyExists') { $serviceAccount = Invoke-BootstrapApi -Method Get -Uri "https://iam.googleapis.com/v1/projects/$ProjectId/serviceAccounts/$serviceAccountEmail" Write-Log -Message "Bootstrap: Service account $($serviceAccount.email) already exists; continuing." -Level Warn } else { Throw "Error creating service account '$ServiceAccountName': $(Get-BootstrapError $_)" } } #endregion Create the service account #region Create the key (with the org-policy dance only if actually blocked) $keyUri = "https://iam.googleapis.com/v1/projects/$ProjectId/serviceAccounts/$($serviceAccount.email)/keys" $keyJson = $null $grantedPolicyAdmin = $false $adminMember = $null try { $key = Invoke-BootstrapApi -Method Post -Uri $keyUri -Body @{} $keyJson = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($key.privateKeyData)) } catch { if ($_.ErrorDetails.Message -notmatch 'disableServiceAccountKeyCreation|Key creation is not allowed') { Throw "Error creating the service account key: $(Get-BootstrapError $_)" } # Blocked by the org policy. Self-grant Organization Policy Administrator (a Workspace # super admin may grant org roles), exempt this project, retry, then revoke below. Write-Log -Message "Bootstrap: Key creation is blocked by iam.disableServiceAccountKeyCreation; attempting a project-level exemption." -Level Warn if (-not $orgName) { Throw "Key creation is blocked by org policy but no organization is visible to this account. Ask the district's GCP admin to exempt project '$ProjectId' from iam.disableServiceAccountKeyCreation." } # Identify the admin for the IAM binding (email is in the token when our OAuth flow ran) try { $tokenInfo = Invoke-RestMethod -Uri "https://oauth2.googleapis.com/tokeninfo?access_token=$plainToken" -ErrorAction Stop } catch { $tokenInfo = $null } $adminEmail = $tokenInfo.email if (-not $adminEmail) { $adminEmail = Read-Host -Prompt "Enter the email of the admin you signed in as (for the temporary role grant)" } $adminMember = "user:$adminEmail" try { $orgPolicy = Invoke-BootstrapApi -Method Post -Uri "https://cloudresourcemanager.googleapis.com/v3/$($orgName):getIamPolicy" -Body @{} $binding = $orgPolicy.bindings | Where-Object { $_.role -eq 'roles/orgpolicy.policyAdmin' } if ($binding -and $binding.members -notcontains $adminMember) { $binding.members += $adminMember } elseif (-not $binding) { $orgPolicy.bindings += [PSCustomObject]@{ role = 'roles/orgpolicy.policyAdmin'; members = @($adminMember) } } $null = Invoke-BootstrapApi -Method Post -Uri "https://cloudresourcemanager.googleapis.com/v3/$($orgName):setIamPolicy" -Body @{ policy = $orgPolicy } $grantedPolicyAdmin = $true Write-Log -Message "Bootstrap: Temporarily granted roles/orgpolicy.policyAdmin to $adminEmail." } catch { Throw "Could not grant the Organization Policy Administrator role ($(Get-BootstrapError $_)). Ask the district's GCP admin to run: gcloud org-policies reset iam.disableServiceAccountKeyCreation --project=$ProjectId" } # Set the exemption and retry the key — IAM grants can take a minute to propagate. $exemptionBody = @{ name = "projects/$ProjectId/policies/iam.disableServiceAccountKeyCreation" spec = @{ rules = @(@{ enforce = $false }) } } for ($i = 0; $i -lt 9 -and -not $keyJson; $i++) { try { try { $null = Invoke-BootstrapApi -Method Post -Uri "https://orgpolicy.googleapis.com/v2/projects/$ProjectId/policies" -Body $exemptionBody } catch { if ($_.ErrorDetails.Message -match 'ALREADY_EXISTS|alreadyExists') { $null = Invoke-BootstrapApi -Method Patch -Uri "https://orgpolicy.googleapis.com/v2/projects/$ProjectId/policies/iam.disableServiceAccountKeyCreation" -Body $exemptionBody } else { Throw } } $key = Invoke-BootstrapApi -Method Post -Uri $keyUri -Body @{} $keyJson = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($key.privateKeyData)) } catch { Start-Sleep -Seconds 10 } } if (-not $keyJson) { Throw "Key creation still blocked after the org-policy exemption. Wait a few minutes and re-run with -ProjectId '$ProjectId' (everything so far is idempotent)." } } Write-Log -Message "Bootstrap: Created a key for $($serviceAccount.email) (kept in memory only)." #endregion Create the key #region Seed the vault and clean up Set-IDBridgeSecret -Name 'GoogleAuth-ServiceAccount' -Secret (ConvertTo-SecureString $keyJson -AsPlainText -Force) $keyJson = $null if ($grantedPolicyAdmin) { try { $orgPolicy = Invoke-BootstrapApi -Method Post -Uri "https://cloudresourcemanager.googleapis.com/v3/$($orgName):getIamPolicy" -Body @{} foreach ($binding in ($orgPolicy.bindings | Where-Object { $_.role -eq 'roles/orgpolicy.policyAdmin' })) { $binding.members = @($binding.members | Where-Object { $_ -ne $adminMember }) } $orgPolicy.bindings = @($orgPolicy.bindings | Where-Object { $_.members.Count -gt 0 }) $null = Invoke-BootstrapApi -Method Post -Uri "https://cloudresourcemanager.googleapis.com/v3/$($orgName):setIamPolicy" -Body @{ policy = $orgPolicy } Write-Log -Message "Bootstrap: Revoked the temporary roles/orgpolicy.policyAdmin grant." } catch { Write-Log -Message "Bootstrap: Could not revoke the temporary policyAdmin role — remove $adminMember from roles/orgpolicy.policyAdmin on $orgName manually. ($($_.Exception.Message))" -Level Warn } } #endregion Seed the vault and clean up #region Create the IDBridge admin role and assign it to the service account # The service account authenticates as ITSELF — a custom Workspace admin role (not # domain-wide delegation) authorizes its Admin SDK calls. Privileges are resolved from # privileges.list at run time because their serviceIds vary per customer. $adminApiBase = 'https://admin.googleapis.com/admin/directory/v1/customer/my_customer' try { $privilegeCatalog = Invoke-BootstrapAdminApi -Method Get -Uri "$adminApiBase/roles/ALL/privileges" } catch { Throw "Error listing Admin SDK privileges: $(Get-BootstrapError $_)" } $allPrivileges = Expand-BootstrapPrivilege -Privileges $privilegeCatalog.items # Licensing privileges are optional (feature-gated by Google.enableLicenseRemoval); the # rest are required. LICENSING covers Enterprise License Manager writes and # LICENSING_READ the per-run assignment reads (catalog names — the Admin console calls # this "License Management"). $requiredPrivilegeNames = @('USERS_ALL', 'ORGANIZATION_UNITS_ALL', 'GROUPS_ALL') $optionalPrivilegeNames = @('LICENSING', 'LICENSING_READ') $rolePrivileges = @() foreach ($privilegeName in ($requiredPrivilegeNames + $optionalPrivilegeNames)) { $match = $allPrivileges | Where-Object { $_.privilegeName -eq $privilegeName } | Select-Object -First 1 if ($match) { $rolePrivileges += @{ privilegeName = $match.privilegeName; serviceId = $match.serviceId } } elseif ($privilegeName -in $requiredPrivilegeNames) { Throw "Privilege '$privilegeName' was not found in this customer's privileges list — cannot build the IDBridge admin role." } else { Write-Log -Message "Bootstrap: Privilege '$privilegeName' not found; the role is created without it (license removal will need it later)." -Level Warn } } # Find or create the role (roleName is unique per customer); patch privileges when it # already exists so a re-run converges an older role to the current set. $role = $null $rolesUri = "$adminApiBase/roles?maxResults=100" do { $rolesPage = Invoke-BootstrapAdminApi -Method Get -Uri $rolesUri $role = $rolesPage.items | Where-Object { $_.roleName -eq 'IDBridge' } | Select-Object -First 1 $rolesUri = if ($rolesPage.nextPageToken) { "$adminApiBase/roles?maxResults=100&pageToken=$($rolesPage.nextPageToken)" } } while (-not $role -and $rolesUri) try { if ($role) { $role = Invoke-BootstrapAdminApi -Method Patch -Uri "$adminApiBase/roles/$($role.roleId)" -Body @{ rolePrivileges = $rolePrivileges } Write-Log -Message "Bootstrap: Admin role 'IDBridge' already exists; privileges converged ($($rolePrivileges.privilegeName -join ', '))." -Level Warn } else { $role = Invoke-BootstrapAdminApi -Method Post -Uri "$adminApiBase/roles" -Body @{ roleName = 'IDBridge' roleDescription = 'IDBridge service account - user, org unit, group, and license management' rolePrivileges = $rolePrivileges } Write-Log -Message "Bootstrap: Created admin role 'IDBridge' ($($rolePrivileges.privilegeName -join ', '))." } } catch { Throw "Error creating/updating the 'IDBridge' admin role: $(Get-BootstrapError $_)" } # Assign the role to the service account (assignedTo = the SA's IAM uniqueId). $assignment = $null $assignmentsUri = "$adminApiBase/roleassignments?roleId=$($role.roleId)&maxResults=200" do { $assignmentsPage = Invoke-BootstrapAdminApi -Method Get -Uri $assignmentsUri $assignment = $assignmentsPage.items | Where-Object { $_.assignedTo -eq $serviceAccount.uniqueId } | Select-Object -First 1 $assignmentsUri = if ($assignmentsPage.nextPageToken) { "$adminApiBase/roleassignments?roleId=$($role.roleId)&maxResults=200&pageToken=$($assignmentsPage.nextPageToken)" } } while (-not $assignment -and $assignmentsUri) if ($assignment) { Write-Log -Message "Bootstrap: Role 'IDBridge' is already assigned to $($serviceAccount.email); continuing." -Level Warn } else { try { $null = Invoke-BootstrapAdminApi -Method Post -Uri "$adminApiBase/roleassignments" -Body @{ roleId = $role.roleId assignedTo = $serviceAccount.uniqueId scopeType = 'CUSTOMER' } Write-Log -Message "Bootstrap: Assigned admin role 'IDBridge' to $($serviceAccount.email)." } catch { Throw "Error assigning the 'IDBridge' role to $($serviceAccount.email): $(Get-BootstrapError $_)" } } #endregion Create the IDBridge admin role and assign it to the service account #region Finish checklist Write-Host "" Write-Host "================ MANUAL FINISH STEPS (no API exists for these) ================" -ForegroundColor Green Write-Host "1. Share the Google Sheets IDBridge uses (staff source sheet, log sheet) with:" -ForegroundColor Green Write-Host " $($serviceAccount.email)" -ForegroundColor Cyan Write-Host " (Editor access - the service account reads sources and writes logs as itself.)" Write-Host "2. Verify the auth chain (no pipeline run): Connect-IDBridgeGoogle" Write-Host " (Role assignments can take a few minutes to propagate; a 403 right away is normal.)" Write-Host "3. Full verification: Invoke-IDBridge -ReadOnly" Write-Host "4. If this deployment previously used domain-wide delegation, remove the grant" -ForegroundColor Yellow Write-Host " AFTER step 3 passes: https://admin.google.com/ac/owl/domainwidedelegation" -ForegroundColor Yellow Write-Host "==============================================================================" -ForegroundColor Green return [PSCustomObject]@{ ProjectId = $ProjectId ServiceAccountEmail = $serviceAccount.email ClientId = $serviceAccount.uniqueId RoleId = $role.roleId } #endregion Finish checklist } |