Back to blog
Best PracticesCloud SecurityGCPIdentity & AccessOperations & Compliance

User Has Both Service Account User and Admin Roles: A GCP Privilege Escalation Risk

Learn why holding both serviceAccountUser and serviceAccountAdmin in GCP is a privilege escalation path, and how to fix and prevent it with scoped IAM and policy-as-code.

TL;DR

When a single GCP user holds both roles/iam.serviceAccountUser and roles/iam.serviceAccountAdmin, they can grant themselves access to any service account and then impersonate it, escalating to whatever permissions that account holds. Split these roles across different principals and scope them to specific service accounts.

Service accounts are the workhorses of GCP. They run your Compute Engine instances, your Cloud Functions, your CI pipelines, and your data jobs. Many of them carry broad permissions because they need to touch a lot of resources. That makes the ability to create and use service accounts a sensitive pairing, and this check exists to catch the moment when one person ends up holding both.

The Lensix check iam_sauserandsaadmin flags any user (or principal) that is granted both the Service Account User role and the Service Account Admin role within the same scope. On its own each role is reasonable. Combined, they form a clean privilege escalation path.


What this check detects

The check inspects IAM policy bindings and looks for a principal that holds both of the following roles, either at the project level or on overlapping resources:

  • roles/iam.serviceAccountAdmin — lets the principal create, delete, and manage service accounts, and crucially, modify the IAM policy on those service accounts (including granting the User role to themselves or others).
  • roles/iam.serviceAccountUser — lets the principal impersonate or "act as" a service account, for example by attaching it to a VM or generating short-lived credentials for it.

Note: "Acting as" a service account means your effective permissions become the union of your own and the service account's. If a service account is an Owner on the project, a user who can act as it is effectively an Owner too.

The check does not care whether the user is "supposed" to be an admin. It cares that the two roles together remove the separation of duties that normally limits what an admin can do.


Why it matters

Think about what the Admin role grants but the User role normally does not: the ability to edit the IAM policy on a service account. Now combine it with the User role, which lets you act as that account.

Here is the escalation in concrete steps. Imagine a user named [email protected] holds both roles at the project level, and the project has a service account [email protected] that happens to be a project Editor.

  1. As Service Account Admin, the user grants roles/iam.serviceAccountTokenCreator on the Terraform service account to themselves.
  2. As Service Account User, the user is already allowed to act as it.
  3. The user mints a short-lived access token for the Terraform service account.
  4. The user now operates with Editor permissions across the entire project, far beyond anything their two original roles imply on paper.

That last point is the business risk. Auditors look at the user and see "service account management" privileges. What they actually have is a path to inherit the permissions of every privileged service account in the project. This is one of the most common GCP privilege escalation patterns documented in real-world cloud pentests.

Warning: Token impersonation produces short-lived credentials that often blend in with normal automation traffic. If you are not logging Data Access audit logs for IAM, this escalation can happen without leaving an obvious trail.

It also matters for compliance. Separation of duties is an explicit control in SOC 2, ISO 27001, and PCI DSS. A principal who can both provision and assume privileged identities violates that control by design.


How to fix it

The fix is to break the combination. In almost every case you want to remove one of the two roles from the principal, and where the remaining role is needed, scope it tightly.

1. Find the offending bindings

First confirm which roles the user actually holds at the project level:

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

You should see both roles/iam.serviceAccountAdmin and roles/iam.serviceAccountUser in the output.

2. Decide which role the user genuinely needs

Most humans do not need to act as service accounts directly. That is usually a job for automation or workloads. If the user is a platform engineer who provisions accounts, keep Admin and remove User. If the user needs to attach an existing service account to resources but never manages accounts, keep User and remove Admin.

Danger: Removing an IAM role from a principal is immediate and can break running workflows. If [email protected] is currently impersonating a service account in a deployment pipeline, pulling the User role mid-deploy will fail it. Confirm what depends on the binding before you remove it.

3. Remove the redundant role

To remove the Service Account User role at the project level:

gcloud projects remove-iam-policy-binding PROJECT_ID \
  --member="user:[email protected]" \
  --role="roles/iam.serviceAccountUser"

Or, if it is the Admin role you want gone:

gcloud projects remove-iam-policy-binding PROJECT_ID \
  --member="user:[email protected]" \
  --role="roles/iam.serviceAccountAdmin"

4. Re-grant the remaining role at the narrowest scope

If the user still needs the User role but only for one service account, grant it on that specific account rather than the whole project:

gcloud iam service-accounts add-iam-policy-binding \
  app-runner@PROJECT_ID.iam.gserviceaccount.com \
  --member="user:[email protected]" \
  --role="roles/iam.serviceAccountUser"

This way the user can act as one specific, low-privilege service account instead of every account in the project.

Tip: Resource-level bindings on a single service account are almost always the right answer. They give the user exactly the access they need and keep the project-level policy clean and auditable.

5. Fix it in Terraform

If your IAM is managed as code, do not patch it in the console or it will drift back. Replace the project-level grants with a scoped resource binding:

resource "google_service_account_iam_member" "app_runner_user" {
  service_account_id = google_service_account.app_runner.name
  role               = "roles/iam.serviceAccountUser"
  member             = "user:[email protected]"
}

# Keep admin separate, on a different principal (e.g. a platform group)
resource "google_project_iam_member" "sa_admin" {
  project = var.project_id
  role    = "roles/iam.serviceAccountAdmin"
  member  = "group:[email protected]"
}

How to prevent it from happening again

Manual cleanup fixes today's problem. To stop it recurring you need a guardrail in the path that grants permissions.

Policy-as-code in CI

If IAM lives in Terraform, add an OPA/Conftest policy that rejects any plan where the same member holds both roles. A simplified Rego check:

package gcp.iam

deny[msg] {
  user := input.bindings[_].members[_]
  has_role(user, "roles/iam.serviceAccountAdmin")
  has_role(user, "roles/iam.serviceAccountUser")
  msg := sprintf("%s holds both serviceAccountAdmin and serviceAccountUser", [user])
}

has_role(user, role) {
  b := input.bindings[_]
  b.role == role
  b.members[_] == user
}

Wire this into your pull request pipeline so the combination never merges in the first place.

Organization policy and least privilege at grant time

Use IAM Conditions and predefined custom roles so that human principals never receive project-wide service account roles. Reserve account administration for a small platform group and review its membership.

Tip: Lensix re-runs iam_sauserandsaadmin continuously, so even if someone grants both roles through the console outside your IaC pipeline, the drift is flagged before it becomes a standing risk.

Catch escalations in logs

Enable Data Access audit logs for the IAM API and alert on SetIamPolicy calls that add token creator or user roles to service accounts. This gives you detection even if a guardrail is bypassed.


Best practices

  • Separate duties. The principal who creates service accounts should not be the same one who acts as them. Split Admin and User across different identities.
  • Prefer groups over users. Bind roles to Google Groups, not individual users, so access tracks team membership and leaves the org cleanly.
  • Scope to the resource, not the project. Grant serviceAccountUser on specific service accounts rather than at project level wherever possible.
  • Watch the token creator role too. roles/iam.serviceAccountTokenCreator is functionally similar to the User role for escalation purposes. Treat it with the same caution.
  • Audit privileged service accounts. The escalation only matters because some service accounts are over-permissioned. Right-size the accounts and the blast radius shrinks.
  • Use short-lived credentials and avoid keys. Impersonation with short-lived tokens is preferable to exported key files, but only when the impersonation rights are tightly controlled.

The core idea is simple: the power to manage identities and the power to assume them should never sit with the same hands. Keep them apart and this entire class of escalation closes off.