SEO Portal Path C — Delegated Service-Account GSC/GA via Workload Identity Federation (MC #103390)

Overview

MC #103390 — SEO Readiness Portal Path C replaces the blocked per-client Google OAuth flow with a delegated read-only service account authenticated via 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

FieldValue
SA email (canonical)seo-gsc-reader@tribal-sign-487920-k0.iam.gserviceaccount.com
GCP projecttribal-sign-487920-k0 (project number: 762788903040)
Display nameSEO Portal GSC/GA read-only reader
Unique ID103977132559951298607
Project IAM rolesNone — access granted per-property by each client only
APIs enabledsearchconsole.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: seo-gsc-reader@tribal-sign-487920-k0.iam.gserviceaccount.com │
│    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

ResourceName / Value
WIF Pool IDazure-mi-pool
WIF Pool resourceprojects/762788903040/locations/global/workloadIdentityPools/azure-mi-pool
WIF Provider IDazure-ad-mi
WIF Provider resourceprojects/762788903040/locations/global/workloadIdentityPools/azure-mi-pool/providers/azure-ad-mi
OIDC issuerhttps://sts.windows.net/3454a03f-20b4-4bda-a116-2293c459aecd/
Allowed audienceapi://AzureADTokenExchange
Attribute conditionassertion.sub == "b381e292-..." && assertion.tid == "3454a03f-..."
SA impersonation memberprincipal://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/seo-gsc-reader@tribal-sign-487920-k0.iam.gserviceaccount.com: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:

  1. Token gate: clientId is extracted from the HMAC intake token payload only — never from user input.
  2. Field validation: GSC requires siteUrl; GA4 requires ga4PropertyId.
  3. Live probe: Calls verifyAccess() from sa-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.
  4. Persist on success: Calls saveGoogleConnectionSa() — stores connectionMode="delegated_sa", sentinel token values, grantVerified=true, verifiedAt=now. Idempotent on platform.
  5. 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

PropertyDetails
No static keyOrg policy constraints/iam.disableServiceAccountKeyCreation is respected. No JSON key was created or stored anywhere.
Read-only SASA holds no project IAM roles. Access is granted only per-property by the client in their own GSC/GA4 settings.
Strict WIF attribute conditionOnly 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 tokensWIF-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 isolationverifyAndSaveGrant() derives clientId from HMAC-signed token only. A client cannot probe another client's property.
No secret loggingVerified via grep in ST2 evidence: no console calls, no token/auth/credential values logged anywhere in sa-reader.ts.
ScopeReads 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:

  1. Generate a new key via GCP IAM Console.
  2. Update the Azure App Service secret GOOGLE_SA_KEY.
  3. Restart the App Service to pick up the new value.
  4. Verify the old key is revoked in GCP IAM → Service Accounts → Keys.
  5. 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:

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

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:

  1. Google Search Console: Settings → Users and permissions → Add user → paste seo-gsc-reader@tribal-sign-487920-k0.iam.gserviceaccount.com → permission Restricted or Full → Add.
  2. Google Analytics 4: Admin → Property Access Management → + → paste same email → role Viewer → Add.
  3. 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


Revision #3
Created 2026-06-10 20:35:55 UTC by John
Updated 2026-06-10 20:37:08 UTC by John