"""Sync a user's Google Workspace group membership at OAuth callback. Called from `app/auth/providers/google.py`. Uses keyless Domain-Wide Delegation: the VM service account signs the impersonation JWT via the IAM ``signJwt`` API (no private key on disk), then exchanges that JWT for a short-lived OAuth token scoped to ``admin.directory.group.readonly``. The Admin SDK ``groups.list?userKey=`` endpoint returns the user's static AND dynamic group memberships in one call. Required GCP setup (one-off): - The VM SA grants itself ``roles/iam.serviceAccountTokenCreator`` so it can call ``IAMCredentials.signJwt`` for its own identity. - A Domain-Wide Delegation entry exists in admin.google.com → Security → API controls → Domain-wide Delegation, mapping the VM SA's numeric Unique ID to scope ``admin.directory.group.readonly``. Required env on the VM: - ``GOOGLE_ADMIN_SDK_SUBJECT`` — the Workspace admin email the SA impersonates. Must be a real Workspace user with directory read privileges. When unset, this module fails soft and returns ``[]``. - ``GOOGLE_ADMIN_SDK_SA_EMAIL`` (optional) — explicit SA email override. When unset, the SA is auto-detected from the GCE metadata server, i.e. whichever SA the VM is currently running as. Useful off-VM (CI, tests). Local dev / CI: Set ``GOOGLE_ADMIN_SDK_MOCK_GROUPS`` to a comma-separated list of group emails to bypass all Google calls. Empty value → empty list. Unset → the real keyless-DWD path. """ from __future__ import annotations import logging import os import urllib.error import urllib.request from typing import List logger = logging.getLogger(__name__) # Bypass real API entirely. Comma-separated group emails. Empty → []. Unset → # real keyless-DWD path. MOCK_ENV = "GOOGLE_ADMIN_SDK_MOCK_GROUPS" # Required: the Workspace admin email impersonated through DWD. SUBJECT_ENV = "GOOGLE_ADMIN_SDK_SUBJECT" # Optional: SA email override. When unset, auto-detect from GCE metadata. SA_EMAIL_ENV = "GOOGLE_ADMIN_SDK_SA_EMAIL" SCOPE = "https://www.googleapis.com/auth/admin.directory.group.readonly" _METADATA_SA_URL = ( "http://metadata.google.internal/computeMetadata/v1/instance/" "service-accounts/default/email" ) def fetch_user_groups(email: str) -> List[str]: """Return the list of group emails ``email`` is a member of. Fail-soft: returns ``[]`` on any error (missing config, metadata server unreachable, API 4xx/5xx, network outage). The caller in the OAuth callback treats ``[]`` as "no data" and leaves the previous membership snapshot intact — so a transient outage does not wipe a user's groups. """ mock = os.environ.get(MOCK_ENV) if mock is not None: return [g.strip() for g in mock.split(",") if g.strip()] return _fetch_real(email) def _detect_sa_email() -> str | None: """Return the SA email this process should impersonate as. Order of resolution: 1. ``GOOGLE_ADMIN_SDK_SA_EMAIL`` env var — explicit override. 2. GCE metadata server — the SA the VM is attached to. Returns ``None`` when neither is available (off-VM with no override). """ explicit = os.environ.get(SA_EMAIL_ENV, "").strip() if explicit: return explicit try: req = urllib.request.Request( _METADATA_SA_URL, headers={"Metadata-Flavor": "Google"}, ) with urllib.request.urlopen(req, timeout=2) as resp: return resp.read().decode("ascii").strip() except (urllib.error.URLError, urllib.error.HTTPError, OSError): return None def _fetch_real(email: str) -> List[str]: try: from google.auth import default, iam from google.auth.transport.requests import Request from google.oauth2 import service_account from googleapiclient.discovery import build except ImportError: logger.warning( "google-api-python-client / google-auth not installed; " "skipping group fetch" ) return [] subject = os.environ.get(SUBJECT_ENV, "").strip() if not subject: logger.warning( "%s not set; skipping group fetch (keyless DWD requires an " "admin email to impersonate)", SUBJECT_ENV, ) return [] sa_email = _detect_sa_email() if not sa_email: logger.warning( "Could not determine VM service account email " "(metadata server unreachable and %s not set); " "skipping group fetch", SA_EMAIL_ENV, ) return [] try: source, _ = default() signer = iam.Signer(Request(), source, sa_email) creds = service_account.Credentials( signer=signer, service_account_email=sa_email, token_uri="https://oauth2.googleapis.com/token", scopes=[SCOPE], subject=subject, ) service = build( "admin", "directory_v1", credentials=creds, cache_discovery=False, ) except Exception as e: # noqa: BLE001 - fail-soft by design logger.warning("Admin SDK init failed: %s", e) return [] groups: List[str] = [] page_token: str | None = None try: while True: resp = service.groups().list( userKey=email, maxResults=200, pageToken=page_token, ).execute() for g in resp.get("groups", []): gid = g.get("email") if gid: groups.append(gid) page_token = resp.get("nextPageToken") if not page_token: break except Exception as e: # noqa: BLE001 - fail-soft by design logger.warning("Group fetch failed for %s: %s", email, e) return [] return groups