Google Cloud IAM skill for Codex

Least privilege without JSON key files. Codex audits the exported project policy with a bundled script, then replaces basic roles with resource-level predefined roles, one service account per workload, and keyless auth through impersonation or Workload Identity Federation. CI pools always get an attribute condition, and Terraform changes use additive member resources so nobody gets locked out.

  • Cloud Platforms
  • $gcp-iam
  • 196 lines
  • 3 files
  • ~2,400 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- gcp-iam

Installs to ~/.agents/skills/gcp-iam/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $gcp-iam, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Designs and audits Google Cloud IAM - predefined and custom roles, resource-level bindings, IAM conditions, one service account per workload, impersonation instead of key files, and Workload Identity Federation for GitHub Actions and other OIDC providers. Use when granting access in a GCP project, replacing JSON service account keys, setting up keyless CI auth to GCP, debugging PERMISSION_DENIED, or reviewing gcloud or Terraform IAM bindings for overly broad roles.

Use it when

  • Granting a workload or team access to GCP resources
  • Replacing downloaded service account JSON keys
  • Wiring GitHub Actions or another OIDC CI into GCP
  • Debugging a PERMISSION_DENIED error

Use something else when

  • The workload runs on GKE and needs pod-level identity: use gcp-gke
  • You are writing AWS policies: use aws-iam-policies

How to check the result

The audit script reports no unexplained high findings, and a call made as the principal succeeds on the intended resource while failing on a neighbouring one.

The mistake it prevents

Creating a GitHub OIDC provider with no attribute condition, which lets workflows from any repository on GitHub authenticate to the pool.

~/.agents/skills/gcp-iam/SKILL.md

--- name: gcp-iam description: Designs and audits Google Cloud IAM - predefined and custom roles, resource-level bindings, IAM conditions, one service account per workload,… license: MIT ---

Done well means: every workload has its own service account, roles are predefined (or custom) and bound at the smallest resource that works, no long-lived JSON keys exist, CI authenticates through Workload Identity Federation with an attribute condition, and every grant has a reason the user can read.

Model in one paragraph

A policy on a resource (org, folder, project, or a single bucket, secret, service) binds roles to principals. Policies inherit downward and are additive: a grant at the project cannot be removed at a bucket. Deny policies and IAM conditions are the only ways to subtract or narrow. A service account is both a principal (it acts) and a resource (others can be allowed to act as it).

Workflow

- [ ] 1. Read AGENTS.md and find where IAM is managed (Terraform, gcloud scripts, console)
- [ ] 2. Export the current policy and audit it (script below)
- [ ] 3. Write down: principal, action needed, smallest resource
- [ ] 4. Pick the narrowest predefined role (or a custom role)
- [ ] 5. Bind at resource level; add a condition if access is time- or prefix-bound
- [ ] 6. Apply through the same tool that owns IAM; confirm with the user first
- [ ] 7. Verify with policy troubleshooter or a real call as the principal

Inspect (needs network and gcloud auth):

gcloud projects get-iam-policy "$PROJECT" --format=json > policy.json
python3 scripts/audit_iam_policy.py policy.json            # flags basic roles, allUsers, escalation roles
gcloud iam service-accounts list --project="$PROJECT"
gcloud iam service-accounts keys list --iam-account="$SA" --managed-by=user   # user-managed keys = debt

scripts/audit_iam_policy.py exits 1 on any high finding. allUsers on roles/run.invoker for an intentionally public service is a legitimate exception; confirm intent rather than removing it blindly.

Choosing a role

NeedPreferAvoid
App reads one secretroles/secretmanager.secretAccessor on that secretSame role on the project
App reads and writes objects in one bucketroles/storage.objectUser on the bucketroles/storage.admin
Service A calls private Cloud Run service Broles/run.invoker on service Broles/run.admin
CI deploys Cloud Runroles/run.developer + roles/iam.serviceAccountUser on the runtime SA onlyroles/editor
App publishes to one topicroles/pubsub.publisher on the topicProject-level Pub/Sub roles
Humans need read accessGroup bound to roles/viewer-like predefined roles per serviceIndividual user bindings

Rules:

  • Never grant roles/owner or roles/editor to a workload. Editor holds thousands of permissions and is the usual root cause of blast-radius incidents.
  • Grant to groups, not individual users; offboarding then happens in one place.
  • Find the role that contains a permission: gcloud iam roles list --filter=... is slow; use the Roles page or gcloud iam roles describe roles/run.developer to check contents.
  • Custom roles when predefined ones are far too broad. They do not get new permissions automatically when Google adds them, so maintain them in code.

Service accounts

gcloud iam service-accounts create api-runtime \
  --display-name="api runtime (Cloud Run)" --project="$PROJECT"

gcloud secrets add-iam-policy-binding db-url --project="$PROJECT" \
  --member="serviceAccount:api-runtime@$PROJECT.iam.gserviceaccount.com" \
  --role=roles/secretmanager.secretAccessor
  • One SA per workload and environment. Shared SAs make audit logs useless.
  • roles/iam.serviceAccountUser (actAs) lets a principal attach the SA to a resource; roles/iam.serviceAccountTokenCreator lets it mint tokens. Both are privilege escalation paths: grant them on the specific SA, never project-wide.
  • Disable before deleting: gcloud iam service-accounts disable SA, watch for errors for a few days, then delete only with user confirmation. A deleted SA leaves deleted: members behind and recreating the same email does not restore old grants.

No keys

Where the code runsHow it authenticates
Cloud Run, Functions, GCE, GKEAttached SA via metadata server; ADC picks it up
GKE podsWorkload Identity Federation for GKE (see gcp-gke)
GitHub Actions, GitLab, other OIDC CIWorkload Identity Federation
AWS or Azure workloadsWorkload Identity Federation with those providers
A developer laptopgcloud auth application-default login, impersonating an SA when needed

Impersonation for humans testing as a workload:

gcloud run services list --impersonate-service-account="deployer@$PROJECT.iam.gserviceaccount.com"
gcloud auth application-default login --impersonate-service-account="deployer@$PROJECT.iam.gserviceaccount.com"

Org policy iam.disableServiceAccountKeyCreation (or its managed-constraint equivalent) blocks new keys; recommend it once no workload depends on keys.

Workload Identity Federation for GitHub Actions

gcloud iam workload-identity-pools create github --location=global --project="$PROJECT"
gcloud iam workload-identity-pools providers create-oidc github-oidc \
  --location=global --workload-identity-pool=github --project="$PROJECT" \
  --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 == 'my-org'"

# Let only one repo impersonate the deployer SA
gcloud iam service-accounts add-iam-policy-binding "deployer@$PROJECT.iam.gserviceaccount.com" \
  --role=roles/iam.workloadIdentityUser \
  --member="principalSet://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/github/attribute.repository/my-org/my-repo"
permissions:
  contents: read
  id-token: write
steps:
  - uses: google-github-actions/auth@v2
    with:
      workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/github-oidc
      service_account: [email protected]
  • The attribute condition is mandatory in practice: without it, any GitHub repository could present a valid token to your pool. Restrict by owner, and by repository or ref for prod.
  • Members use the project number, not the project ID.
  • For production, also restrict to protected branches or environments (attribute.ref == 'refs/heads/main', or map assertion.environment).

Conditions and temporary access

gcloud projects add-iam-policy-binding "$PROJECT" \
  --member="group:[email protected]" --role=roles/run.developer \
  --condition='expression=request.time < timestamp("2026-11-01T00:00:00Z"),title=incident-4711,description=break-glass'

Conditions work only on roles and resource types that support them; basic roles cannot be conditioned. Bucket-prefix conditions use resource.name.startsWith("projects/_/buckets/b/objects/tenant-a/").

Terraform

ResourceBehaviour
google_project_iam_policyAuthoritative for the whole project policy; can lock everyone out. Avoid.
google_project_iam_bindingAuthoritative for one role; removes members added elsewhere
google_project_iam_memberAdditive, one member; safest default

Never mix _binding and _member for the same role. Review plans for IAM removals before applying, and get user confirmation for any change that removes access.

Debugging PERMISSION_DENIED

  1. Read the error: it names the permission (run.services.get) and often the principal.
  2. Confirm which identity is actually calling (gcloud auth list, ADC file, attached SA).
  3. gcloud policy-intelligence troubleshoot-policy iam RESOURCE --principal-email=EMAIL --permission=PERMISSION
  4. Check deny policies, org policies and VPC Service Controls if IAM looks correct.
  5. IAM changes propagate within seconds to a few minutes; retry before adding broader roles.

Gotchas

  • gcloud projects set-iam-policy replaces the entire policy; a stale file drops recent grants. Use add-iam-policy-binding / remove-iam-policy-binding.
  • Google-managed service agents (service-PROJECT_NUMBER@...) need their roles; deleting them breaks the product silently.
  • The default compute SA often has Editor. Remove that grant only after workloads move to dedicated SAs, or deployments break.
  • Use the IAM recommender (gcloud recommender recommendations list --recommender=google.iam.policy.Recommender --project=$PROJECT --location=global) to find unused permissions after 90 days of data.

Verification

  • Audit script passes, or every remaining finding has a written justification.
  • A real call as the principal (impersonation or the workload itself) succeeds for the intended action and fails for a neighbouring one (another bucket, another secret).
  • gcloud iam service-accounts keys list --managed-by=user is empty for migrated SAs.

Output template

| Principal | Role | Resource | Condition | Reason |
|---|---|---|---|---|
| api-runtime@ | secretmanager.secretAccessor | secret/db-url | - | read DB URL at start |
| github deploy (my-org/my-repo, main) | run.developer | project | - | CI deploys |
**Removed:** roles/editor from 123-compute@ (no workload uses it since revision api-00042)
**Keys deleted:** 2 user-managed keys on deployer@ after WIF cutover (user confirmed)

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Google Cloud IAM"
  short_description: "Least privilege without JSON key files"
  default_prompt: "$gcp-iam Set up keyless GitHub Actions auth to our GCP project with Workload Identity Federation"
scripts/audit_iam_policy.py77 lines, 3.5 KB
#!/usr/bin/env python3
"""Flag risky bindings in a GCP IAM policy exported as JSON.

Usage:
    gcloud projects get-iam-policy PROJECT --format=json > policy.json
    python3 audit_iam_policy.py policy.json [--fail-on high|medium|low]

Reads a policy document ({"bindings": [...], "etag": ...}) and prints one line per
finding. Exits 1 when any finding meets the --fail-on severity (default: high),
2 on unreadable input, 0 otherwise. Standard library only.
"""
import argparse
import json
import sys

BASIC_ROLES = {"roles/owner", "roles/editor", "roles/viewer"}
PUBLIC_MEMBERS = {"allUsers", "allAuthenticatedUsers"}
# Roles that let the holder mint credentials for, or act as, other identities.
ESCALATION_ROLES = {
    "roles/iam.serviceAccountTokenCreator": "can mint tokens for any SA in scope",
    "roles/iam.serviceAccountUser": "can attach (actAs) any SA in scope to resources",
    "roles/iam.serviceAccountKeyAdmin": "can create long-lived SA keys",
    "roles/iam.workloadIdentityUser": "can impersonate SAs from federated identities",
    "roles/resourcemanager.projectIamAdmin": "can grant itself any role",
    "roles/iam.securityAdmin": "can change IAM policy",
    "roles/iam.roleAdmin": "can create custom roles with any permission",
}
RANK = {"low": 1, "medium": 2, "high": 3}


def audit(policy):
    findings = []
    for b in policy.get("bindings", []):
        role = b.get("role", "")
        members = b.get("members", [])
        cond = b.get("condition")
        cond_note = f" [condition: {cond.get('title', cond.get('expression', ''))}]" if cond else ""
        for m in members:
            if m in PUBLIC_MEMBERS:
                findings.append(("high", role, m, "public principal" + cond_note))
            if m.startswith("deleted:"):
                findings.append(("low", role, m, "binding for a deleted principal; remove it"))
            if role in BASIC_ROLES:
                sev = "high" if role != "roles/viewer" else "medium"
                if m.startswith("serviceAccount:") and m.endswith("[email protected]"):
                    sev = "high"
                findings.append((sev, role, m, "basic role; replace with predefined roles" + cond_note))
            if role in ESCALATION_ROLES:
                sev = "medium" if cond else "high"
                findings.append((sev, role, m, "project-wide: " + ESCALATION_ROLES[role] + cond_note))
            if m.startswith("user:") and m.split("@")[-1] in {"gmail.com", "googlemail.com"}:
                findings.append(("medium", role, m, "consumer account; use a managed identity or group"))
    return findings


def main():
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("policy_json")
    ap.add_argument("--fail-on", choices=list(RANK), default="high")
    args = ap.parse_args()
    try:
        with open(args.policy_json, encoding="utf-8") as f:
            policy = json.load(f)
    except (OSError, json.JSONDecodeError) as err:
        print(f"error: cannot read policy: {err}", file=sys.stderr)
        return 2
    findings = sorted(audit(policy), key=lambda x: -RANK[x[0]])
    total = sum(len(b.get("members", [])) for b in policy.get("bindings", []))
    print(f"bindings: {len(policy.get('bindings', []))}, role-member pairs: {total}, findings: {len(findings)}")
    for sev, role, member, why in findings:
        print(f"{sev.upper():6} {role:45} {member}  -- {why}")
    worst = max((RANK[s] for s, *_ in findings), default=0)
    return 1 if worst >= RANK[args.fail_on] else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem