Part 3 of 840 min read · 7 diagramsAI-assisted

IAM & Identity

.mdPDF

Assumes you're comfortable with Part 1's resource hierarchy and policy inheritance — this chapter is where "who can touch what" gets a full, precise answer.

Meridian Logistics — the fictional freight-tracking company running throughout this course — and its platform team (Priya, Devon, Ana) are introduced in Part 1 if you're picking this course up mid-series.

Table of Contents#

  1. What This Chapter Covers
  2. The Core IAM Vocabulary: Principals, Roles, Bindings, Policies
  3. Principals: Google Accounts, Groups, Domains, and Federated Identities
  4. Primitive, Predefined, and Custom Roles
  5. Reading and Writing IAM Policies
  6. IAM Conditions — Context-Aware Access
  7. Allow Policies and Deny Policies — Org Policy's Sharper Cousin
  8. Service Accounts — GCP's Workload Identity
  9. Service Account Keys — Why They're Risky, and When They're Still Used
  10. Service Account Impersonation — Short-Lived Access Without Keys
  11. Workload Identity Federation for External Workloads
  12. Workload Identity Federation for GKE — A First Look
  13. A Full Worked IAM Design for Meridian Logistics
  14. Designing Break-Glass Access
  15. Auditing IAM: Policy Analyzer, Recommender, and Audit Logs
  16. Real-World Scenario: The Incident Response Drill
  17. Chapter Recap: How the Pieces Connect
  18. Pre-Flight Checklist: Is This IAM Design Actually Safe?
  19. Common Mistakes and Interview Traps
  20. Worked Practice Problems
  21. Summary and What's Next

What This Chapter Covers#

IAM answers exactly one question — "can this principal perform this action on this resource, right now?" — and this chapter builds up every piece that answer depends on: what counts as a principal, how roles bundle permissions, how a binding connects the two, how conditions add context, and how a workload (not a human) gets its own identity without a permanent secret sitting in a file somewhere.

🎯 By the end of this chapter, you'll design an IAM structure where every grant is scoped to the narrowest resource and role that does the job, every workload authenticates without a long-lived key, and you can explain — precisely, not just intuitively — why a given access request is allowed or denied.

A pattern worth naming upfront, because it recurs across nearly every mistake this chapter walks through: almost every IAM failure in this chapter traces back to a grant that was correct the day it was made and simply never revisited — a departed contractor's binding, a custom role that stopped matching its workload, a "temporary" exception with no expiration. IAM design isn't a one-time setup task with a correct final state; it's an ongoing practice of granting narrowly, expiring deliberately, and auditing on a schedule. Keep that framing in mind as a lens for every mechanism below, not just the closing auditing section.

The Core IAM Vocabulary: Principals, Roles, Bindings, Policies#

Four terms, used precisely for the rest of this chapter and this course:

TermDefinition
PrincipalThe "who" — a human, a group, a service account, or a federated external identity
PermissionA single, fine-grained capability (compute.instances.create, storage.objects.get) — never granted directly, only through a role
RoleA named bundle of permissions (roles/compute.admin bundles dozens of compute.* permissions)
BindingThe connection: "this role, granted to this principal, at this resource"
PolicyThe full set of bindings attached to one resource (project, folder, org)
Diagram

A permission is never granted alone — it only ever reaches a principal bundled inside a role, and a role only ever reaches a resource through an explicit binding.

Principals: Google Accounts, Groups, Domains, and Federated Identities#

GCP recognizes several distinct kinds of principal, each with a different lifecycle and a different right way to grant it access:

Principal typeExampleGrant it access when...
Individual Google Accountuser:priya@meridianlogistics.comRarely — see the group guidance below
Google Groupgroup:platform-team@meridianlogistics.comAlmost always, for human access — adding/removing a person means updating group membership, not IAM policy
Google Workspace domaindomain:meridianlogistics.comVery rarely — grants every account in the domain at once, an extremely broad blast radius
Service accountserviceAccount:gps-worker@meridian-shipment-prod.iam.gserviceaccount.comFor any workload — an application, a script, a CI/CD pipeline — that needs to call GCP APIs
Federated identity (Workforce/Workload Identity Federation)principalSet://.../workforcePools/...An external human or workload identity source, covered later in this chapter and Part 1
# Grant a role to an individual — the pattern this chapter argues against by default
gcloud projects add-iam-policy-binding meridian-shipment-prod \
  --member="user:priya@meridianlogistics.com" \
  --role="roles/compute.admin"

# Grant a role to an ENTIRE Google Group — the strongly recommended
# pattern for human access
gcloud projects add-iam-policy-binding meridian-shipment-prod \
  --member="group:platform-team@meridianlogistics.com" \
  --role="roles/compute.admin"

Tip

Best practice: grant IAM roles to groups, never individuals, for anything but a genuine one-off exception. Onboarding and offboarding become a Workspace group-membership change instead of an IAM-policy audit across every project a person ever touched — and a security review answering "who can do X" only has to read group memberships, not reconcile individual grants scattered across dozens of resources.

A Group Naming Convention That Scales#

A group named simply platform-team reads clearly today, at three people; it stops scaling the moment Meridian needs different levels of access within that same team, or a group scoped to one specific system rather than one whole team. Meridian's actual convention, adopted before that ambiguity became a real problem:

<team>-<scope>-<level>@meridianlogistics.com platform-all-admin@meridianlogistics.com # full platform team, broad admin platform-gps-oncall@meridianlogistics.com # GPS pipeline on-call rotation specifically data-bigquery-readonly@meridianlogistics.com # data team, read-only BigQuery access break-glass-admins@meridianlogistics.com # the emergency-access group covered later in this chapter

Tip

Best practice: name IAM-bound groups by scope and level, not just by team. A generic <team>-team group is fine for a team's first few months, but retrofitting a naming convention onto dozens of existing IAM bindings later is far more painful than starting with one — decide the convention before the second group gets created, not after the tenth.

From the Trenches: The Departed Employee Who Kept Access for Six Months#

A former Meridian contractor's individual IAM grant (roles/storage.objectViewer on a project, added directly to their personal account during a one-week task eighteen months earlier) was still active six months after their contract ended, discovered only during the IAM Policy Analyzer audit described later in this chapter. The immediate cause was an offboarding checklist that covered Workspace account deactivation but never cross-checked individual IAM grants scattered across projects; the deeper cause was that the grant existed as an individual binding in the first place, invisible to any process built around group membership. Had the original grant gone to a group instead — even a group with just that one contractor in it temporarily — removing them from the group during offboarding would have revoked the access as a side effect of a process that already existed, rather than requiring a dedicated IAM audit to even discover the gap.

Primitive, Predefined, and Custom Roles#

Three tiers of roles exist, and the ACE exam (along with real production judgment) expects you to know exactly why the top tier should almost never be used.

Diagram

Primitive roles predate GCP's granular IAM system and were never redesigned to be narrow — they're a legacy compatibility layer, not a recommended starting point.

TierWhat it isWhen to reach for it
Primitive (roles/owner, roles/editor, roles/viewer)Extremely broad, pre-dating fine-grained IAMAlmost never in production — Editor alone grants create/modify access across nearly every service
Predefined (roles/compute.admin, roles/pubsub.publisher, hundreds more)Google-maintained, scoped to one service or a coherent slice of oneThe default choice for almost every real grant
CustomYou define the exact permission setWhen no predefined role matches the actual need — too broad on one axis, too narrow on another
# Create a custom role with an exact, narrow permission bundle —
# reach for this only after confirming no predefined role already fits
gcloud iam roles create gpsWorkerPublisher \
  --project=meridian-shipment-prod \
  --title="GPS Worker Publisher" \
  --description="Exactly the permissions the GPS ingestion worker needs" \
  --permissions=pubsub.topics.publish,pubsub.topics.get \
  --stage=GA

Warning

A custom role is not maintained by Google — if a new permission is added to a service that the role's intent should logically cover, it doesn't automatically appear; you own keeping it current. This is a real, ongoing cost, not a one-time setup task — weigh it against a predefined role that's slightly broader before committing to a custom one for something that isn't genuinely a bad fit.

Realistic Scenario: The Custom Role Nobody Updated#

Meridian created a custom role two years ago for a legacy reporting job with exactly the BigQuery permissions it needed at the time. When the job was later extended to also write results to Cloud Storage, the developer assumed the existing custom role — clearly labeled "reporting job permissions" — would just work, and spent an afternoon debugging a permission-denied error before realizing the custom role's permission list hadn't been touched since creation and simply didn't include any storage.objects.create permission. The fix was quick once diagnosed; the actual lesson was procedural: any custom role needs an owner and a review trigger tied to the workload it serves, the same discipline this course keeps returning to for anything "temporary" or "set once" — a custom role with no owner drifts out of sync with the workload it was built for exactly as easily as an org-policy exception does.

IAM Terminology Map#

The same cross-provider habit from Parts 1 and 2 applies here — IAM is where the terminology gap between clouds is widest, because the underlying models genuinely differ, not just the names:

ConceptGCPAWSAzure
Identity for a workloadService accountIAM role (assumed by a resource)Managed identity
Bundle of permissionsRole (primitive/predefined/custom)Policy (managed/inline)Role definition
Connecting identity to permissionsBindingPolicy attachmentRole assignment
Federated external identityWorkload Identity FederationIAM Identity Provider (OIDC/SAML)Federated credential
Hard "no" regardless of grantsDeny policySCP with an explicit Deny statementAzure Policy deny effect

The mapping breaks down most on service accounts vs. IAM roles: an AWS IAM role has no independent existence outside being assumed — it's purely a set of permissions a workload temporarily takes on. A GCP service account is a real, persistent identity (with its own email address, its own IAM policy governing who can act as it) that permissions are granted to directly, closer in shape to an AWS IAM user than an AWS IAM role, despite service accounts being GCP's role-equivalent mechanism in practice. This is a genuine source of confusion porting AWS-shaped IAM designs onto GCP, worth stating explicitly rather than assuming the terms map cleanly.

Reading and Writing IAM Policies#

# View the current IAM policy on a resource — the full JSON/YAML of
# every binding attached directly to it (not counting inheritance)
gcloud projects get-iam-policy meridian-shipment-prod

# Grant one role to one principal — the common case
gcloud projects add-iam-policy-binding meridian-shipment-prod \
  --member="group:platform-team@meridianlogistics.com" \
  --role="roles/compute.admin"

# Remove a specific binding
gcloud projects remove-iam-policy-binding meridian-shipment-prod \
  --member="user:former-contractor@meridianlogistics.com" \
  --role="roles/storage.objectViewer"

A policy is a list of bindings, each with a role and a list of members — worth seeing the actual shape once, since add-iam-policy-binding/remove-iam-policy-binding are really just read-modify-write helpers around this same structure:

bindings:
  - role: roles/compute.admin
    members:
      - group:platform-team@meridianlogistics.com
  - role: roles/pubsub.publisher
    members:
      - serviceAccount:gps-worker@meridian-shipment-prod.iam.gserviceaccount.com
etag: BwXhqLd7fN8=
version: 1

Important

The etag field exists specifically to prevent a lost-update race: set-iam-policy (the underlying primitive add/remove-iam-policy-binding both wrap) fails if the etag doesn't match the policy's current state, meaning someone else's concurrent change would otherwise silently be overwritten. Always fetch the current policy immediately before modifying it programmatically, rather than reusing an old cached copy — the same "read fresh state right before you write" discipline this project's own engineering rules apply to any shared state.

IAM Conditions — Context-Aware Access#

A condition attaches a boolean expression to a binding, so the grant only applies when the expression evaluates true — a real narrowing mechanism beyond "this role, this principal, this resource," evaluated against request attributes like time, resource name pattern, or origin.

# Grant temporary access that automatically expires — no manual
# cleanup step required, unlike a plain binding a human has to remember to remove
gcloud projects add-iam-policy-binding meridian-shipment-prod \
  --member="user:contractor@external-firm.com" \
  --role="roles/cloudsql.admin" \
  --condition="expression=request.time < timestamp('2026-12-01T00:00:00Z'),title=contractor-access,description=Expires end of engagement"
# The same condition, in the raw policy shape
- role: roles/cloudsql.admin
  members:
    - user:contractor@external-firm.com
  condition:
    title: contractor-access
    description: Expires end of engagement
    expression: request.time < timestamp("2026-12-01T00:00:00Z")

Tip

Best practice: use a time-bound condition for every genuinely temporary grant instead of a plain binding plus a calendar reminder to remove it. This directly solves the exact "temporary access that quietly became permanent" pattern that showed up twice already in this course (Part 1's region-lock exception, Part 2's parallel billing account) — a condition-bound grant expires on its own, with no human remembering step required at all.

Beyond time, conditions can match resource name patterns (grant access only to resources whose name starts with a given prefix) or request origin — genuinely useful for a "this role, but only for staging-prefixed resources in a shared project" scenario, though Part 1's project-per-environment design means Meridian rarely needs this specific pattern since environments already have separate projects.

# A resource-based condition: grant broad Cloud Storage access, but
# only to buckets whose name carries the reporting team's own prefix
gcloud projects add-iam-policy-binding meridian-shipment-prod \
  --member="group:data-team@meridianlogistics.com" \
  --role="roles/storage.admin" \
  --condition="expression=resource.name.startsWith('projects/_/buckets/meridian-reporting-'),title=reporting-buckets-only"

This is the pattern that would matter if Meridian ever consolidated multiple teams' storage into one shared project — a resource-name condition narrows a role that would otherwise apply project-wide down to exactly the buckets a given team owns, without needing a separate project per team for every case where environment-per-project (Part 1's default) doesn't naturally apply.

From the Trenches: The Condition That Silently Never Matched#

Priya once wrote a time-bound condition intending to expire a contractor's access at the end of a specific day, but used the contractor's local time zone offset directly in the timestamp() literal without converting to UTC — the expression evaluated correctly syntactically, so gcloud accepted it with no error, but the actual expiration landed eight hours earlier than intended because IAM evaluates request.time in UTC internally. The access expired mid-afternoon on what the contractor experienced as still their last working day, triggering a confused "why can't I access anything" message rather than a security problem — a lucky direction for the bug to fail in, since the alternative (an off-by-eight-hours timestamp that granted access longer than intended) would have been a real, silent over-grant nobody would have noticed without deliberately checking. The fix, and the team's standing rule since: always write condition timestamps in explicit UTC (Z suffix) and never in a reader's assumed local time, verified once with gcloud iam policy troubleshooting tools rather than trusted from a first read of the expression.

Least Privilege in Practice: Building a Role From Actual Usage#

Designing a custom role's permission list from first principles ("what does this workload probably need") is a reasonable starting guess, but IAM Recommender (covered fully later in this chapter) closes the loop by comparing a granted role's full permission set against what a principal actually calls over a real observation window:

# After gps-worker has run in production for two weeks with a
# reasonably broad starting role, check what it actually used
gcloud recommender insights list \
  --project=meridian-shipment-prod \
  --insight-type=google.iam.policy.Insight \
  --location=global \
  --filter="targetResources:gps-worker"

Ana's team found that gps-worker's original roles/pubsub.editor grant (assigned because it "seemed safely broad enough" during initial setup) only ever exercised two permissions in three weeks of real production traffic: pubsub.topics.publish and pubsub.subscriptions.consume. The Recommender's suggested narrower custom role dropped topic/subscription creation and deletion permissions the worker never used and never should have held — a worker that only publishes and consumes messages has no legitimate reason to be able to delete the topic it depends on.

Tip

Best practice: start a new workload with a reasonably-scoped predefined role, then let two to four weeks of real IAM Recommender data — not a first-principles guess alone — inform whether a narrower custom role is worth the maintenance cost this chapter already warned custom roles carry. Guessing the exact permission set upfront either over-scopes (the common failure) or under-scopes (breaking the workload on day one) — observed usage is a strictly better signal than either.

Allow Policies and Deny Policies — Org Policy's Sharper Cousin#

A deny policy is IAM's own hard "no," evaluated before any allow policy and impossible for an allow grant — at any level — to override, distinct from the org policy constraints Part 1 covered (org policies restrict what resources can exist/be configured; deny policies restrict what identities can ever do, full stop, regardless of any role they hold).

# Deny EVERYONE except a specific break-glass group from deleting
# Cloud SQL instances in production — even a project Owner is blocked
gcloud iam policies create sql-delete-deny-policy \
  --attachment-point=projects/meridian-shipment-prod \
  --kind=denypolicies \
  --policy-file=sql-delete-deny.yaml
# sql-delete-deny.yaml
displayName: "Deny Cloud SQL deletion except break-glass"
rules:
  - denyRule:
      deniedPrincipals:
        - "principalSet://goog/public:all"
      exceptionPrincipals:
        - "group:break-glass-admins@meridianlogistics.com"
      deniedPermissions:
        - "cloudsql.googleapis.com/instances.delete"

By this point in the course, three distinct guardrail mechanisms exist across two chapters — worth a single table disambiguating them, since exam questions and real design reviews alike often conflate them:

MechanismControlsEvaluated against
Org policy (Part 1)What resources can exist or be configured (regions, public IPs, key creation)The resource being created/modified, regardless of who's asking
IAM allow policy (this chapter, most of it)What a specific principal can doThe principal making the request
IAM deny policy (this section)A hard "never," for specific permissions, regardless of any roleThe permission being invoked, overriding any allow grant

Important

A deny policy is the right tool specifically for "no identity should ever be able to do X, no matter what role they're later granted" — a genuinely different guarantee from carefully scoping roles, since a scoping mistake (accidentally granting too-broad a role) is exactly the kind of error a deny policy is designed to survive. Reserve it for the handful of truly catastrophic actions (deleting production data stores, disabling audit logging) where "we scoped roles carefully" isn't a strong enough guarantee on its own.

Service Accounts — GCP's Workload Identity#

A service account is an identity for a workload, not a human — the same conceptual role an IAM role serving an application plays in AWS, though the underlying mechanics differ meaningfully.

# Create a service account — one per distinct workload, never one
# broad "everything" service account shared across unrelated systems
gcloud iam service-accounts create gps-worker \
  --project=meridian-shipment-prod \
  --display-name="GPS Ingestion Worker"

# Grant the SERVICE ACCOUNT (as an identity) a role on a resource —
# it's a principal like any other, receiving roles the same way
gcloud pubsub topics add-iam-policy-binding gps-pings \
  --member="serviceAccount:gps-worker@meridian-shipment-prod.iam.gserviceaccount.com" \
  --role="roles/pubsub.subscriber"

# Grant a HUMAN the ability to ACT AS this service account — a
# separate, distinct grant from any role the service account itself holds
gcloud iam service-accounts add-iam-policy-binding \
  gps-worker@meridian-shipment-prod.iam.gserviceaccount.com \
  --member="user:devon@meridianlogistics.com" \
  --role="roles/iam.serviceAccountUser"

A service account is simultaneously a principal (it can be granted roles) and a resource (IAM policies can be attached to it, controlling who can use or manage it) — this dual nature is worth internalizing early, since it's exactly why "granting a role to a service account" and "granting a human the ability to act as a service account" are two completely separate bindings, easy to conflate.

Diagram

Devon needing serviceAccountUser doesn't give him any of the permissions gps-worker holds directly — it only lets him act through that identity, which then uses its own separately-granted permissions.

From the Trenches: One Service Account for Everything#

An earlier iteration of Meridian's setup (before Priya joined) used a single default-compute service account for every workload in a project — the GPS-ingestion workers, a batch reporting job, and an internal admin tool all shared one identity. When the batch reporting job needed temporary elevated BigQuery access for a one-time backfill, granting it to the shared service account silently gave that same elevated access to the GPS workers and the admin tool too, since they were indistinguishable to IAM. The immediate fix was splitting into per-workload service accounts (gps-worker@, reporting-batch@, admin-tool@), each granted only what its specific workload needs — the deeper lesson, consistent with this chapter's group-not-individual guidance for humans, is that a shared identity always means a shared blast radius, whether the identity belongs to a person or a workload.

Service Account Keys — Why They're Risky, and When They're Still Used#

A service account key is a downloadable JSON file containing a private key that authenticates as that service account indefinitely, from anywhere, until explicitly revoked — genuinely useful in a narrow set of cases, and a real liability everywhere else.

# Create a key — deliberately shown so you recognize it, not as a
# recommendation to reach for this by default
gcloud iam service-accounts keys create gps-worker-key.json \
  --iam-account=gps-worker@meridian-shipment-prod.iam.gserviceaccount.com

# List existing keys — a real audit worth running periodically
gcloud iam service-accounts keys list \
  --iam-account=gps-worker@meridian-shipment-prod.iam.gserviceaccount.com

# Revoke a key immediately if it's ever exposed
gcloud iam service-accounts keys delete KEY_ID \
  --iam-account=gps-worker@meridian-shipment-prod.iam.gserviceaccount.com
PropertyWhy it's risky
No built-in expirationWorks forever until someone remembers to revoke it — the exact "temporary became permanent" failure mode this chapter keeps returning to
PortableWorks from any machine that has the file — a leaked key (committed to a repo, left in a laptop backup) is usable by anyone who finds it, with no origin check
Invisible usageA key being actively misused looks identical, from GCP's perspective, to legitimate use by whoever holds the file

An org policy from Part 1 (constraints/iam.disableServiceAccountKeyCreation) exists specifically to prevent teams from creating keys at all — Meridian enforces this org-wide, with a narrowly-scoped exception process for the rare legitimate case (an on-premises system with no supported federation path, say) rather than leaving key creation open by default and hoping engineers choose not to use it.

Caution

If a service account key file is ever committed to a repository, treat it as compromised immediately — revoke it (not just delete the commit; git history retains it) and rotate to a new key or, better, migrate that workload to impersonation or Workload Identity Federation as covered next. GitHub's own secret scanning specifically detects this exact JSON key shape because it's a common, high-impact leak pattern.

If You Genuinely Can't Avoid a Key: Rotation as a Compensating Control#

For the narrow, org-policy-exempted legitimate case (an on-premises system with no viable federation path), automatic rotation is the compensating control that limits how long a leaked key stays useful even if it's never detected as leaked at all:

# An org policy limiting how OLD a key is allowed to get before
# it's flagged — doesn't rotate automatically, but makes stale
# keys visible for a scheduled rotation process to act on
gcloud resource-manager org-policies set-policy key-max-age-policy.yaml \
  --project=meridian-shipment-prod
# key-max-age-policy.yaml
constraint: constraints/iam.serviceAccountKeyExpiryHours
listPolicy:
  allowedValues:
    - "2160"  # 90 days, expressed in hours

Note

A key-age constraint doesn't rotate the key for you — it caps how long a key is usable at all before GCP itself starts rejecting it, forcing whoever owns that legacy system to have a real rotation process rather than a key that silently works forever. Pair it with a calendar-scheduled task to generate and deploy the replacement key before the old one expires, not a reactive scramble the day it stops working.

Service Account Impersonation — Short-Lived Access Without Keys#

Impersonation lets an already-authenticated principal (a human with their own login, or another service account) temporarily act as a different service account, receiving a short-lived token instead of a permanent key file.

# A human, already authenticated via gcloud auth login, temporarily
# acts as gps-worker for a single debugging command — no key file
# ever created or downloaded
gcloud pubsub topics list \
  --impersonate-service-account=gps-worker@meridian-shipment-prod.iam.gserviceaccount.com

# Generate a short-lived access token directly, for use in a script
# that calls the REST API without the gcloud CLI wrapping it
gcloud auth print-access-token \
  --impersonate-service-account=gps-worker@meridian-shipment-prod.iam.gserviceaccount.com

The permission required is the same roles/iam.serviceAccountTokenCreator role (or the broader serviceAccountUser shown earlier, which is a superset) — the practical difference from a key file is that the resulting token expires automatically (one hour by default) and every impersonation event is itself logged in Cloud Audit Logs, giving a real, attributable trail of who acted as the service account and when, something a shared key file can never provide.

Tip

Best practice: default to impersonation for any human occasionally needing to act as a service account (debugging, a one-off administrative task), and reserve actual service-account-native credentials (the metadata-server identity covered in Part 4, or Workload Identity Federation below) for genuinely automated workloads that run unattended. A human never needs a permanent credential for a service account — impersonation covers every legitimate human use case with a strictly better security posture.

Choosing Between Keys, Impersonation, and Federation#

Three ways now exist to authenticate as a service account, covered across this and the next two sections — worth a single decision table before going deeper into federation specifically:

MechanismCredential lifetimeWho/what uses itReach for it when...
Service account keyIndefinite, until manually revokedLegacy systems with no federation supportGenuinely last resort — see the org-policy exemption process above
ImpersonationShort-lived (1 hour default), generated on demandA human already authenticated via their own loginAny occasional human need to act as a service account
Workload Identity FederationShort-lived, generated per request via token exchangeAn automated workload with its own native identity (CI/CD, another cloud, GKE)Any unattended, automated workload — the default choice for new automation

The common thread across the two recommended mechanisms: neither one ever creates a long-lived GCP secret that has to be stored, rotated, or protected as its own artifact — the credential is generated fresh, short-lived, and tied to an identity GCP can already verify independently (a human's own login, or an external platform's own token issuer).

Workload Identity Federation for External Workloads#

Workload Identity Federation lets a workload running outside GCP — a CI/CD pipeline, a workload in AWS or on-premises — exchange its own platform's native identity for a short-lived GCP token, impersonating a service account without ever holding a GCP key.

Diagram

The trust boundary is GitHub's own OIDC issuer — GCP verifies a token GitHub already signed, rather than GCP issuing and managing a separate long-lived secret GitHub would have to store.

# Set up the pool and provider once — the trust relationship to
# GitHub Actions' own OIDC issuer
gcloud iam workload-identity-pools create github-actions-pool \
  --project=meridian-shipment-prod --location=global

gcloud iam workload-identity-pools providers create-oidc github-provider \
  --workload-identity-pool=github-actions-pool --location=global \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \
  --attribute-condition="assertion.repository=='meridian-logistics/shipment-api'"

# Allow that specific repository's federated identity to impersonate
# the CI service account — scoped to exactly one repo, not "any GitHub repo"
gcloud iam service-accounts add-iam-policy-binding \
  terraform-ci@meridian-shipment-prod.iam.gserviceaccount.com \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/847213590482/locations/global/workloadIdentityPools/github-actions-pool/attribute.repository/meridian-logistics/shipment-api"
# .github/workflows/deploy.yml — the CI side of the same flow,
# authenticating via federation instead of a stored secret
- uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: 'projects/847213590482/locations/global/workloadIdentityPools/github-actions-pool/providers/github-provider'
    service_account: 'terraform-ci@meridian-shipment-prod.iam.gserviceaccount.com'

Important

The --attribute-condition scoping to one specific repository is not optional polish — it's the actual security boundary. Without it, any repository under Meridian's GitHub organization could authenticate through the same provider and impersonate terraform-ci, since the provider trusts GitHub's issuer generically unless a condition narrows exactly which tokens it accepts. This is a genuinely common misconfiguration: setting up federation correctly but leaving the attribute condition too broad, which looks secure (no key file exists!) while still granting far more access than intended.

Devon's team migrated the CI/CD pipeline in Part 2's drift-detection workflow to this exact pattern — the pipeline never held a GCP credential of any kind, closing the exact "downloaded key file for local Terraform development" mistake flagged in Part 2's own common-mistakes table, extended to the automated pipeline as well as human workstations.

Workload Identity Federation for GKE — A First Look#

GKE has its own, closely related mechanism — also called Workload Identity Federation for GKE — that lets a Kubernetes Pod's own service account impersonate a GCP service account, so application code running inside a cluster never handles a GCP credential directly either.

# Bind a Kubernetes service account to a GCP service account —
# Part 5's GKE chapter covers the full cluster-level setup this
# depends on; this is the IAM half of that binding
gcloud iam service-accounts add-iam-policy-binding \
  gps-worker@meridian-shipment-prod.iam.gserviceaccount.com \
  --role="roles/iam.workloadIdentityUser" \
  --member="serviceAccount:meridian-shipment-prod.svc.id.goog[gps-ingestion/gps-worker-ksa]"

The pattern is conceptually identical to the CI/CD federation above — an external identity (here, a Kubernetes ServiceAccount inside a specific namespace) exchanges its own token for impersonation of a GCP service account — just scoped to GKE's own identity system rather than an external OIDC provider. Part 5 picks this up in full once GKE clusters themselves are covered.

Note

A naming history worth knowing, because older documentation and job postings alike still use the earlier term: what's now called "Workload Identity Federation for GKE" was originally just "Workload Identity" (no "Federation" in the name) when GCP first introduced it, years before the same underlying token-exchange concept was generalized into the external-workload federation mechanism covered in the previous section. They're related — both let a non-human identity exchange a token for GCP access without a key — but "Workload Identity" alone, in an older doc or a job description written a few years back, refers specifically to this GKE-scoped mechanism, not the newer general-purpose external federation. If a resource or exam question uses the bare older term, read it as this section's GKE-specific mechanism unless context says otherwise.

A Full Worked IAM Design for Meridian Logistics#

Putting every mechanism in this chapter together — this is Meridian's actual current IAM layout for the production project:

Diagram

Every arrow into the production project is either a scoped group grant, a single-purpose workload identity, or the deny policy that overrides all of them for one specific catastrophic action — no individual human accounts, no shared service accounts, no unscoped primitive roles anywhere in the picture.

# 1. Human access via groups only, narrowly scoped predefined roles
gcloud projects add-iam-policy-binding meridian-shipment-prod \
  --member="group:platform-team@meridianlogistics.com" \
  --role="roles/compute.admin"
gcloud projects add-iam-policy-binding meridian-shipment-prod \
  --member="group:data-team@meridianlogistics.com" \
  --role="roles/bigquery.dataEditor"

# 2. One service account per workload, no shared identities
gcloud iam service-accounts create gps-worker --project=meridian-shipment-prod
gcloud iam service-accounts create shipment-api --project=meridian-shipment-prod
gcloud iam service-accounts create terraform-ci --project=meridian-shipment-prod

# 3. Each service account granted only what its specific workload needs
gcloud pubsub topics add-iam-policy-binding gps-pings \
  --member="serviceAccount:gps-worker@meridian-shipment-prod.iam.gserviceaccount.com" \
  --role="roles/pubsub.subscriber"
gcloud projects add-iam-policy-binding meridian-shipment-prod \
  --member="serviceAccount:terraform-ci@meridian-shipment-prod.iam.gserviceaccount.com" \
  --role="roles/editor" --condition=None  # scoped further by deny policy, shown below

# 4. A deny policy protecting the one truly catastrophic action —
# even terraform-ci's broad Editor role can't delete the orders database
gcloud iam policies create prod-db-delete-deny \
  --attachment-point=projects/meridian-shipment-prod \
  --kind=denypolicies --policy-file=prod-db-delete-deny.yaml

# 5. No service account keys anywhere — enforced by org policy from Part 1
gcloud resource-manager org-policies describe-effective \
  constraints/iam.disableServiceAccountKeyCreation \
  --project=meridian-shipment-prod

# 6. Every human/CI credential path goes through impersonation or
# Workload Identity Federation, never a downloaded key

Note

Step 3's broad roles/editor grant to terraform-ci looks like it contradicts this chapter's own least-privilege guidance — and it's worth sitting with that discomfort for a moment rather than accepting the justification immediately, since a healthy IAM review should always push back on a broad grant first and require the justification to earn it, not the other way around. In isolation, the grant would be a real problem. Meridian accepts it specifically because step 4's deny policy makes the one truly catastrophic action (deleting the production database) impossible regardless of what any role grants, turning "audit every permission this role could theoretically use" into a narrower, more tractable "confirm the deny policy actually covers every catastrophic action" review. This is a deliberate defense-in-depth trade-off, not an oversight — a team newer to GCP should still default to scoping the role itself narrowly first, and reach for this pattern only once they understand exactly what a deny policy does and doesn't cover.

Designing Break-Glass Access#

Break-glass access is a deliberately-provisioned emergency path around normal access controls, for the specific scenario where following the normal process would make an active incident worse — a real, sanctioned exception, not a loophole, and every deny policy in this chapter has referenced one via exceptionPrincipals without yet explaining how that group itself should be designed.

Design requirementWhy it matters
A dedicated group, never an individual's personal accountThe same group-not-individual reasoning as ordinary access, doubled for something this sensitive — membership should be auditable and rotatable
Heavily alerted on use, not just loggedBreak-glass access being used should page someone in real time — silent logging alone means nobody notices until a later audit
Time-boxed membership, not standingA person is added to the break-glass group only when a genuine emergency starts, and removed immediately after — standing membership defeats the point of treating it as exceptional
Tested via drills, same as the incident-response drill aboveAn emergency path nobody has actually exercised is a theoretical control, not a working one
# Alert on ANY use of the break-glass group's credentials — this is
# the control that actually makes break-glass safe to have at all
gcloud logging read \
  'protoPayload.authenticationInfo.principalEmail:"break-glass-admins@meridianlogistics.com"' \
  --project=meridian-shipment-prod

# A log-based metric feeding a real-time alert on any break-glass
# activity, wired into the same on-call paging Part 2's budget
# alerts already use
gcloud logging metrics create break-glass-access-used \
  --description="Any API call authenticated as the break-glass group" \
  --log-filter='protoPayload.authenticationInfo.principalEmail:"break-glass-admins@meridianlogistics.com"'

Warning

A break-glass mechanism with no usage alerting is worse than not having one at all — it creates a false sense of security (an "emergency path exists") while actually functioning as a standing, unmonitored privilege escalation route. The alerting requirement isn't optional hardening; it's the thing that makes the exception legitimate rather than a backdoor.

Auditing IAM: Policy Analyzer, Recommender, and Audit Logs#

Three tools close the loop on everything this chapter built, each answering a different question:

ToolQuestion it answers
IAM Policy Analyzer (Part 1's Cloud Asset Inventory feature)"Who can currently do X to resource Y, accounting for all inheritance?"
IAM Recommender"Which grants does this principal hold but never actually use?"
Cloud Audit Logs"Who did what, and when, in the past?"
Security Command Center (Part 5 of the Security Engineering course covers this fully)"What IAM misconfigurations exist right now, ranked by severity?" — a continuous posture-monitoring layer on top of the point-in-time tools above

Meridian doesn't yet run Security Command Center's paid tier — the three point-in-time tools above cover their current quarterly-review cadence adequately at their current scale — but it's worth knowing exists as the natural next step once ad-hoc quarterly reviews stop being frequent enough for a growing attack surface, a decision Course 5 (GCP Security Engineering) covers in depth.

# IAM Recommender surfaces over-provisioned grants based on actual
# usage history — the tool that found the departed contractor's
# unused grant from this chapter's earlier scenario
gcloud recommender recommendations list \
  --project=meridian-shipment-prod \
  --recommender=google.iam.policy.Recommender \
  --location=global

# Cloud Audit Logs — who granted this role, and when?
gcloud logging read \
  'protoPayload.methodName="SetIamPolicy" AND resource.type="project"' \
  --project=meridian-shipment-prod --limit=20

Tip

Best practice: run IAM Recommender as a standing quarterly review, not just reactively after an incident. Meridian's departed-contractor scenario earlier in this chapter was discovered by a one-off ad-hoc audit — a scheduled quarterly Recommender review (the same discipline as Part 2's scheduled cost queries) would have caught it within three months of the contract ending, not eighteen.

From the Trenches: The Audit Log Question That Took a Day, Then Took a Minute#

Before adopting a regular Policy Analyzer review, answering "who granted roles/compute.admin to the departed contractor's account, and when" during Meridian's post-incident review meant manually paging through the project's Activity log in the console, sorting by date, and cross-referencing timestamps against the contractor's known engagement dates — the better part of a day for one question. The same question today is a single filtered Cloud Audit Logs query, because the underlying data was always there — Cloud Audit Logs records every SetIamPolicy call automatically, with no configuration needed for Admin Activity logs specifically (Data Access logs, covered in Part 6's security material, are the ones that need explicit enablement). The immediate cause of the day-long investigation wasn't missing data, it was not knowing the query existed; the deeper fix was documenting the exact gcloud logging read pattern shown above in the team's own incident-response runbook, so the next investigation starts from a known-good query instead of a console click-through.

# The exact runbook query Meridian now keeps on hand: every IAM
# policy change on a project, over a specific window, with the
# acting identity clearly shown
gcloud logging read \
  'protoPayload.methodName="SetIamPolicy" AND resource.type="project" AND timestamp>="2026-01-01T00:00:00Z"' \
  --project=meridian-shipment-prod \
  --format="table(timestamp, protoPayload.authenticationInfo.principalEmail, protoPayload.methodName)"

Real-World Scenario: The Incident Response Drill#

Meridian's platform team runs a quarterly "assume a credential is compromised" drill — deliberately not a real incident, but treated with the same urgency, specifically to find out whether the IAM design in this chapter actually supports fast revocation under pressure, not just clean design on paper. The drill scenario: gps-worker's credentials are assumed leaked, and the team has to cut off its access as fast as possible while keeping the actual production ingestion pipeline running through a hot failover to a freshly-created replacement identity.

Diagram

Disabling the service account (not just removing its bindings) is the actual fast-stop lever — a disabled service account can't authenticate at all, even if some binding was missed during the rushed revocation.

The first drill run took fourteen minutes end-to-end and surfaced a real gap: gps-worker still held a stale binding on an old, since-decommissioned Pub/Sub topic that nobody remembered to clean up, discovered only when the on-call engineer ran a full Policy Analyzer query to confirm every binding was actually gone rather than trusting memory of what had been granted. This is exactly why the drill exists as a genuine exercise rather than a tabletop discussion — the stale binding would have been invisible without actually attempting the revocation and verifying it against reality, the same "verify, don't assume" discipline Part 1 applied to org-policy inheritance with describe-effective.

# The verification step that caught the gap — never trust "I revoked
# the bindings I remember granting," always check the full current
# policy against the resource
gcloud asset analyze-iam-policy \
  --organization=847213590482 \
  --full-resource-name="//iam.googleapis.com/projects/meridian-shipment-prod/serviceAccounts/gps-worker@meridian-shipment-prod.iam.gserviceaccount.com" \
  --permissions="*"

Tip

Best practice: run a genuine access-revocation drill on a schedule, not just document the theoretical procedure. A runbook that's never been executed under time pressure reliably has gaps that only surface during a real incident — Meridian's stale-binding discovery cost fourteen minutes during a drill; the same gap during an actual credential compromise would have cost real exposure time instead.

Chapter Recap: How the Pieces Connect#

Diagram

A reminder from earlier in this course: mindmap diagrams stay uncolored deliberately — mermaid's mindmap renderer doesn't reliably support classDef styling.

Pre-Flight Checklist: Is This IAM Design Actually Safe?#

  • Every human grant is on a group, never an individual account, except a documented, rare exception
  • No primitive role (Owner/Editor/Viewer) granted anywhere without a specific, understood reason
  • Every service account is scoped to exactly one workload — no shared "does everything" identity
  • Service account key creation is blocked by org policy; any existing keys have a documented, reviewed justification
  • CI/CD pipelines authenticate via Workload Identity Federation, never a stored key file
  • Every genuinely temporary grant uses an IAM condition with an expiration, not a manual-removal reminder
  • A deny policy protects the handful of truly catastrophic, irreversible actions
  • IAM Recommender and Policy Analyzer are reviewed on a standing schedule, not only after an incident
  • Break-glass access (if it exists) is time-boxed, alerted on use, and has been exercised in a drill at least once
  • Group naming follows a consistent, scoped convention before the second or third group is ever created
  • Every access-revocation runbook has been executed as a real drill, not just written and filed away

Common Mistakes and Interview Traps#

MistakeWhy it happensThe fix
Granting IAM roles to individuals instead of groupsFeels faster for a one-off needUse a group even for a single current member — onboarding/offboarding becomes membership management
Using a primitive role because "it definitely has enough permissions"Predefined roles feel like more research to find the right oneThe research cost is worth it — Editor alone is broad enough to be a real security incident waiting to happen
Downloading a service account key "just to test something locally"It's the fastest path to a working credential in the momentUse impersonation (--impersonate-service-account) — it needs no key file and expires automatically
Assuming serviceAccountUser grants the service account's own permissions to the human holding itConflating "can act as" with "inherits the permissions of"serviceAccountUser only allows acting through the identity — the service account's own separately-granted permissions are what's actually used
Setting up Workload Identity Federation without a narrow --attribute-conditionThe setup works and looks secure without itAn unscoped condition trusts the whole external issuer generically — always scope to the exact repository/workload that should be trusted
Treating a deny policy as a substitute for scoping roles narrowlyIt feels like a stronger, simpler guaranteeDeny policies are a backstop for catastrophic actions specifically — narrow role scoping is still the primary defense for everything else
Standing (always-on) membership in a break-glass groupIt's convenient to always have emergency access readyMembership should be time-boxed to an actual active emergency, added and removed deliberately — standing membership defeats the purpose of treating it as exceptional
A break-glass mechanism with no usage alertingLogging feels sufficient since the activity is technically recordedSilent logging alone means nobody notices real-time misuse — wire break-glass activity to the same paging as any other critical alert

Worked Practice Problems#

1. A new engineer needs temporary access to debug a Cloud SQL instance in staging for exactly one week. What's the single best mechanism from this chapter, and why does it beat both a plain IAM binding and a calendar reminder?

An IAM condition with a request.time < expiration bound to one week from now. A plain binding requires someone to remember to remove it — exactly the "temporary became permanent" failure this course has shown repeatedly (Part 1's policy exception, Part 2's billing account, this chapter's departed-contractor scenario). A calendar reminder is a process control, not a technical one — it depends on a human actually acting on it. A time-bound condition expires automatically regardless of whether anyone remembers, removing the human dependency entirely.

2. Devon's CI/CD pipeline currently authenticates using a downloaded service account key stored as a GitHub Actions secret. What's the migration path to Workload Identity Federation, and what's the one configuration detail that determines whether the migration actually improves security or just moves the same risk?

The migration path: create a Workload Identity Pool and an OIDC provider trusting GitHub Actions' own token issuer, grant the target service account's roles/iam.workloadIdentityUser to a principalSet representing the federated identity, update the workflow to use google-github-actions/auth@v2 with the provider instead of a stored key, then revoke the old key. The detail that actually determines the security improvement is the --attribute-condition scoping the trust to exactly the intended repository (and ideally branch/environment) — without it, the provider trusts any token GitHub's issuer signs for the whole organization, which can be broader than the single key file it replaced, technically removing a stored secret while not actually narrowing who can authenticate as that service account.

3. Meridian wants to guarantee that even a compromised terraform-ci service account credential — however it authenticates — can never delete the production Cloud SQL instance, regardless of what IAM role it's granted now or in the future. Which mechanism from this chapter provides that guarantee, and why doesn't careful role-scoping alone provide it?

A deny policy targeting cloudsql.googleapis.com/instances.delete, scoped to deny by default with a narrow break-glass exception principal. Careful role-scoping reduces the chance of an overly broad grant but doesn't provide a guarantee against a future mistake — a role change six months from now, made by someone unfamiliar with the original scoping decision, could reintroduce the exact permission being protected against. A deny policy is evaluated independently of whatever role a principal holds, so it keeps blocking the action even if a future IAM change accidentally grants it — the guarantee comes from deny policies being a separate enforcement layer, not from disciplined role design alone (which is still the right primary defense for everything else, per this chapter's own common-mistakes guidance).

4. gps-worker's IAM Recommender data over three weeks shows it only ever uses pubsub.topics.publish and pubsub.subscriptions.consume, out of the much broader roles/pubsub.editor role it currently holds. Should Meridian narrow this immediately based on three weeks of data, and what's the risk of narrowing too early?

Not immediately without more consideration — three weeks might not cover every legitimate code path the workload can take (a monthly reconciliation job, an error-handling branch that creates a dead-letter topic only during a rare failure mode, a deploy-time setup step that runs once). Narrowing to exactly what's been observed risks breaking a legitimate but infrequent operation the observation window didn't happen to exercise. The right approach is extending the observation window to cover at least one full operational cycle relevant to the workload (a month, if there's a monthly job; through at least one incident/failure-handling path if those exist) before committing to a narrower custom role, and testing the narrowed role in staging against the same workload's full code paths before applying it to production.

5. During the incident-response drill, the on-call engineer found a stale IAM binding on a decommissioned Pub/Sub topic that nobody remembered granting. What process gap does this actually reveal, beyond "someone forgot to clean up," and what single practice from this chapter would have caught it months earlier without needing a drill at all?

The deeper gap isn't the one stale binding itself — individual forgotten cleanups are inevitable in any real system — it's that nothing was systematically checking for exactly this class of drift between what's granted and what's actually still needed. A standing quarterly IAM Recommender and Policy Analyzer review (this chapter's own auditing-tools section) would have surfaced the unused binding as an "unused permission" recommendation within one review cycle, the same mechanism that caught the departed contractor's stale grant earlier in this chapter — the drill caught it reactively under time pressure; a scheduled review would have caught it proactively, with no incident required to force the discovery.

Summary and What's Next#

This chapter built the complete IAM picture: the principal/role/binding/policy vocabulary, why groups beat individual grants, the three role tiers and when custom roles earn their maintenance cost, IAM conditions for access that expires itself, deny policies as a backstop for catastrophic actions, break-glass access designed to be safe rather than a silent backdoor, and the full progression away from service account keys — impersonation for humans, Workload Identity Federation for external workloads and GKE Pods alike. Meridian's IAM design now closes every "temporary became permanent" gap this course has surfaced, using conditions and federation instead of relying on someone remembering a cleanup step.

The specific techniques worth carrying forward into every later chapter's own IAM decisions: default to groups over individuals, default to predefined roles over primitive ones, treat any "temporary" access as a bug unless it's bound to a real IAM condition, default to impersonation and Workload Identity Federation over any credential that outlives a single session, and put a real review cadence — not just a good initial design — behind every access decision this course makes from here forward.

Every remaining chapter in this course grants IAM access to something new — a Compute Engine VM's own identity, a GKE cluster's node pool, a Cloud SQL instance's database users — and every one of those grants should be read against this chapter's own checklist before it's considered finished, not treated as a separate, disconnected topic each time.

Part 4 moves into compute: Compute Engine instances, managed instance groups and autoscaling, disk types, and the OS Login/VM Manager tooling that applies this chapter's identity model to actually logging into a running VM.