diff --git a/hindsight-api/hindsight_api/engine/storage/gcs.py b/hindsight-api/hindsight_api/engine/storage/gcs.py index f44968bf..6013a193 100644 --- a/hindsight-api/hindsight_api/engine/storage/gcs.py +++ b/hindsight-api/hindsight_api/engine/storage/gcs.py @@ -1,7 +1,8 @@ """Google Cloud Storage backend using obstore.""" import logging -from datetime import timedelta +import os +from datetime import datetime, timedelta, timezone import obstore as obs from obstore.store import GCSStore @@ -11,6 +12,30 @@ from .base import FileStorage logger = logging.getLogger(__name__) +def _make_google_auth_credential_provider(): + """Create a credential provider using google.auth (supports all credential types). + + obstore's built-in credential parsing only supports service_account and + authorized_user JSON types. This provider uses the google-auth library + which additionally handles external_account (Workload Identity Federation), + impersonated credentials, and metadata-server credentials. + """ + import google.auth + import google.auth.transport.requests + + credentials, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"]) + request = google.auth.transport.requests.Request() + + def _provide(): + credentials.refresh(request) + expiry = credentials.expiry + if expiry and expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=timezone.utc) + return {"token": credentials.token, "expires_at": expiry} + + return _provide + + class GCSFileStorage(FileStorage): """ Google Cloud Storage backend. @@ -27,8 +52,29 @@ class GCSFileStorage(FileStorage): kwargs: dict = {} if service_account_key: kwargs["service_account_key"] = service_account_key + else: + # Use google.auth credential provider for broad credential type support + # (service_account, authorized_user, external_account, metadata server, etc.) + try: + kwargs["credential_provider"] = _make_google_auth_credential_provider() + logger.info("Using google.auth credential provider for GCS") + except Exception as e: + logger.warning( + f"Failed to create google.auth credential provider, falling back to obstore defaults: {e}" + ) - self._store = GCSStore(bucket, **kwargs) + # Workaround for https://github.com/developmentseed/obstore/issues/605 + # obstore's Rust layer doesn't support external_account credentials (Workload + # Identity Federation) and eagerly parses GOOGLE_APPLICATION_CREDENTIALS even + # when credential_provider is given. Per the obstore maintainer's guidance, + # remove env vars so the Rust code doesn't try to authenticate itself. + # google.auth (used by credential_provider above) has already loaded credentials. + gac = os.environ.pop("GOOGLE_APPLICATION_CREDENTIALS", None) + try: + self._store = GCSStore(bucket, **kwargs) + finally: + if gac is not None: + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = gac logger.info(f"Initialized GCS file storage: bucket={bucket}") async def store(self, file_data: bytes, key: str, metadata: dict[str, str] | None = None) -> str: