IAM roles bound at project scope that could be resource-scoped
What does ZopNight detect here?
Project-level IAM bindings apply to every current and future resource in the project. ZopNight's RC-1251 flags each such role that could instead be bound on individual resources, and skips 13 non-scopable roles (Owner, billing, org-policy administration) where "move it to resource level" would be impossible advice.
Signal and threshold
| Field | Value |
|---|---|
| Rule IDs | RC-1251 |
| Category | compliance |
| Severity | medium |
| Metric | none — pure configuration read |
| Source | iam_project_level.go |
Project scope is a forward commitment
A role bound at project level does not just cover the resources that existed when someone
clicked Save. It covers every resource anyone creates in that project forever after. Grant
roles/storage.admin on the project because a pipeline needed one bucket, and next
quarter’s new buckets, including the one holding customer exports, are silently included.
That forward-inheriting property is what separates a project binding from the same role on
a single bucket or Cloud SQL instance, and it is why blast-radius reviews treat the two
very differently even when the role name is identical.
The 13 roles the rule refuses to flag
Advice is only useful if it is possible to follow, so the rule maintains an explicit skip
list of roles with no resource-level equivalent. Project Owner, Editor, and Viewer are
excluded here because RC-1250 already flags them with the correct remediation: replace,
not re-scope. A project-wide Owner grant cannot be “moved to a resource”. The four
roles/billing.* roles and the project/organization administration roles
(roles/resourcemanager.projectIamAdmin, roles/resourcemanager.organizationAdmin,
roles/orgpolicy.policyAdmin, roles/iam.organizationRoleAdmin) plus the two
roles/serviceusage.* roles are inherently project- or org-scoped. What remains, the
findings you actually see, are roles like roles/storage.admin or roles/cloudsql.admin
that genuinely could live on the specific resources a member uses.
Narrowing without breaking anything
Add the resource-level binding first, remove the project-level one second, and keep the order strict, since the overlap window is what prevents an outage:
gcloud projects get-iam-policy PROJECT_ID \ --flatten="bindings[].members" \ --format="table(bindings.role, bindings.members)"lists what is bound project-wide today; then, for example,
gcloud storage buckets add-iam-policy-binding gs://BUCKET --member=... --role=... creates
the narrow grant before you delete the broad one. Verify the workload with the narrow grant
under real traffic before removing anything.
Provenance instead of a hardcoded label
Detection rides on how the rows are produced: the discoverer’s project-policy walk is the
only producer of GCP iam-role resources, so the resource type itself proves the binding
is project-level, replacing an older label that was stamped true on every row and
therefore discriminated nothing. The rule then abstains on any role in the skip list. All
evidence is read from the project IAM policy; ZopNight never edits bindings, and the
finding retires on the discovery cycle after the project-level binding disappears.