Roles — Primitive, Predefined, Custom
A role on GCP is a named bundle of permissions, where each permission is a service.resource.verb string like storage.objects.get. You grant roles, never raw permissions — there is no way to hand someone a single permission directly. There are three kinds of role, and the most important rule is about the oldest kind.
The basic roles — also called primitive — roles/owner, roles/editor, and roles/viewer predate fine-grained IAM. Each spans thousands of permissions across every service in a project, and they have no place in a production project. Predefined roles exist precisely so you never have to reach for them. The whole of this topic is choosing the right kind for a given job function, and the default answer is "a predefined role."
Basic Roles
roles/owner, roles/editor, and roles/viewer are coarse and cross-service. roles/editor can mutate almost everything in a project across every enabled API, and roles/owner can change the project's IAM policy outright — meaning it can grant itself or anyone else anything. They are fine for a personal sandbox where you are the only user and the blast radius is your own toy project. They are never appropriate for hatch-app-prod.
The reason they survive at all is convenience: granting roles/editor "just works" for almost any task, because it includes almost every permission. That is exactly why it is the opposite of least privilege and the first thing an auditor flags. Treat any basic-role binding near a project with an SLO as a code smell that needs replacing with something scoped to the actual job.
Predefined Roles
Predefined roles are Google-maintained and service-scoped: roles/run.developer, roles/storage.objectViewer, roles/bigquery.dataEditor. Each maps to a real job function within one service, and Google tracks the service's permissions over time on your behalf — when Cloud Run gains a new permission a developer needs, it lands in roles/run.developer without you editing anything. You get least privilege without curating permission lists by hand. This is the default choice for essentially every grant.
The binding below grants gcp-developers@hatch.io the predefined roles/run.developer on hatch-app-prod. It is scoped to one service (Cloud Run) and one job (developer), it self-updates as the service evolves, and it is a fraction of the permissions roles/editor would have handed over. That is the trade you make every time: a little more thought about which predefined role fits, in exchange for a grant that an audit passes cleanly.
resource "google_project_iam_member" "run_dev" { project = "hatch-app-prod" role = "roles/run.developer" # scoped to Cloud Run, self-updating member = "group:gcp-developers@hatch.io" # not roles/editor, not a user }
Custom Roles
When no predefined role fits a real job function — predefined ones are either too broad or too narrow — you define an exact permission set yourself. google_project_iam_custom_role creates a role scoped to one project; google_organization_iam_custom_role creates one at the org that is reusable across every project in the hierarchy. The difference matters: if the same job function exists in hatch-app-prod, hatch-app-staging, and hatch-data-prod, define the role once at the org and reference it everywhere rather than copying a project-scoped definition three times.
A custom role comes with a maintenance burden the predefined ones do not. When Google adds a new permission to a service, a predefined role picks it up automatically; your custom role does not, and you must add it by hand or fall behind. The configuration below defines an org-wide custom role for a narrow operator job. Note the stage — it is part of the role's lifecycle.
resource "google_organization_iam_custom_role" "event_operator" { org_id = "123456789012" role_id = "eventOperator" title = "Event Pipeline Operator" stage = "GA" # DISABLED retires it without deleting permissions = [ "pubsub.subscriptions.consume", "pubsub.topics.publish", "storage.objects.get", ] }
Role Launch Stages
Every custom role carries a stage: EAP, ALPHA, BETA, GA, DEPRECATED, or DISABLED. Most are documentation, but DISABLED earns its keep — it makes the role grant nothing without deleting the role itself. That is the clean way to retire a custom role: set stage = "DISABLED", confirm nothing breaks, then delete it once you are sure. The bindings that reference the role stay valid throughout, so you keep a rollback path.
Deleting a custom role that live bindings still reference does the opposite — the bindings break, and you have no safe way back. The launch stage exists so you never have to choose between "leave a dead role granting access" and "delete it and break things." Disable first, observe, then delete.
Choosing the Kind
The decision order is simple. Reach for a predefined role first — it covers the overwhelming majority of real grants and maintains itself. Build a custom role only when predefined roles are genuinely too broad or too narrow for a real job function, and define org-wide reusable ones at the org node. Treat primitive roles as a code smell anywhere near production: if you see roles/editor in a binding on hatch-app-prod, that is a finding, not a configuration.
Predefined role — Google-maintained, service-scoped, self-updating as the service gains permissions. The default for nearly every grant; choose it whenever one maps to the job function you are granting.
Custom role — you define and own the exact permission set, project-scoped (google_project_iam_custom_role) or org-wide (google_organization_iam_custom_role). Choose it only when no predefined role fits, and accept that you must add new permissions Google ships yourself.
- Granting
roles/editortogcp-developers@hatch.ioonhatch-app-prodbecause it "just works" — it is thousands of permissions across every API, the opposite of least privilege, and the first thing an auditor flags. - Building a custom role that duplicates an existing predefined role — you sign up to manually add every new permission Google ships for that service, and you inevitably fall behind.
- Defining a custom role at the project level when the same job function exists in
hatch-app-prod,hatch-app-staging, andhatch-data-prod— angoogle_organization_iam_custom_roledefines it once and reuses it everywhere. - Deleting a custom role that is still referenced by live bindings instead of setting its
stagetoDISABLEDfirst — the bindings break and you lose the safe rollback path. - Granting
roles/ownerto a group for day-to-day work — owner can rewrite the project's IAM policy, so it can grant itself anything, which makes it ungovernable.
- Default to predefined roles scoped to the service and the job function; treat
owner,editor, andvieweras banned in any project with an SLO. - Build custom roles only when no predefined role fits, and define org-wide reusable ones with
google_organization_iam_custom_role. - Retire a custom role by setting
stage = "DISABLED"first, confirming nothing breaks, then deleting it. - Review the permission set of each custom role on a cadence, because predefined roles self-update and custom ones do not.
- Grant every role to a group (
group:...@hatch.io), so the role-to-people mapping is managed in the directory and never in a binding.
Knowledge Check
Why are the basic roles owner, editor, and viewer unsuitable for a production project?
- They span thousands of permissions across every service, so they violate least privilege and are the first thing an audit flags
- They are formally deprecated and silently stop granting any access the moment a project enables any additional Google Cloud API
- They can only be granted to individual users, never to groups
- They are slower to evaluate than predefined roles at request time
What do you give up by building a custom role instead of using a predefined one?
- The automatic updates — a predefined role gains new service permissions on its own, while your custom role must be maintained by hand
- The ability to grant it to a Google group, since a custom role can only ever be bound to an individual service account and never to a group
- Inheritance — custom roles do not flow down the resource hierarchy
- Nothing; custom roles are strictly superior and self-update like predefined ones
The same operator job function is needed in three projects. Which is the right way to define the custom role?
- Define it once at the org with
google_organization_iam_custom_roleand reference it from each project - Define a separate
google_project_iam_custom_rolein each of the three projects - Define it in the most important project and let the other two projects inherit the role definition down through their shared folder
- Skip the custom role and grant
roles/editorin all three for simplicity
What does setting a custom role's stage to DISABLED accomplish?
- The role grants nothing while still existing, so you can retire it without breaking the bindings that reference it
- It permanently deletes the custom role and cascades the removal to every binding across every project that still references it
- It freezes the permission list so Google stops adding new permissions to it
- It converts the custom role into the equivalent predefined role
You got correct