GCP, IAM & Security

GCP IAM least privilege in practice: groups, roles, service accounts

How I structure Google Cloud IAM so people get access through groups, workloads get one keyless service account each, and every grant is small and auditable.

Published

Updated

—

Reading time

13 min

Open the IAM page of almost any Google Cloud project that is two years old and you will find the same story. Forty individual email addresses, a third of them with Editor. Two former contractors who still have Owner. The Compute Engine default service account holding Editor and running half the workloads. A JSON key for a "deploy" service account living in three CI systems and at least one laptop. None of it was a bad decision at the time. It is the sum of a hundred small "just give them access" moments. This article is for platform engineers, tech leads and solo builders who want to reach the opposite state and stay there: access that is granted to groups, scoped to the smallest resource that works, carried by keyless identities, and cheap to audit.

Constraints#

The design has to survive real conditions, not a greenfield diagram:

  • People join, change teams and leave. Access must follow the role, not the person, and removal must be one action.
  • Workloads need identities. Services, jobs and CI pipelines all call Google APIs. Each needs credentials that cannot leak the way a file can.
  • Developers must stay fast. If requesting access takes a week, people route around the system with shared credentials.
  • Audits happen. Someone will ask "who can read the production database password?" and you need an answer in minutes, not days.
  • Cost matters, even for security. IAM itself is free. The logging and some of the analysis tooling around it are not.

How IAM actually evaluates access#

Two facts drive everything else. First, the resource hierarchy: organization, folders, projects, then resources such as buckets, secrets and services. An allow policy set at any level is inherited by everything beneath it, and the effective policy on a resource is the union of its own policy and all of its ancestors'. Second, allow policies are purely additive. You cannot use an allow policy on a project to take away a role granted on its folder. Only IAM deny policies subtract, and they are a separate mechanism.

The consequence is blunt: a grant high in the tree is a grant everywhere below it, including projects that do not exist yet. Least privilege in GCP is mostly about granting low.

Policies flow down the tree. Grants land as low as possible: groups on folders, workloads on individual resources.

Options for granting access#

ApproachOffboardingBlast radiusAudit effortTypical failure
Basic roles (Owner, Editor, Viewer) to individual usersPer project, per userHuge: Editor can change almost everythingHigh: every project differsFormer staff and contractors keep access
Predefined roles to individual users on projectsPer project, per userMediumHighRole sprawl, nobody knows why a grant exists
Predefined roles to Google Groups on folders or projectsOne group membership changeMediumLowGrants a bit too broad for some members
Predefined or custom roles to groups at the lowest level, one keyless service account per workload, resource-level and time-bound grantsOne group change; workloads unaffectedSmallLow with toolingMore resources to manage; needs infrastructure as code

And the role types themselves, since the choice matters as much as where you bind them:

Role typeExampleUse it forAvoid because
Basicroles/editorThrowaway sandboxes onlyThousands of permissions across nearly every service
Predefinedroles/run.developer, roles/secretmanager.secretAccessorThe default choiceOccasionally broader than a job needs
Customprojects/app-prod/roles/appDeployerWhen no predefined role is close enoughYou maintain it forever; new permissions are not added for you

The decision#

I use the last row of the first table. Humans get access only through Google Groups, bound at the folder or project that matches their job, with predefined roles by default. Every workload gets its own service account, attached to the runtime, never with a downloaded key. Sensitive resources such as secrets and buckets get resource-level bindings instead of project-wide ones. Elevated human access is time-bound. An organization policy makes the keyless rule impossible to break by accident, and a small set of queries tells me at any time who can do what.

Implementation#

The commands below use gcloud. Replace ORG_ID, folder IDs, project IDs and example.com with your own values.

1. Shape the hierarchy before you grant anything#

Folders are the cheapest security control you have. Put production in its own folder so that production-wide grants can never leak into development projects, and so a single org policy or group binding can cover every production project, including future ones.

terminalbash
gcloud resource-manager folders create --display-name="prod" --organization=ORG_ID
gcloud resource-manager folders create --display-name="non-prod" --organization=ORG_ID
 
gcloud projects create app-prod --folder=PROD_FOLDER_ID
gcloud projects create app-staging --folder=NONPROD_FOLDER_ID

Split projects by environment and by trust boundary. A project is the unit most people reason about, and a service that holds payment data deserves a project where not everybody has read access.

2. Grant to groups, never to users#

Create groups that describe jobs, not people: platform-admins@, engineers@, oncall@, billing-viewers@. Cloud Identity Free is enough to host them if you do not use Google Workspace. Membership lives in the directory; IAM only ever sees the group.

terminalbash
# Group creation and membership (Cloud Identity); the admin console works too
gcloud identity groups create engineers@example.com \
  --organization=example.com --display-name="Engineers"
gcloud identity groups memberships add \
  --group-email=engineers@example.com --member-email=alice@example.com
 
# Bind predefined roles to the group at the folder level
for role in roles/run.viewer roles/logging.viewer roles/monitoring.viewer; do
  gcloud resource-manager folders add-iam-policy-binding NONPROD_FOLDER_ID \
    --member="group:engineers@example.com" --role="$role"
done

Offboarding is now a single membership removal, and "who has access to staging?" becomes "who is in these three groups?", which your identity provider already answers.

3. Choose the smallest role that does the job#

Before granting a predefined role, read what it contains:

terminalbash
gcloud iam roles describe roles/run.developer --format="yaml(includedPermissions)"

If the closest predefined role is still too broad, and the gap matters, write a custom role. A CI deployer for Cloud Run, for example, needs to update services and read revisions, not delete services or change IAM:

terminalbash
gcloud iam roles create appDeployer --project=app-prod \
  --title="App deployer" \
  --description="Deploy new revisions to existing Cloud Run services" \
  --stage=GA \
  --permissions=run.services.get,run.services.update,run.revisions.get,run.revisions.list,run.operations.get

Custom roles can be defined at the organization or project level, not on folders, and they do not evolve when Google adds permissions to a product. Keep the number small and keep them in code.

4. One service account per workload#

Every Cloud Run service, job, function and pipeline gets its own service account, named after the workload. The runtime identity of the api service is not the identity that deploys it, and neither is the identity of the nightly export job.

terminalbash
gcloud iam service-accounts create api-runtime --project=app-prod \
  --display-name="Runtime identity for the api service"
 
gcloud run deploy api --project=app-prod --region=europe-west1 \
  --image=europe-west1-docker.pkg.dev/app-prod/containers/api:1.42.0 \
  --service-account=api-runtime@app-prod.iam.gserviceaccount.com

Stop using default service accounts. Older organizations automatically granted Editor to the Compute Engine default service account; the org policy constraint iam.automaticIamGrantsForDefaultServiceAccounts prevents that, and the managed constraint iam.managed.preventPrivilegedBasicRolesForDefaultServiceAccounts also blocks granting it later by hand.

The least-known escalation path in GCP sits here. Anyone who can act as a service account inherits its permissions, and roles/iam.serviceAccountUser granted at the project level lets its holder act as every service account in that project. Grant it on the specific service account instead:

terminalbash
gcloud iam service-accounts add-iam-policy-binding \
  api-runtime@app-prod.iam.gserviceaccount.com \
  --member="serviceAccount:ci-deployer@app-prod.iam.gserviceaccount.com" \
  --role="roles/iam.serviceAccountUser"

5. Never download a service account key#

A JSON key is a long-lived bearer credential in a file. It gets copied into CI secrets, pasted into chat, committed by mistake, and it does not expire by default. Nearly every workload has a keyless alternative: attached service accounts for anything running on Google Cloud, Workload Identity Federation for CI systems and other clouds, and gcloud auth application-default login for humans on laptops.

For GitHub Actions, federation takes three commands (I cover the full pipeline setup in keyless deploys from GitHub Actions). I keep the pool in a dedicated security or CI project rather than in each application project:

terminalbash
gcloud iam workload-identity-pools create ci-pool \
  --project=ci-identity --location=global --display-name="CI"
 
gcloud iam workload-identity-pools providers create-oidc github \
  --project=ci-identity --location=global --workload-identity-pool=ci-pool \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" \
  --attribute-condition="assertion.repository_owner == 'example-org'"
 
gcloud iam service-accounts add-iam-policy-binding \
  ci-deployer@app-prod.iam.gserviceaccount.com \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/CI_PROJECT_NUMBER/locations/global/workloadIdentityPools/ci-pool/attribute.repository/example-org/api"

The attribute condition is not optional: without it, any GitHub repository could present a token to your pool. The binding then narrows impersonation to one repository. In the workflow, grant id-token: write and use google-github-actions/auth with the provider's full resource name; no secret is stored anywhere.

Then make the rule structural with an organization policy. Organizations created on or after 3 May 2024 enforce key creation and upload blocks by default; older ones should enable them explicitly:

disable-sa-keys.yamlyaml
name: organizations/ORG_ID/policies/iam.managed.disableServiceAccountKeyCreation
spec:
  rules:
    - enforce: true
terminalbash
gcloud org-policies set-policy disable-sa-keys.yaml

If a vendor truly requires a key, grant a narrow exception on that single project with a tag or a project-level policy override, and write down why.

6. Bind sensitive resources at the resource level#

Granting roles/secretmanager.secretAccessor on a project lets the holder read every secret in it, including ones created next year. Bind it on the secret instead:

terminalbash
gcloud secrets add-iam-policy-binding db-password --project=app-prod \
  --member="serviceAccount:api-runtime@app-prod.iam.gserviceaccount.com" \
  --role="roles/secretmanager.secretAccessor"
 
gcloud storage buckets add-iam-policy-binding gs://app-prod-exports \
  --member="serviceAccount:nightly-export@app-prod.iam.gserviceaccount.com" \
  --role="roles/storage.objectCreator"

The api service can read one secret. The export job can write objects to one bucket but cannot read or delete them. When either is compromised, the damage stops there.

7. Make elevated access expire#

Standing admin access is the grant that hurts most when an account is phished. IAM conditions let a binding carry an expiry:

terminalbash
gcloud projects add-iam-policy-binding app-prod \
  --member="group:oncall@example.com" \
  --role="roles/run.admin" \
  --condition='expression=request.time < timestamp("2026-10-07T18:00:00Z"),title=incident-4711,description=Expires at end of incident window'

After the timestamp the binding stays in the policy but grants nothing; clean it up in your next Terraform run. For a managed just-in-time flow with approvals and justification, look at Privileged Access Manager, which grants and revokes these bindings for you.

8. Put it in Terraform, with non-authoritative resources#

Every grant above belongs in code, reviewed like any other change. The Google provider offers three flavours per resource type, and the difference is the most common way teams lock themselves out:

  • google_*_iam_member adds one member to one role and leaves everything else alone. Use this.
  • google_*_iam_binding owns the full member list for one role. Anything added outside Terraform is removed on the next apply.
  • google_*_iam_policy owns the entire policy. One mistake removes every other grant, including your own.
iam.tfhcl
resource "google_service_account" "api_runtime" {
  project      = var.project_id
  account_id   = "api-runtime"
  display_name = "Runtime identity for the api service"
}
 
resource "google_secret_manager_secret_iam_member" "api_db_password" {
  project   = var.project_id
  secret_id = google_secret_manager_secret.db_password.secret_id
  role      = "roles/secretmanager.secretAccessor"
  member    = "serviceAccount:${google_service_account.api_runtime.email}"
}
 
resource "google_folder_iam_member" "engineers_nonprod" {
  for_each = toset([
    "roles/run.viewer",
    "roles/logging.viewer",
    "roles/monitoring.viewer",
  ])
  folder = "folders/${var.nonprod_folder_id}"
  role   = each.value
  member = "group:engineers@example.com"
}
 
resource "google_project_iam_member" "oncall_breakglass" {
  project = var.project_id
  role    = "roles/run.admin"
  member  = "group:oncall@example.com"
 
  condition {
    title       = "incident-4711"
    description = "Expires at end of incident window"
    expression  = "request.time < timestamp(\"2026-10-07T18:00:00Z\")"
  }
}

The Terraform runner itself should authenticate through federation too, with a service account that holds only the IAM admin roles it needs on the folders it manages.

9. Audit continuously, not annually#

Start with the policy on a single project, flattened so each row is one member and one role:

terminalbash
gcloud projects get-iam-policy app-prod \
  --flatten="bindings[].members" \
  --format="table(bindings.role, bindings.members, bindings.condition.title)"

That shows direct grants only. For the whole organization, including inherited and resource-level bindings, use Cloud Asset Inventory:

terminalbash
# Every place a basic Owner role is granted
gcloud asset search-all-iam-policies --scope=organizations/ORG_ID \
  --query='policy:roles/owner'
 
# Every binding that names an individual user instead of a group
gcloud asset search-all-iam-policies --scope=organizations/ORG_ID \
  --query='memberTypes:user'
 
# Who can read secret values in app-prod, through any path
gcloud asset analyze-iam-policy --organization=ORG_ID \
  --full-resource-name="//cloudresourcemanager.googleapis.com/projects/app-prod" \
  --permissions="secretmanager.versions.access"

The last command is Policy Analyzer, and it answers the auditor's question directly, expanding groups and inheritance. Next, ask the IAM recommender which grants go unused. It observes up to 90 days of permission usage and suggests removals or smaller roles:

terminalbash
gcloud recommender recommendations list --project=app-prod \
  --location=global --recommender=google.iam.policy.Recommender \
  --format="table(description, stateInfo.state)"

Finally, watch changes as they happen. Every SetIamPolicy call is recorded in Admin Activity audit logs, which are always on:

terminalbash
gcloud logging read 'protoPayload.methodName="SetIamPolicy"' \
  --project=app-prod --freshness=30d \
  --format="table(timestamp, protoPayload.authenticationInfo.principalEmail, protoPayload.resourceName)"

Route that filter to a log-based alert or a sink so that a grant made outside Terraform pages someone the same day.

CostWhat least privilege costs

IAM, groups, conditions and org policies are free. Admin Activity audit logs are free and always on. Data Access audit logs, which record who read data, are off by default for most services and are billed as Cloud Logging ingestion; at the time of writing that is a free allotment per project per month, then a per-GiB charge listed on the Cloud Logging pricing page. Enable them selectively, for example on Secret Manager in production, not everywhere. For the recommender, basic-role recommendations are free at every level; recommendations for predefined roles and for resource-level grants require Security Command Center Premium or Enterprise, according to the role recommendations overview.

Trade-offs and failure modes#

  • More identities to manage. One service account per workload means dozens of accounts. Without Terraform and a naming convention, that turns into its own mess. The payoff is that every log line names exactly which workload did something.
  • Resource-level grants are harder to see. A secret-level binding does not appear in gcloud projects get-iam-policy. Anyone auditing only project policies will miss it. Use Asset Inventory searches, not the console's project IAM page, as your source of truth.
  • Custom roles drift. When a product adds a permission your workflow needs, a custom role breaks silently until someone notices a 403. Prefer predefined roles and document every custom one.
  • Propagation is not instant. Grants and revocations usually take effect within minutes, occasionally longer. Do not build automation that grants a role and uses it in the same second, and do not assume a revocation is effective the moment the command returns.
  • Authoritative Terraform resources lock people out. A single google_project_iam_policy applied from a stale state has removed Owner from the people who could have fixed it. Use _member resources and keep an out-of-band break-glass group at the organization level.
  • Additive inheritance surprises people. Removing a role from a project does nothing if the same principal holds it on the folder. Always check ancestors, which is precisely what Policy Analyzer does for you.
  • Groups hide membership. A binding to engineers@ is only as tight as the group's membership. Review group owners and memberships with the same rigour as IAM policies.

Checklist

  • Production lives in its own folder; nobody holds broad roles at the organization level except a tiny break-glass group
  • Every human grant targets a Google Group, never an individual user
  • No basic roles outside personal sandboxes
  • Each workload has its own service account; default service accounts are unused and hold no Editor role
  • roles/iam.serviceAccountUser is granted on specific service accounts, never on projects
  • Org policy blocks service account key creation and upload; CI uses Workload Identity Federation with an attribute condition
  • Secrets and buckets are bound at the resource level
  • Elevated human access is time-bound through IAM conditions or Privileged Access Manager
  • All grants live in Terraform using _member resources
  • Asset Inventory searches for roles/owner and memberTypes:user run on a schedule
  • An alert fires on SetIamPolicy changes made outside the pipeline

When not to do this#

For a personal sandbox project that holds no real data and will be deleted next month, the full structure is overhead; one project, your own account as Owner and a billing budget is fine. Even there, keep the one rule that costs nothing: do not download service account keys. And if your organization already runs a central landing zone with a platform team, do not build a parallel hierarchy. Fit your projects into their folders and groups, and spend your effort on the resource-level grants and workload identities they cannot see from above.

Share
All articles →

Early-stage systems rarely go broke on traffic. They bleed money while idle. How to design a GCP stack whose cost stays near zero when no one is using it.

13 min