Skip to main content
Google Cloud · lesson 2 of 10
Google Cloud beginner Lesson 2 of 10

Also part of: Cloud Security

GCP IAM: Roles and Service Accounts

Create a service account, watch it fail with PERMISSION_DENIED, then grant exactly the role it needs — the real workflow behind every GCP access error.

By SkillByExample · Published

gcp iam security service-accounts

Nearly every GCP access problem is the same shape: something authenticates fine, then gets refused. This lesson creates a service account, deliberately hits that refusal, and fixes it — so the pattern is recognizable the next time it shows up on a service you haven’t used before.

Creating a service account

gcloud iam service-accounts create deploy-bot \
    --display-name="Deploy Bot" \
    --description="Used by the CI pipeline to push builds"
Created service account [deploy-bot].
gcloud iam service-accounts list
DISPLAY NAME  EMAIL                                                       DISABLED
Deploy Bot    deploy-bot@skillbyexample-437219.iam.gserviceaccount.com    False

A service account’s identity is that email-shaped string, not a human login. It’s what you’ll grant roles to and what other resources will impersonate.

Hitting PERMISSION_DENIED on purpose

Create a bucket, then try to list its objects while impersonating the brand-new service account, which has no roles yet.

gcloud storage buckets create gs://skillbyexample-assets-437219 --location=us-central1

gcloud storage ls gs://skillbyexample-assets-437219 \
    --impersonate-service-account=deploy-bot@skillbyexample-437219.iam.gserviceaccount.com
Creating gs://skillbyexample-assets-437219/...

ERROR: (gcloud.storage.ls) HTTPError 403: deploy-bot@skillbyexample-437219.iam.gserviceaccount.com does not have storage.objects.list access to the Google Cloud Storage bucket. Permission 'storage.objects.list' denied on resource (or it may not exist).

That 403 is PERMISSION_DENIED — the service account authenticated correctly (GCP knows exactly who it is), it simply has zero IAM bindings, so it holds zero permissions. This is the default state of every new service account: an identity with nothing granted.

Inspecting the policy before touching it

gcloud storage buckets get-iam-policy gs://skillbyexample-assets-437219
etag: ACAB
version: 1

An empty-looking policy — no bindings key at all — confirms nobody but the project’s default owners has any explicit grant on this bucket yet.

Granting the role

gcloud storage buckets add-iam-policy-binding gs://skillbyexample-assets-437219 \
    --member="serviceAccount:deploy-bot@skillbyexample-437219.iam.gserviceaccount.com" \
    --role="roles/storage.objectViewer"
bindings:
- members:
  - serviceAccount:deploy-bot@skillbyexample-437219.iam.gserviceaccount.com
  role: roles/storage.objectViewer
etag: ACACyx8=
version: 1

roles/storage.objectViewer is a predefined role bundling storage.objects.get and storage.objects.list — enough to read, nothing that lets it delete or overwrite. Rerun the same ls command:

gcloud storage ls gs://skillbyexample-assets-437219 \
    --impersonate-service-account=deploy-bot@skillbyexample-437219.iam.gserviceaccount.com
(no output — the bucket is empty, but the call succeeded)

No error this time. The call succeeded and printed nothing because the bucket has no objects yet — an empty result looks identical to a satisfied, boring success, which is the correct outcome for a viewer role hitting an empty bucket.

Reading a policy binding back

gcloud storage buckets get-iam-policy gs://skillbyexample-assets-437219 \
    --format="table(bindings.role, bindings.members)"
ROLE                          MEMBERS
roles/storage.objectViewer    ['serviceAccount:deploy-bot@skillbyexample-437219.iam.gserviceaccount.com']

Practice

1. Try the same impersonated call after granting only roles/storage.objectCreator instead, then attempt to list objects.
gcloud storage buckets remove-iam-policy-binding gs://skillbyexample-assets-437219 \
    --member="serviceAccount:deploy-bot@skillbyexample-437219.iam.gserviceaccount.com" \
    --role="roles/storage.objectViewer"

gcloud storage buckets add-iam-policy-binding gs://skillbyexample-assets-437219 \
    --member="serviceAccount:deploy-bot@skillbyexample-437219.iam.gserviceaccount.com" \
    --role="roles/storage.objectCreator"

gcloud storage ls gs://skillbyexample-assets-437219 \
    --impersonate-service-account=deploy-bot@skillbyexample-437219.iam.gserviceaccount.com
ERROR: (gcloud.storage.ls) HTTPError 403: deploy-bot@skillbyexample-437219.iam.gserviceaccount.com does not have storage.objects.list access to the Google Cloud Storage bucket. Permission 'storage.objects.list' denied on resource (or it may not exist).

objectCreator grants storage.objects.create only — it can upload but not list or read back, proof that roles are additive and narrow, not a single generic “storage access” toggle.

2. List every predefined role whose name mentions "storage".
gcloud iam roles list --filter="name:roles/storage" --format="value(name)"
roles/storage.admin
roles/storage.objectAdmin
roles/storage.objectCreator
roles/storage.objectViewer
roles/storage.legacyBucketReader

Predefined roles are scoped per service and per action shape (admin / creator / viewer) rather than one all-or-nothing role — this is the pattern to expect from every GCP service, not just storage.

3. Revoke the role entirely and confirm the original error comes back.
gcloud storage buckets remove-iam-policy-binding gs://skillbyexample-assets-437219 \
    --member="serviceAccount:deploy-bot@skillbyexample-437219.iam.gserviceaccount.com" \
    --role="roles/storage.objectCreator"

gcloud storage ls gs://skillbyexample-assets-437219 \
    --impersonate-service-account=deploy-bot@skillbyexample-437219.iam.gserviceaccount.com
ERROR: (gcloud.storage.ls) HTTPError 403: deploy-bot@skillbyexample-437219.iam.gserviceaccount.com does not have storage.objects.list access to the Google Cloud Storage bucket. Permission 'storage.objects.list' denied on resource (or it may not exist).

IAM bindings are the only thing standing between “authenticated” and “authorized” — remove the binding and the identity is exactly as powerless as the moment it was created.

4. Check which permissions your own user account has on the project, without granting anything.
gcloud projects get-iam-policy skillbyexample-437219 \
    --flatten="bindings[].members" \
    --filter="bindings.members:you@gmail.com" \
    --format="table(bindings.role)"
ROLE
roles/owner

The account that created a project is bound to roles/owner automatically — full control, including the ability to grant or revoke every other role, which is why production setups avoid doing day-to-day work as the Owner.

5. Delete the service account when it's no longer needed.
gcloud iam service-accounts delete \
    deploy-bot@skillbyexample-437219.iam.gserviceaccount.com --quiet
deleted service account [deploy-bot@skillbyexample-437219.iam.gserviceaccount.com]

Deleting the account also invalidates every key it held immediately — any pipeline still using one starts failing authentication, not authorization, on its very next call.

Next: Compute Engine — creating a VM, opening a firewall port, and connecting over SSH.

Frequently Asked Questions

What is a service account in GCP?
An identity for a program rather than a person — a VM, a Cloud Run service, or a CI pipeline authenticates as a service account instead of a human's Google login. It has its own email-shaped identifier and its own IAM role grants, separate from the user who created it.
What does PERMISSION_DENIED actually mean in GCP?
The caller's identity is valid and authenticated, but no IAM binding grants it the specific permission the API call requires. It is distinct from a 401 (not authenticated at all) — GCP returns PERMISSION_DENIED even to correctly authenticated callers that simply lack the role.
What is the difference between a role and a permission in IAM?
A permission is a single fine-grained action, like storage.objects.get. A role is a named bundle of permissions — basic roles (Owner, Editor, Viewer) are broad and predate IAM, predefined roles like roles/storage.objectViewer are scoped to a service, and custom roles let you assemble an exact permission set.
Why did IAM say the binding worked but the API call still failed?
IAM role grants can take up to a couple of minutes to propagate globally. If a call fails immediately after gcloud reports the binding added, wait and retry before assuming the grant is wrong — this is the most common false alarm in IAM debugging.
Should I grant roles to a service account at the project level or a narrower level?
Narrower when possible. IAM bindings can attach at the organization, folder, project, or individual-resource level, and permissions inherit downward. Granting roles/storage.objectViewer on one bucket instead of roles/storage.admin on the whole project limits the blast radius if the service account's key ever leaks.