SEO Portal Path C — Delegated Service-Account GSC/GA via Workload Identity Federation (MC #103390)
Overview
Updating
MC #103390 — SEO Readiness Portal Path C replaces the blocked per-client Google OAuth flow
with fulla contentdelegated read-only service account authenticated via
curl.Workload Identity Federation (WIF). No static JSON key is created or stored;
the portal authenticates entirely through Azure App Service's managed identity federating into GCP.
Why Path C?
The previous OAuth path required Google to verify the app's OAuth consent screen for restricted scopes
(webmasters.readonly + analytics.readonly). Asmir (pilot client, snowit.ba) hit
the hard block: "Access blocked: snowit.ba has not completed the Google verification process"
on /api/oauth/google/start. Google app verification for restricted scopes takes weeks and
requires domain validation, brand approval, and security review — not feasible for an MVP launch.
Path C sidesteps this entirely: the portal reads GSC and GA4 data as a single read-only service account that each client explicitly grants access to in their own Google properties. No OAuth consent screen, no Google app verification, no per-user token storage.
Service Account
| Field | Value |
|---|---|
| SA email (canonical) | [email protected] |
| GCP project | tribal-sign-487920-k0 (project number: 762788903040) |
| Display name | SEO Portal GSC/GA read-only reader |
| Unique ID | 103977132559951298607 |
| Project IAM roles | None — access granted per-property by each client only |
| APIs enabled | searchconsole.googleapis.com, analyticsdata.googleapis.com, iamcredentials.googleapis.com, sts.googleapis.com |
Workload Identity Federation Chain
Because the GCP organisation (ID: 897698145517) enforces the org policy
constraints/iam.disableServiceAccountKeyCreation, no static JSON key can be created.
WIF allows the Azure App Service's managed identity to impersonate the SA at runtime without any
long-lived credential.
Architecture Diagram
┌────────────────────────────────────────────────────────────────────────────────┐ │ SEO Portal — Path C Auth Chain │ │ │ │ Azure App Service (seo-readiness-alai, rg-seo-readiness-prod) │ │ System-Assigned Managed Identity │ │ principalId (objectId): b381e292-c4ce-488f-a553-3da32d077461 │ │ tenantId: 3454a03f-20b4-4bda-a116-2293c459aecd │ │ │ │ │ │ 1. Fetch MI token from Azure IMDS │ │ │ GET http://169.254.169.254/metadata/identity/oauth2/token │ │ │ ?resource=api://AzureADTokenExchange │ │ ▼ │ │ Azure AD (tenant 3454a03f) │ │ OIDC issuer: https://sts.windows.net/3454a03f.../ │ │ │ │ │ │ 2. Exchange MI JWT → GCP federated token │ │ │ POST https://sts.googleapis.com/v1/token │ │ ▼ │ │ GCP WIF Pool: azure-mi-pool │ │ Resource: projects/762788903040/locations/global/ │ │ workloadIdentityPools/azure-mi-pool │ │ Provider: azure-ad-mi │ │ - OIDC issuer: sts.windows.net/3454a03f.../ │ │ - Audience: api://AzureADTokenExchange │ │ - Attribute condition (strict lock): │ │ assertion.sub == "b381e292-c4ce-488f-a553-3da32d077461" │ │ && assertion.tid == "3454a03f-20b4-4bda-a116-2293c459aecd" │ │ │ │ │ │ 3. Impersonate SA via iamcredentials.googleapis.com │ │ │ roles/iam.workloadIdentityUser granted to this MI principal │ │ ▼ │ │ Service Account: [email protected] │ │ Short-lived access token (1h TTL, auto-refreshed) │ │ │ │ │ │ 4a. GSC Search Analytics API │ │ │ searchconsole.searchanalytics.query(siteUrl) │ │ │ 4b. GA4 Data API │ │ │ analyticsdata.runReport(properties/NNNN) │ │ ▼ │ │ Client's GSC + GA4 property (client must grant SA email as Viewer/Restricted) │ └────────────────────────────────────────────────────────────────────────────────┘
WIF Resource Names
| Resource | Name / Value |
|---|---|
| WIF Pool ID | azure-mi-pool |
| WIF Pool resource | projects/762788903040/locations/global/workloadIdentityPools/azure-mi-pool |
| WIF Provider ID | azure-ad-mi |
| WIF Provider resource | projects/762788903040/locations/global/workloadIdentityPools/azure-mi-pool/providers/azure-ad-mi |
| OIDC issuer | https://sts.windows.net/3454a03f-20b4-4bda-a116-2293c459aecd/ |
| Allowed audience | api://AzureADTokenExchange |
| Attribute condition | assertion.sub == "b381e292-..." && assertion.tid == "3454a03f-..." |
| SA impersonation member | principal://iam.googleapis.com/projects/762788903040/locations/global/workloadIdentityPools/azure-mi-pool/subject/b381e292-c4ce-488f-a553-3da32d077461 |
WIF Credential Config (non-secret)
Stored as Azure App Service app setting GOOGLE_APPLICATION_CREDENTIALS_JSON on
seo-readiness-alai. This JSON contains no private key material — it is safe to log
at the config level (but not to expose to browsers). Local copy:
/tmp/evidence-103390/wif-credential-config.json.
{
"type": "external_account",
"audience": "//iam.googleapis.com/projects/762788903040/locations/global/workloadIdentityPools/azure-mi-pool/providers/azure-ad-mi",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "https://sts.googleapis.com/v1/token",
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[email protected]:generateAccessToken",
"credential_source": {
"url": "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=api%3A%2F%2FAzureADTokenExchange",
"headers": { "Metadata": "true" },
"format": { "type": "json", "subject_token_field_name": "access_token" }
}
}
Connection-Verify Flow (verifyAndSaveGrant)
The server action verifyAndSaveGrant() in
src/app/intake/[token]/access-grant/actions.ts handles the client grant confirmation:
- Token gate: clientId is extracted from the HMAC intake token payload only — never from user input.
- Field validation: GSC requires
siteUrl; GA4 requiresga4PropertyId. - Live probe: Calls
verifyAccess()fromsa-reader.ts, which attempts one real API call using the WIF-authenticated SA. A 403 from Google means the client has not yet added the SA as a user. - Persist on success: Calls
saveGoogleConnectionSa()— storesconnectionMode="delegated_sa", sentinel token values,grantVerified=true,verifiedAt=now. Idempotent on platform. - Error isolation:
not_granted→ nothing persisted, SA email returned for display.api_error/misconfigured→ generic error, nothing persisted. Token value never included in any returned detail.
Security Posture
| Property | Details |
|---|---|
| No static key | Org policy constraints/iam.disableServiceAccountKeyCreation is respected. No JSON key was created or stored anywhere. |
| Read-only SA | SA holds no project IAM roles. Access is granted only per-property by the client in their own GSC/GA4 settings. |
| Strict WIF attribute condition | Only the specific Azure MI (objectId b381e292) from the specific tenant (3454a03f) can impersonate the SA. No other identity can use this WIF provider. |
| Short-lived tokens | WIF-generated access tokens have a 1-hour TTL and are fetched at runtime by the google-auth-library ADC. No long-lived credential on disk. |
| clientId isolation | verifyAndSaveGrant() derives clientId from HMAC-signed token only. A client cannot probe another client's property. |
| No secret logging | Verified via grep in ST2 evidence: no console calls, no token/auth/credential values logged anywhere in sa-reader.ts. |
| Scope | Reads are scoped to GSC Search Analytics and GA4 Data API. No write scopes, no Admin SDK, no domain-wide delegation. |
No-Key Rotation / How WIF Differs from Key Rotation
Traditional SA Key Rotation
With a static JSON key (GOOGLE_SA_KEY), the operational runbook is:
- Generate a new key via GCP IAM Console.
- Update the Azure App Service secret
GOOGLE_SA_KEY. - Restart the App Service to pick up the new value.
- Verify the old key is revoked in GCP IAM → Service Accounts → Keys.
- Repeat every 90 days (or on suspected compromise).
This path is not available because constraints/iam.disableServiceAccountKeyCreation is enforced at org level (org ID: 897698145517). No account with org-level orgpolicy.policyAdmin is currently credentialed.
WIF — There Is Nothing to Rotate
The WIF credential config (GOOGLE_APPLICATION_CREDENTIALS_JSON) contains no private key material.
It is a routing config that tells the google-auth-library how to reach the Azure IMDS endpoint and which
WIF pool to exchange against. It is not a secret. Tokens are minted at runtime by Azure MI (renewed automatically
by the platform) and exchanged for short-lived GCP access tokens.
Operational actions that DO require maintenance:
-
If the Azure App Service is recreated / MI changes: The new MI objectId must be added as a
new
roles/iam.workloadIdentityUserbinding on the SA, and the WIF provider attribute condition must be updated to the new objectId. UpdateGOOGLE_APPLICATION_CREDENTIALS_JSONwith the new MI's IMDS URL if the subscription/tenantId changes. -
If the GCP WIF pool/provider needs to be re-created: Re-run ST1 FlowForge steps B–D
(pool create, provider create, binding). Update
GOOGLE_APPLICATION_CREDENTIALS_JSONaudience URL with the new pool resource name. -
If the SA email changes: Update
service_account_impersonation_urlin the WIF config, update all client-facing onboarding instructions with the new SA email. - Audit / verify the chain is intact (periodic check): From Azure App Service Kudu console, run the 4-step ST6 probe sequence (documented in st1-flowforge.md): IMDS token fetch → GCP STS exchange → ADC token → tokeninfo SA email confirmation.
Summary: WIF eliminates the 90-day key rotation burden and removes the attack surface of a long-lived credential leaking from a secrets store. The tradeoff is that the WIF configuration binds to infrastructure identities (Azure MI objectId) rather than a portable key — so infra changes require config updates, not just secret rotation.
Evidence Files
/tmp/evidence-103390/st1-flowforge.md— SA creation, API enablement, WIF provisioning (Phase 1 + Phase 2)/tmp/evidence-103390/st2-codecraft.md— sa-reader.ts implementation, 19/19 Vitest tests PASS/tmp/evidence-103390/st3-codecraft.md— verifyAndSaveGrant action, GoogleConnection model pivot, 31/31 tests PASS/tmp/evidence-103390/wif-credential-config.json— non-secret WIF credential config (reference copy)/tmp/evidence-103390/client-onboarding-grant-access-bs.md— Bosnian client onboarding instructions (LEXICON-PENDING)/Users/makinja/business/ALAI-Holding-AS/products/SEO-Readiness-Portal/SPEC-PATH-C-DELEGATED-SA-103390.md— original specification
Client Onboarding Summary
Each client must add the SA email as a user in their own Google properties before the portal can read data. The steps are documented in the Bosnian client-facing document (file path above, Lexicon validation pending). In brief:
-
Google Search Console: Settings → Users and permissions → Add user →
paste
[email protected]→ permission Restricted or Full → Add. - Google Analytics 4: Admin → Property Access Management → + → paste same email → role Viewer → Add.
-
Portal grant page: Paste GSC site URL + GA4 property ID → click Verify.
The
verifyAndSaveGrant()action probes the live APIs and persists the connection on success.
Open Items / Follow-On
- ST4/Vizu: UX for the access-grant page (paste siteUrl/propertyId + verify button) — not yet built.
- ST5/Securion: OAuth retirement — disable per-client OAuth start/callback, confirm no dead secret exposure.
- ST6/Proveo: Real E2E against live snowit.ba GSC + GA4 property — final verification gate.
- Lexicon: Bosnian client onboarding text requires Dževad Jahić validation before client send.
- Push blocker: ST2/ST3 code on branch
seo-path-c-sa-reader-103390could not be pushed (network error from build context). Orchestrator to retry push from network-capable context.