~/2026/01/14/python-gcloud-audit-iam-bindings-across-a-gcp-organization.md
Python: gcloud – Audit IAM Bindings Across a GCP Organization
--- author: Tom Lasswell date: read: 4 min in: [scripts, engineering] tags: [python, gcloud] ---
$ grep -n '^#' post.md
IAM sprawl in a GCP organization tends to follow a predictable pattern: someone gets roles/editor on a project during initial setup "just to get it working," and it's never revisited. Multiply that across a few dozen projects under an org and you have a real exposure that no single project's IAM page will ever show you, because allow policies are attached per resource (organization, folder, project, bucket, dataset, and so on). Cloud Asset Inventory's SearchAllIamPolicies API is the one place that answers "who has been granted what, where" across an entire organization in a single paginated call. This script wraps that call, flattens the result into one row per principal-role-resource binding, and flags the things I care about most on a first pass:
- the write-capable basic roles (legacy
roles/ownerandroles/editor, and their newer counterpartsroles/adminandroles/writer) granted to a user or group. Google's own guidance is blunt: basic roles "include thousands of permissions across all Google Cloud services," and in production you shouldn't grant them unless there is no alternative; - public bindings to
allUsersorallAuthenticatedUsers; - users or groups outside your own domain;
deleted:principals, which are grants left behind after the identity was removed and are pure clutter.
Requirements
- Python 3.9 or later.
- The
google-cloud-assetpackage (pip install google-cloud-asset). - Application Default Credentials (
gcloud auth application-default loginfor interactive use, or an attached service account when this runs on a schedule). Avoid a downloaded service account key for this; an org-wide read of every IAM policy is exactly the kind of credential you don't want sitting in a file. cloudasset.assets.searchAllIamPolicieson the organization. The predefined Cloud Asset Viewer role (roles/cloudasset.viewer) granted at the organization node includes it.serviceusage.services.useon the project the calls are billed to (the quota project). The Cloud Asset Inventory docs state that all Cloud Asset Inventory calls require it;roles/serviceusage.serviceUsageConsumeron that project covers it.- The Cloud Asset API (
cloudasset.googleapis.com) enabled on that quota project:gcloud services enable cloudasset.googleapis.com --project=my-audit-project.
Grant the two roles to whoever runs it (swap user: for serviceAccount: for a scheduled job):
gcloud organizations add-iam-policy-binding 123456789012 \
--member='user:auditor@example.com' \
--role='roles/cloudasset.viewer'
gcloud projects add-iam-policy-binding my-audit-project \
--member='user:auditor@example.com' \
--role='roles/serviceusage.serviceUsageConsumer'
Parameters
| Parameter | Required | Description |
|---|---|---|
--organization | Yes | Numeric organization ID (gcloud organizations list shows it). |
--quota-project | Yes | Project ID the API calls are billed and quota-checked against. |
--domain | Yes | Approved domain. Can be repeated (--domain example.com --domain example.org). |
--query | No | Optional Cloud Asset Inventory query, for example memberTypes:user or roles:roles/owner, to narrow the search server-side. |
--output | No | CSV path. Defaults to iam-audit.csv. |
Usage
Before running the script, it's worth knowing that the same API is available from gcloud, which is handy for a single question:
# Every resource where anyone holds roles/owner
gcloud asset search-all-iam-policies \
--scope=organizations/123456789012 \
--query='roles:roles/owner' \
--flatten='policy.bindings[].members[]' \
--format='csv(resource,policy.bindings.role,policy.bindings.members)'
# Everything granted to one person, anywhere in the org
gcloud asset search-all-iam-policies \
--scope=organizations/123456789012 \
--query='policy:"user:alex@example.com"'
The script does the same search without a filter and applies all the checks in one pass. Run it against the whole organization and write a CSV:
python audit_iam_bindings.py \
--organization 123456789012 \
--quota-project my-audit-project \
--domain example.com \
--output iam-audit.csv
Narrow the search server-side to human and group principals only:
python audit_iam_bindings.py --organization 123456789012 --quota-project my-audit-project \
--domain example.com --query 'memberTypes:user OR memberTypes:group'
Sample output (illustrative):
Searching organizations/123456789012 for IAM allow policies ...
Retrieved 1,842 bindings across 96 resources.
basic role to user/group ........ 7
public (allUsers/allAuth) ....... 1
external identity ............... 3
deleted principal ............... 12
Report written to iam-audit.csv
And the CSV rows it produces:
resource,asset_type,project,role,member,condition,flagged,flag_reason
//cloudresourcemanager.googleapis.com/projects/web-prod,cloudresourcemanager.googleapis.com/Project,projects/111111111111,roles/editor,user:jane@example.com,,True,basic role (roles/editor) granted to a user or group
//storage.googleapis.com/public-assets,storage.googleapis.com/Bucket,projects/222222222222,roles/storage.objectViewer,allUsers,,True,public binding (allUsers)
//cloudresourcemanager.googleapis.com/folders/333333333333,cloudresourcemanager.googleapis.com/Folder,,roles/viewer,user:contractor@partner.example,,True,external identity (partner.example)
Script
"""
audit_iam_bindings.py
Audits IAM allow policies across a GCP organization using Cloud Asset
Inventory's SearchAllIamPolicies API. Flattens the results to one row per
principal-role-resource binding and flags:
- basic roles (roles/owner, roles/editor, roles/admin, roles/writer) granted
to users or groups
- public bindings (allUsers, allAuthenticatedUsers)
- users or groups outside the approved domain(s)
- deleted principals left behind in policies
Reads: the allow policies attached to every resource in the organization.
Writes: a CSV report at --output with every binding plus flagged/flag_reason.
Requires roles/cloudasset.viewer on the organization and
serviceusage.services.use on --quota-project.
"""
import argparse
import csv
import sys
from collections import Counter
from google.api_core.client_options import ClientOptions
from google.api_core.exceptions import GoogleAPICallError
from google.cloud import asset_v1
BASIC_ROLES = {"roles/owner", "roles/editor", "roles/admin", "roles/writer"}
PUBLIC_MEMBERS = {"allUsers", "allAuthenticatedUsers"}
FIELDS = ["resource", "asset_type", "project", "role", "member", "condition", "flagged", "flag_reason"]
def parse_args():
parser = argparse.ArgumentParser(description="Audit IAM bindings across a GCP organization.")
parser.add_argument("--organization", required=True, help="Organization ID, e.g. 123456789012")
parser.add_argument("--quota-project", required=True, help="Project ID to bill the API calls against")
parser.add_argument("--domain", required=True, action="append", help="Approved domain; repeatable")
parser.add_argument("--query", default="", help="Optional Cloud Asset Inventory query string")
parser.add_argument("--output", default="iam-audit.csv", help="Path to the output CSV")
return parser.parse_args()
def flag_binding(member, role, approved_domains):
"""Returns (category, reason) for a flagged binding, or (None, "")."""
if member in PUBLIC_MEMBERS:
return "public", f"public binding ({member})"
if member.startswith("deleted:"):
return "deleted", "deleted principal still bound"
is_user_or_group = member.startswith("user:") or member.startswith("group:")
if is_user_or_group and role in BASIC_ROLES:
return "basic", f"basic role ({role}) granted to a user or group"
if is_user_or_group:
domain = member.split(":", 1)[1].split("@")[-1].lower()
if domain not in approved_domains:
return "external", f"external identity ({domain})"
return None, ""
def main():
args = parse_args()
approved = {d.lower() for d in args.domain}
client = asset_v1.AssetServiceClient(
client_options=ClientOptions(quota_project_id=args.quota_project)
)
scope = f"organizations/{args.organization}"
request = asset_v1.SearchAllIamPoliciesRequest(scope=scope, query=args.query, page_size=500)
print(f"Searching {scope} for IAM allow policies ...")
rows = []
counts = Counter()
resources = 0
try:
for result in client.search_all_iam_policies(request=request):
resources += 1
for binding in result.policy.bindings:
condition = binding.condition.title if binding.condition.expression else ""
for member in binding.members:
category, reason = flag_binding(member, binding.role, approved)
if category:
counts[category] += 1
rows.append(
{
"resource": result.resource,
"asset_type": result.asset_type,
"project": result.project,
"role": binding.role,
"member": member,
"condition": condition,
"flagged": bool(category),
"flag_reason": reason,
}
)
except GoogleAPICallError as exc:
print(f"Cloud Asset Inventory call failed: {exc}", file=sys.stderr)
sys.exit(1)
print(f"Retrieved {len(rows):,} bindings across {resources:,} resources.")
print(f" basic role to user/group ........ {counts['basic']}")
print(f" public (allUsers/allAuth) ....... {counts['public']}")
print(f" external identity ............... {counts['external']}")
print(f" deleted principal ............... {counts['deleted']}")
with open(args.output, "w", newline="", encoding="utf-8") as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=FIELDS)
writer.writeheader()
writer.writerows(rows)
print(f"Report written to {args.output}")
if __name__ == "__main__":
main()
Notes
SearchAllIamPoliciesreturns the allow policy attached to each resource, not effective access. The docs spell this out: a search on a project returns principals granted roles on that project and doesn't include principals who inherit access from a folder or the organization. So a binding at the org node shows up once, as a row on the organization resource, not on every project beneath it. To answer "who can actually do X on this resource," including inherited and group-expanded access, use Policy Analyzer:gcloud asset analyze-iam-policy --organization=123456789012 --full-resource-name=//cloudresourcemanager.googleapis.com/projects/web-prod --permissions=resourcemanager.projects.setIamPolicy --expand-groups. Policy Analyzer is quota-limited: more than 20 queries per organization per day needs Security Command Center Premium or Enterprise.- Coverage is limited to searchable asset types: Google notes that not every resource type is available in the search APIs, so check the Cloud Asset Inventory resource types list before treating the report as complete for a given service.
- The results cover allow policies only. IAM deny policies, principal access boundaries, and organization policies can all make a flagged binding less dangerous than it looks, or a clean one more.
page_sizetops out at 500. The client's pager followsnext_page_tokenfor you, so the loop above walks every page without extra code.- Conditional bindings are kept, with the condition's title in the
conditioncolumn. The legacy basic roles can't carry a condition (IAM rejects conditions on Owner, Editor, and Viewer), so a flaggedroles/ownerorroles/editorrow is always unconditional;roles/adminandroles/writercan be conditional, so check that column for them.roles/readerandroles/viewerare basic roles too, but read-only, so the script leaves them unflagged. - Service accounts are deliberately not domain-checked.
serviceAccount:members legitimately live in other projects, including Google-managed service agents (service-PROJECT_NUMBER@gcp-sa-*.iam.gserviceaccount.com). If you want to catch service accounts from projects outside your org, compare the project part of the address with a list fromgcloud projects list. - Group grants are reported as the group, not its members. An external user nested inside an internal group won't be flagged here;
--expand-groupson Policy Analyzer, or a Cloud Identity group membership export, is how you close that gap. Expanding Google Workspace groups in Policy Analyzer needs the Workspacegroups.readpermission (in the Groups Reader admin role) on top of the Cloud IAM roles above. - I diff each run's CSV against the previous one instead of reading the whole report fresh. A new basic-role grant or a new
allUsersrow deserves an immediate look; a binding that hasn't changed since last month usually doesn't.
References
- Search IAM allow policies (Cloud Asset Inventory)
- Cloud Asset Inventory access control
- AssetServiceClient reference (Python)
- Cloud Asset Inventory query syntax
- google-api-core ClientOptions
- Analyze IAM policies (Policy Analyzer)
- Roles and permissions overview (basic role guidance)
- Configure temporary access (conditions and basic roles)