Skip to content

google-cloud-storage: BucketMetadataCache (ACO) triggers storage.buckets.get on object-only operations; no supported opt-out #17650

Open

Description

@AdrianFerJ

Environment details

  • Programming language: Python 3.11
  • Package: google-cloud-storage
  • Versions observed: 3.11.0 and 3.12.0 (the BucketMetadataCache / ACO code path is present in both)

Summary

Client.__init__ unconditionally instantiates a BucketMetadataCache ("App-centric Observability" / ACO). As a side effect, ordinary object-level operations (list_blobs, download_*, upload_*, etc.) now trigger a background Client.get_bucket() call, i.e. the REST storage.buckets.get permission — even though the application code never requests bucket metadata and the operation itself only needs storage.objects.*.

For principals granted object-only roles (roles/storage.objectViewer, roles/storage.objectAdmin) — which intentionally exclude storage.buckets.get — this produces a continuous stream of denied storage.buckets.get entries in Cloud Audit Logs (data_access, severity ERROR), one per bucket per client instance.

The failure is swallowed internally (data operations still succeed), so it is not a functional outage — but it is:

  • Alarming, high-volume audit-log noise (ERROR severity, "lots per second" under fan-out workloads that create many short-lived clients), and
  • Pressure to over-grant IAM — teams are asked to add storage.buckets.get (e.g. via roles/storage.bucketViewer/legacyBucketReader) purely to satisfy a telemetry probe the application does not use, across every bucket the service account ever touches. This directly conflicts with least-privilege.

There is no supported way to disable it — no client option and no environment variable (the only observability env var, ENABLE_GCS_PYTHON_CLIENT_OTEL_TRACES, is a separate opt-in for OpenTelemetry tracing). The only workaround is mutating the private attribute client._bucket_metadata_cache = None, which is fragile and fails open on library upgrades.

Steps to reproduce

Object-only operations cause a background get_bucket() (→ storage.buckets.get). The repro below is fully offline and needs no credentials or real bucket — it monkeypatches get_bucket only to observe that it is invoked and by whom:

import time
import traceback

from google.auth.credentials import AnonymousCredentials
from google.cloud import storage

calls = []
def traced_get_bucket(self, bucket_name, *a, **k):
    if not calls:
        print("get_bucket() caller:\n" + "".join(traceback.format_stack()[-3:-1]))
    calls.append(bucket_name)
    raise RuntimeError("short-circuit (offline repro)")  # swallowed by ACO bg thread
storage.Client.get_bucket = traced_get_bucket

client = storage.Client(project="proof", credentials=AnonymousCredentials())
# Optional workaround under test: client._bucket_metadata_cache = None
try:
    list(client.list_blobs("some-bucket", prefix="x/", max_results=1))
except Exception:
    pass                       # objects.list fails offline; irrelevant to the point
time.sleep(1.0)                # let the ACO daemon thread run

print(f"google-cloud-storage {storage.__version__}: "
      f"get_bucket invoked {len(calls)} time(s) by one list_blobs() call")

Actual output

get_bucket() caller:
  File ".../google/cloud/storage/_bucket_metadata_cache.py", line 98, in _fetch_background
    bucket = self._client.get_bucket(bucket_name, timeout=10.0)
google-cloud-storage 3.11.0: get_bucket invoked 1 time(s) by one list_blobs() call

With client._bucket_metadata_cache = None uncommented, the count is 0.

Root cause (call chain)

  1. _helpers.create_trace_span_helper wraps every operation. For any span not in {Storage.Client.getBucket, Storage.Client.lookupBucket, Storage.Bucket.reload, Storage.Bucket.exists} (i.e. object ops like listBlobs), it calls client._bucket_metadata_cache.get_or_queue_fetch(bucket_name).
  2. On a cache miss, get_or_queue_fetch spawns a daemon thread → _bucket_metadata_cache._fetch_background.
  3. _fetch_background calls self._client.get_bucket(bucket_name, timeout=10.0) → REST storage.buckets.get.
  4. On Forbidden, it caches a fallback and returns; the object op proceeds. Every fresh client re-probes on first touch of each bucket.

Expected behavior

Object-level operations should not require storage.buckets.get. This is a long-standing, explicitly-recognized contract for this library — cf. googleapis/python-storage #528 ("Redesign of API so it does not need storage.buckets.get access for every single operation") and #1225 ("Need to be able to download objects without storage.buckets.get permission same as gsutil"). gsutil/gcloud storage operate on objects without buckets.get, and object-only IAM roles deliberately omit it.

At minimum, ACO's background metadata probe should be opt-out via a supported mechanism, and ideally should not fire for principals/operations that don't need bucket metadata.

Requested change

A supported opt-out, e.g. one or more of:

  1. A Client constructor option (e.g. enable_bucket_metadata_cache: bool = True), and/or
  2. An environment variable mirroring the existing ENABLE_GCS_PYTHON_CLIENT_OTEL_TRACES pattern (e.g. DISABLE_GCS_PYTHON_CLIENT_BUCKET_METADATA_CACHE), and/or
  3. Making the probe lazy/suppressible so a Forbidden result is cached once per client without re-emitting audit-log errors, and documenting that ACO requires storage.buckets.get.

Any of these lets callers using object-only roles avoid the audit-log noise without granting storage.buckets.get, and gives a stable alternative to mutating the private _bucket_metadata_cache attribute.

Happy to open a PR for option 1 or 2 if a maintainer confirms the preferred shape.

Activity

  1. ayam04 commented on Aug 23, 2026

    @ayam04

    I'll implement this — the cleanest of the three options is the constructor flag (option 1): enable_bucket_metadata_cache: bool = True as a keyword-only argument, mirroring the existing api_key pattern. When False, the client skips instantiating the cache so create_trace_span_helper's probe is inert without any private-attribute hack. I'll add the env-var escape hatch too if the maintainers prefer, but constructor-first keeps it explicit. Implementing now.

  2. added a commit that references this issue on Aug 27, 2026
    0b6db28
  3. asteriaaaa commented on Sep 18, 2026

    @asteriaaaa

    Hi, I'd like to add an additional failure mode related to this, rather than lacking storage.buckets.get permission or socket issue mentioned in #18116 , the lib seems to have structurally discarded provided permission info.
    If the usage itself was wrong please correct me.

    Environment details

    Programming language: Python 3.12
    Package: google-cloud-storage
    Versions observed: 3.12.1

    Summary

    We were calling

        bucket = storage.Client(project=PROJECT_ID).bucket(TARGET_BUCKET, user_project=PROJECT_ID)
        bucket.blob(blob_path).upload_from_string(b'')

    when we get the error:

    methodName: "storage.buckets.get"
    Bucket is a requester pays bucket but no user project provided.

    While the object was successfully created at designated path.
    After downgrading lib to version 3.9.0 which other jobs were using, this error disappeared.
    From the observation of the existing issues, I assume this call to get() didn't take the "user_project=" I set in my code. And seems adding an opt-out isn't a true fix to this problem so I point it out here.

    I think the problem is with this, it passes a name string here, and get_bucket will create a new instance due to the string arg is presented, which creates a brand new bucket object without the user_project info.

    Attach the full audit log (removed user info), you can see a grant to resourcemanager.projects.createBillingAssignment is false while to storage.buckets.get is true.

    {
      "protoPayload": {
        "@type": "type.googleapis.com/google.cloud.audit.AuditLog",
        "status": {
          "code": 5,
          "message": "Bucket is a requester pays bucket but no user project provided."
        },
        "authenticationInfo": {
          "principalEmail": "",
          "serviceAccountDelegationInfo": [
            {
              "firstPartyPrincipal": {
                "principalEmail": ""
              }
            }
          ],
          "oauthInfo": {
            "oauthClientId": ""
          }
        },
        "requestMetadata": {
          "callerIp": "",
          "callerSuppliedUserAgent": "gcloud-python/3.12.1  gl-python/3.12.14 grpc/1.84.0 gax/2.37.0 gccl/3.12.1,gzip(gfe)",
          "requestAttributes": {
            "time": "2026-09-18",
            "auth": {}
          },
          "destinationAttributes": {}
        },
        "serviceName": "storage.googleapis.com",
        "methodName": "storage.buckets.get",
        "authorizationInfo": [
          {
            "resource": "projects/_",
            "permission": "storage.buckets.get",
            "granted": true,
            "resourceAttributes": {}
          },
          {
            "resource": "projects/",
            "permission": "resourcemanager.projects.createBillingAssignment",
            "granted": false,
            "resourceAttributes": {}
          }
        ],
        "resourceName": "projects/_/buckets",
        "metadata": {
          "session_id": ""
        },
        "resourceLocation": {
          "currentLocations": [
            ""
          ]
        }
      },
      "insertId": "1u1mp84e8zkib",
      "resource": {
        "type": "gcs_bucket",
        "labels": {
          "location": "",
          "project_id": "",
          "bucket_name": ""
        }
      },
      "timestamp": "2026-09-18",
      "severity": "ERROR",
      "logName": "projects/",
      "receiveTimestamp": "2026-09-18"
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions