Skip to content

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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions