Back to blog
Best PracticesCloud SecurityGCPIdentity & AccessOperations & Compliance

IAM Permission Granted Directly to User on GCP

Why direct GCP IAM grants to users break access reviews and offboarding, plus step-by-step gcloud and Terraform fixes and CI gates to prevent them.

TL;DR

This check flags GCP IAM roles bound directly to individual user accounts instead of groups. Direct grants make access nearly impossible to audit and tend to linger after people change roles or leave. Fix it by moving the binding to a Google group and removing the per-user grant.

On GCP, IAM lets you bind roles to several types of members: individual users, service accounts, Google groups, and domains. The flexibility is convenient, and that is exactly the problem. When teams grant roles to user:[email protected] directly on a project, the permission model fragments fast. Multiply that across dozens of projects and hundreds of engineers, and you end up with access sprawl that nobody can fully explain.

The IAM Permission Granted Directly to User check (iam_directuserpermissions) looks for IAM policy bindings where the member is a specific user rather than a group. It is a low-noise signal that your access model is drifting away from a manageable, group-based design.


What this check detects

Lensix inspects the IAM policy bindings on your GCP resources (projects, folders, and the organization) and reports any binding whose member type is user:. For example, a policy like this would trigger the check:

{
  "bindings": [
    {
      "role": "roles/storage.admin",
      "members": [
        "user:[email protected]",
        "group:[email protected]"
      ]
    }
  ]
}

Here, group:[email protected] is fine. The user:[email protected] entry is what gets flagged. The role is assigned to a person rather than to a role-based group that Alice happens to be a member of.

Note: This check is about how access is granted, not whether the access itself is excessive. A direct user binding to a low-privilege viewer role still trips it, because the structural problem (ungroupable, hard-to-audit permissions) exists regardless of the role's scope.


Why it matters

Direct user grants feel harmless when you make them. The cost shows up later, usually at the worst possible time.

Access reviews become guesswork

When someone asks "who can delete buckets in the production project?", a group-based model answers cleanly: list the members of the groups bound to roles/storage.admin. With direct grants scattered across projects, you have to walk every policy on every resource and reconcile individual emails by hand. Quarterly access reviews turn into multi-day archaeology projects, and things get missed.

Offboarding leaks permissions

This is the big one. When an engineer leaves or changes teams, removing them from a few groups revokes their access cleanly. Direct bindings do not follow that flow. A user removed from your Google Workspace directory may have their user: binding linger as an orphaned entry, and even when the account is gone, the audit trail of "what could this person do" is fragmented across resources.

Warning: Deleting a user from Google Workspace does not automatically scrub their direct IAM bindings everywhere. Those bindings can persist as references to a deleted principal, which clutters policies and complicates audits. Group membership removal, by contrast, takes effect immediately across every resource the group is bound to.

Privilege creep is invisible

People accumulate one-off grants over years. Someone needed BigQuery access for a migration in 2022, got a direct binding, and never lost it. Group membership has a natural review point (the group roster), but a pile of individual bindings has none. Attackers who compromise a long-tenured account often inherit far more than that person currently needs.

The attack scenario

Consider a phished engineer account. If access flows through groups, the blast radius matches that engineer's current role, and you can revoke it instantly by pulling group membership. If the account carries a decade of accumulated direct grants, the attacker gets a grab bag of permissions across projects, and your incident response team has to enumerate every binding to understand exposure while the clock is running.


How to fix it

The fix has three parts: find the direct grants, create or identify a group for the equivalent role, then move the binding and remove the per-user grant.

1. Find the direct user bindings

Use gcloud to dump a project's IAM policy and filter for user members:

gcloud projects get-iam-policy PROJECT_ID \
  --format=json \
  | jq '.bindings[] | select(.members[] | startswith("user:"))'

To find every direct grant for a specific person across a project:

gcloud projects get-iam-policy PROJECT_ID \
  --flatten="bindings[].members" \
  --filter="bindings.members:user:[email protected]" \
  --format="table(bindings.role)"

2. Create or pick a group for the role

Group the permission by job function, not by person. If several engineers need roles/storage.admin, create a single group that represents that responsibility:

# Using the Cloud Identity Groups API via gcloud
gcloud identity groups create [email protected] \
  --organization=example.com \
  --display-name="Storage Admins"

# Add the user as a member
gcloud identity groups memberships add \
  --group-email="[email protected]" \
  --member-email="[email protected]"

3. Bind the role to the group and remove the user binding

# Grant the role to the group
gcloud projects add-iam-policy-binding PROJECT_ID \
  --member="group:[email protected]" \
  --role="roles/storage.admin"

Danger: Before removing the direct binding, confirm the user is actually a member of the group you just bound. If you remove the user grant first and the group membership has not propagated, you can lock the person out of production resources. Verify with gcloud identity groups memberships list [email protected] before proceeding.

# Remove the direct user binding once group access is confirmed
gcloud projects remove-iam-policy-binding PROJECT_ID \
  --member="user:[email protected]" \
  --role="roles/storage.admin"

Tip: Cloud Identity group membership can take a few minutes to fully propagate to IAM. When migrating production access, leave both bindings (user and group) in place for a short overlap window, verify the user still has access through the group, then remove the direct grant.


Doing it in Terraform

If you manage IAM as code, the structural fix maps cleanly. Replace per-user members with group members in your bindings:

resource "google_project_iam_member" "storage_admins" {
  project = var.project_id
  role    = "roles/storage.admin"
  member  = "group:[email protected]"
}

Manage the group roster separately so membership changes do not require IAM policy edits:

resource "google_cloud_identity_group_membership" "alice" {
  group = google_cloud_identity_group.storage_admins.id

  preferred_member_key {
    id = "[email protected]"
  }

  roles {
    name = "MEMBER"
  }
}

This separation is the whole point. Granting access is an infrequent, reviewed change to a binding. Adding or removing a person is a routine membership update that does not touch the resource policy at all.


How to prevent it from happening again

Manual cleanup is a one-time fix. Stopping the regression takes guardrails.

Organization Policy constraint

GCP supports a domain restriction constraint, and more usefully, you can deny direct user grants through custom organization policies and IAM Deny policies. Start by blocking the most sensitive roles from accepting user: members at the org or folder level.

Policy-as-code in CI/CD

If your IAM lives in Terraform, gate it. A simple conftest / OPA policy can reject any plan that introduces a user member:

package main

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "google_project_iam_member"
  member := resource.change.after.member
  startswith(member, "user:")
  msg := sprintf("Direct user IAM binding not allowed: %s", [member])
}

Wire this into your pull request pipeline so the check runs against terraform plan output before any apply. Engineers get fast feedback, and direct grants never reach production.

Tip: Pair the CI gate with continuous detection in Lensix. The pipeline catches new infrastructure-as-code changes, while Lensix catches direct grants made through the console or gcloud outside your IaC workflow. You need both, because someone always clicks something in the console under deadline pressure.


Best practices

  • Model groups around job functions, not individuals. A group like data-engineers or oncall-sre should map to a role a person holds, so membership is self-documenting.
  • Keep service accounts out of this pattern too. Bind workload identity and service-to-service access through service accounts and their own grants, not by adding human users to machine roles.
  • Use predefined or custom roles, not basic roles. Group-based access only helps if the roles themselves follow least privilege. Avoid roles/owner and roles/editor on groups.
  • Set a review cadence on group rosters. Quarterly membership reviews are far cheaper than per-binding audits, which is the entire payoff of going group-first.
  • Treat the organization and folder levels with extra care. A direct user grant high in the resource hierarchy inherits down to everything below it, so these are the bindings to find and fix first.

None of this requires a big-bang migration. Start by finding direct grants on your most sensitive projects, group the ones that map to clear job functions, and put a CI gate in place so the pile stops growing. Over a couple of sprints, your IAM policy goes from a sprawl of individual names to a short list of groups you can actually reason about.