allUsers + objectViewer on GCS Grants list, Not Just get
This article may contain affiliate links. Its content is not affected by advertising.
In short
Granting roles/storage.objectViewer to allUsers bundles storage.objects.list with get, letting anyone enumerate every object name in the bucket with no authentication at all.
Conclusion
Granting roles/storage.objectViewer to allUsers doesn’t just allow get on a known object — it bundles storage.objects.list, letting anyone enumerate every object name in the bucket with no authentication at all. キミテラス-v2’s public ad-creative bucket (ad_media) granted allUsers read access so signage devices could GET images directly via <img> tags, and the role used was roles/storage.objectViewer. What the design actually wanted was “fetch one object if you already know its key.” What it got, because that’s also what the role grants, was “list every key in the bucket.”
Symptom
To let signage devices and browsers fetch ad images with no authentication, the ad_media module bound allUsers to read access on the bucket:
# infrastructure/terraform/modules/ad_media/main.tf (before)
# Public read (object viewing only). Lets signage devices/browsers fetch images with no auth.
resource "google_storage_bucket_iam_member" "public_read" {
count = var.enabled ? 1 : 0
bucket = google_storage_bucket.ad_media[0].name
role = "roles/storage.objectViewer"
member = "allUsers"
}
The comment says “object viewing only,” but what roles/storage.objectViewer actually grants isn’t just storage.objects.get — it also includes storage.objects.list. Bind that role to allUsers and anyone, with no authentication, can call the JSON API’s list endpoint — https://storage.googleapis.com/storage/v1/b/<bucket>/o — and get back every object name in the bucket. The ad creative filenames were keyed by school ID, so while a known URL only reveals one file, list exposed every key at once, including schools’ keys nobody had ever been given a link to.
Cause
The design intent was narrow: writes limited to the deploy/seed principal, and allUsers gets read, specifically read of one object you already know the key for. But the role actually used, roles/storage.objectViewer, is a Google-defined predefined role that bundles both “fetch an object” and “list the bucket’s contents” into a single grant. The granularity of GCS’s predefined role was coarser than the granularity the bucket actually needed, so a line that was meant to say “grant public read” also, inescapably, said “grant public list.”
Fix
The grant to allUsers was replaced with a project custom role containing only storage.objects.get:
# infrastructure/terraform/modules/ad_media/main.tf (after)
resource "google_project_iam_custom_role" "public_object_get" {
count = var.enabled ? 1 : 0
project = var.project_id
role_id = var.public_get_role_id
title = "Ad media public object get"
description = "storage.objects.get only (no list). Bound to allUsers on the public ad-media bucket."
permissions = ["storage.objects.get"]
stage = "GA"
}
resource "google_storage_bucket_iam_member" "public_get" {
count = var.enabled ? 1 : 0
bucket = google_storage_bucket.ad_media[0].name
role = google_project_iam_custom_role.public_object_get[0].name
member = "allUsers"
}
google_project_iam_custom_role creates a role whose only permission is storage.objects.get, and allUsers is bound to that custom role instead of the predefined one. Serving only ever needed “fetch an object whose key you already have,” so the existing GCS URLs (ad media_url values) keep working exactly as before. The list endpoint, on the other hand, now has nothing to grant it to allUsers and rejects the call.
Project custom roles come with their own constraint:
# infrastructure/terraform/modules/ad_media/variables.tf
variable "public_get_role_id" {
type = string
default = "adMediaPublicObjectGet"
description = "role_id for the get-only custom role granted to allUsers (unique within the project; cannot be reused for 37 days after deletion)."
}
role_id must be unique within the project, and once deleted, the same id can’t be reused for 37 days. Keeping the default behind a variable means a role can be recreated under a different name if it ever needs to be torn down and rebuilt.
Prevention
The same commit fixed a second instance of the exact same shape of mistake, this time in the GitHub Actions Workload Identity Federation module.
# infrastructure/terraform/modules/workload_identity_federation/main.tf (before)
resource "google_service_account_iam_binding" "deploy_wif" {
service_account_id = google_service_account.deploy.name
role = "roles/iam.workloadIdentityUser"
members = [local.repo_principal_set]
}
resource "google_service_account_iam_binding" "plan_wif" {
service_account_id = google_service_account.plan.name
role = "roles/iam.workloadIdentityUser"
members = [local.repo_principal_set]
}
The deploy service account’s workloadIdentityUser binding was open to local.repo_principal_set — any OIDC token for the whole repository — even though no workflow actually used that service account (deploys run locally via scripts/deploy/*). Leaving an unused path open meant any collaborator with write access could push a branch workflow and mint credentials for that service account. The binding was removed; only the read-only plan service account still trusts local.repo_principal_set.
# infrastructure/terraform/modules/workload_identity_federation/main.tf (after)
resource "google_service_account_iam_binding" "plan_wif" {
service_account_id = google_service_account.plan.name
role = "roles/iam.workloadIdentityUser"
members = [local.repo_principal_set]
}
Both the objectViewer grant and the repo-wide principalSet binding were the same shape of mistake: taking an official, predefined unit — a role, a trust binding — at face value, without checking that everything it actually grants is something the design intended to grant. Binding a predefined role or a wide principal to a broad target like allUsers or a repo-wide principalSet is now something we check against the role’s actual permission list before applying, not after.
Frequently asked questions
Q1Why does roles/storage.objectViewer also grant list?
Because objectViewer is a predefined role that bundles both storage.objects.get and storage.objects.list. Granting it to allUsers lets anyone who has never seen a single object key call the JSON API's list endpoint and enumerate every object name in the bucket.
Q2What was actually exposed by the listing?
The ad creative filenames were keyed by school ID. A known GET URL only reveals one file, but list exposed every key in the bucket, including other schools' keys that were never shared with anyone.
Q3How was it fixed?
allUsers was moved off roles/storage.objectViewer onto a project custom role (google_project_iam_custom_role) whose permissions are just storage.objects.get. Serving still works off a known key; the list endpoint now rejects allUsers.
Q4Any gotcha with project custom roles?
The role_id must be unique within the project, and once deleted it cannot be reused for 37 days. Keeping the default behind a variable means a role can be recreated under a new name if that ever matters.
Environment verified
- Terraform >= 1.9.0, < 2.0.0 / google provider ~> 6.0 (キミテラス-v2 infrastructure/terraform/envs/prod)
- Found and fixed the same day in a 2026-10-01 fix commit
What this article is based on
- Terraform file lines 1-64commit 9761682
- Terraform file lines 1-82commit f4c36b7
- Terraform file lines 41-45commit f4c36b7
- Terraform file lines 1-15commit 9761682
- Terraform file lines 120-138commit 9761682
- Terraform file lines 1-23commit f4c36b7
- Terraform file lines 128-144commit f4c36b7
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.