GCP IAM: Roles, Permissions & Testing
Permissions vs Roles
Permission — one atomic capability, maps 1:1 to an API method:
storage.objects.get
│ │ └─ verb
service resource
Role — a named bag of permissions. That is all it is.
You cannot assign permissions directly to a member. Only roles can be bound. If no predefined role has exactly the permissions you need → create a custom role.
# What permissions does a role contain?
gcloud iam roles describe roles/storage.objectViewer
# What roles contain a specific permission?
gcloud iam list-testable-permissions \
//cloudresourcemanager.googleapis.com/projects/MY_PROJECT \
| grep "storage.objects.get"
Basic vs Predefined vs Custom
| Basic | Predefined | Custom | |
|---|---|---|---|
| Also called | Primitive / legacy | Curated | Custom |
| Maintained by | Google (frozen) | Google (auto-updated) | You |
| Scope | Cross-service (entire project) | One service, one job function | Exactly what you list |
| New API perms auto-added? | Yes ← danger | Yes | No — manual updates required |
| When to use | Throwaway sandboxes only | Default choice (95% of cases) | When predefined is still too broad |
Basic roles — avoid in prod
roles/viewer, roles/editor, roles/owner — apply to everything in the project. editor alone carries thousands of permissions across all services.
Predefined roles — your default
Naming convention (loose, not enforced):
roles/<service>.<resource><Level>
Common suffixes and what they mean:
| Suffix | Meaning |
|---|---|
Viewer | Read-only |
Editor / dataEditor | Read + write |
Admin | Full control including IAM on the resource |
Creator | Create new, not manage existing |
User | Use without managing |
Invoker | Trigger/call (Cloud Run, Cloud Functions) |
JobUser | Submit jobs (BigQuery, Dataflow) |
These are not a formal standard — each service team named their own roles. Some don't follow the pattern at all (roles/iam.serviceAccountTokenCreator, roles/cloudsql.client).
BigQuery needs two roles together in practice:
bigquery.dataEditor(data access) +bigquery.jobUser(run queries/pay for them). Neither alone is sufficient.
Custom roles — least-privilege precision
Build by trimming a predefined role's permission list rather than from scratch:
# Dump a predefined role, trim it, create custom from it
gcloud iam roles describe roles/storage.objectAdmin --format=yaml > role.yaml
# edit role.yaml — remove unwanted permissions
gcloud iam roles create trimmedStorage --project=MY_PROJECT --file=role.yaml
Or create directly:
gcloud iam roles create dataIngester \
--project=MY_PROJECT \
--title="Data Ingester" \
--permissions="storage.objects.get,storage.objects.list,bigquery.tables.create,bigquery.tables.updateData" \
--stage="GA"
Custom roles don't auto-update — when Google adds new permissions to a service, predefined roles get them, yours does not.
Decision flow
Need to grant access?
│
├─ Throwaway sandbox / solo lab?
│ └─ Basic role is fine
│
├─ Does a predefined role match the job function?
│ └─ YES → use it ← 95% of cases
│
└─ Predefined too broad for compliance / least-privilege?
└─ Custom role — accept the maintenance cost
Testing IAM Without Creating Resources
IAM itself is free. Creating service accounts, bindings, and custom roles costs $0. You only pay for compute resources.
Policy Simulator (Console)
Test "if I set this policy, would X be able to do Y?" without granting anything.
console.cloud.google.com/iam-admin/simulator
Input: principal + proposed policy → output: allow/deny + which binding caused it.
test-iam-permissions (CLI)
Check what a member can do on a resource right now:
gcloud storage buckets test-iam-permissions gs://my-bucket \
--permissions="storage.objects.get,storage.objects.list" \
--member="user:alice@theodo.com"
gcloud projects test-iam-permissions MY_PROJECT \
--permissions="bigquery.tables.create" \
--member="serviceAccount:sa@MY_PROJECT.iam.gserviceaccount.com"
Read-only exploration (no side effects)
# What predefined roles exist for a service?
gcloud iam list-predefined-roles --filter="name:storage"
# What roles does a member currently have on a project?
gcloud projects get-iam-policy MY_PROJECT \
--flatten="bindings[].members" \
--filter="bindings.members:alice@theodo.com" \
--format="table(bindings.role)"
# Preview a policy change before applying
gcloud projects get-iam-policy MY_PROJECT --format=yaml > policy.yaml
# edit locally, inspect, then:
gcloud projects set-iam-policy MY_PROJECT policy.yaml
Throwaway project (safest sandbox)
gcloud projects create pr01-iam-lab --organization=732586063639
# experiment freely — IAM ops are free
gcloud projects delete pr01-iam-lab # wipes everything when done
Service Accounts — Instance Identity
Are VMs attached to a service account by default?
Yes. Every Compute Engine instance gets an identity unless you explicitly remove it — the Compute Engine default service account:
PROJECT_NUMBER-compute@developer.gserviceaccount.com
It's auto-created the moment the Compute Engine API is enabled (nobody asks for it), and new VMs attach to it automatically.
| Instance identity by default? | Construct | Model | |
|---|---|---|---|
| GCP | Yes — default SA auto-attached | Service account | Opt-out |
| AWS | No — none until attached | IAM Instance Profile (role) | Opt-in |
| Azure | No — none until enabled | Managed Identity | Opt-in |
Coming from AWS/Azure this is the surprise: there, an instance has no cloud identity until you deliberately attach one. In GCP, assume an identity is always attached and make sure it's the least-privilege one you chose — not the broad default.
The legacy Editor grant
Historically, enabling Compute Engine also auto-granted the default SA
roles/editor at the project level — one binding, inherited by every
VM using that SA (access always follows identity + binding, never "same
project = access").
roles/editor → bound to → PROJECT_NUMBER-compute@developer.gserviceaccount.com
| Behavior | |
|---|---|
Org policy iam.automaticIamGrantsForDefaultServiceAccounts not enforced (older orgs) | New default SAs still auto-get roles/editor |
| Constraint enforced (new orgs, default since May 2024) | No auto-grant for newly created default SAs |
| Projects created before the change | Still carry the legacy Editor grant — enforcing the constraint later does not retroactively remove it |
Check a project for the legacy grant:
gcloud projects get-iam-policy MY_PROJECT \
--flatten="bindings[].members" \
--filter="bindings.role:roles/editor AND bindings.members:*-compute@developer.gserviceaccount.com" \
--format="table(bindings.role,bindings.members)"
The access-scope gotcha (why cloud-platform scope is dangerous with the default SA)
A VM's effective permissions = IAM role ∩ access scope (legacy scope model). Two settings, both must allow it:
| VM scope setting | Effect |
|---|---|
| "Allow default access" (narrow legacy scopes: read-only storage, logging/monitoring write) | Even with Editor bound to the SA, the VM itself can't exercise most of it |
cloud-platform scope ("Allow full access to all Cloud APIs") | Scope stops limiting anything — effective access becomes the full IAM role |
Dangerous combo: default SA + legacy Editor grant + cloud-platform
scope → every VM in the project can act as project-wide Editor from inside
the guest OS. This combo is easy to reach by accident (cloud-platform scope
is the standard Terraform recommendation for a properly scoped SA — it only
becomes a problem paired with an over-privileged identity).
Why a dedicated SA per workload, not the default
| Default SA | Dedicated SA | |
|---|---|---|
| Identity | Shared across every VM using it | Unique per workload/tier |
| Permissions | Whatever's bound project-wide (often legacy Editor) | Only what you explicitly grant |
| Blast radius if one VM is compromised | Entire project | Just what that SA can do |
| Audit trail | Every call logs as the same shared identity | Logs show which workload did what |
| Enables SA-based firewalling / resource restriction | No — tiers are indistinguishable if they share an SA | Yes — this is the prerequisite |
| Tightening perms for one workload | Impossible without affecting every VM sharing the SA | Change that SA's bindings only |
Concrete failure: an app VM gets RCE'd while running as the default SA with
Editor + cloud-platform scope → attacker can read every bucket, tamper with
Pub/Sub, create/delete Compute resources, touch BigQuery, project-wide, from
one shell. A dedicated app-sa with only roles/storage.objectViewer on one
bucket caps the damage to exactly that.
Pattern: grant a resource to some VMs, not all
Access to any resource (e.g. a GCS bucket) is always
VM's attached SA → IAM role binding on the resource — project membership
grants nothing by itself.
All VMs need access → share one SA, bind the role once:
resource "google_storage_bucket_iam_member" "vms_read" {
bucket = google_storage_bucket.data.name
role = "roles/storage.objectViewer"
member = "serviceAccount:${google_service_account.vm.email}"
}
Only some VMs need access → dedicated SA, attached only to those VMs:
resource "google_service_account" "bucket_reader" {
account_id = "bucket-reader"
}
resource "google_storage_bucket_iam_member" "reader" {
bucket = google_storage_bucket.data.name
role = "roles/storage.objectViewer"
member = "serviceAccount:${google_service_account.bucket_reader.email}"
}
resource "google_compute_instance" "privileged" {
# ...
service_account {
email = google_service_account.bucket_reader.email
scopes = ["cloud-platform"] # safe here: the SA itself is least-privilege
}
}
Every other VM, running as a different (non-bound) SA, has no access — the restriction is enforced by which SA is attached, not network placement.
Network access (firewall rules) and resource authorization (IAM bindings) are separate planes, but both key off the same SA identity — see Networking & Firewalls: AWS vs Azure vs GCP for the service-account firewalling side of this pattern.