Back to blog
Best PracticesCloud SecurityGCPIdentity & AccessOperations & Compliance

Service Account Has Admin Access: Locking Down Over-Privileged GCP Identities

Learn why GCP service accounts with Owner or Editor roles are a major risk, and how to scope them down with predefined roles, custom roles, and policy-as-code.

TL;DR

This check flags non-default service accounts that hold roles/owner or roles/editor on a GCP project. Those roles are far too broad for an automated identity, so swap them for narrowly scoped predefined or custom roles that grant only what the workload actually needs.

Service accounts are the workhorses of GCP. They run your Cloud Functions, sign requests from your GKE pods, and let your CI pipeline push images to Artifact Registry. Because they operate without a human in the loop, the permissions you attach to them tend to get forgotten. Granting Editor "just to get the deploy working" is one of the most common shortcuts in cloud engineering, and it quietly turns a single leaked key into a full project compromise.

The Service Account Has Admin Access check (iam_saadminaccess) looks for exactly this pattern: a non-default service account that has been bound to roles/owner or roles/editor at the project level.


What this check detects

Lensix inspects the IAM policy on each project and identifies any service account principal (serviceAccount:...) that holds one of the two basic roles with the widest reach:

  • roles/owner — full control over all resources, including the ability to manage IAM bindings and modify billing.
  • roles/editor — read and write access to almost every resource in the project, short of managing IAM and a handful of sensitive operations.

The check deliberately excludes Google-managed default service accounts (the ones with names like [email protected]) from a separate finding, because those carry their own dedicated check. This finding targets the service accounts you created and bound to a basic role.

Note: GCP groups its IAM roles into three tiers. Basic roles (Owner, Editor, Viewer) predate the modern IAM system and apply broadly across services. Predefined roles are service-specific and curated by Google. Custom roles are ones you build by hand-picking individual permissions. This check is about basic roles attached to automation, which is almost always a sign of over-provisioning.


Why it matters

A service account with Editor or Owner is a high-value target precisely because nobody is watching it the way they watch human logins. There is no MFA prompt, no anomalous-login email, no one to notice the credential being used at 3 a.m.

The credential-leak blast radius

Service account keys leak constantly. They get committed to Git repos, baked into container images, pasted into Slack, and left in CI environment variables that get logged. When a key for an over-privileged account leaks, the consequences scale with the role:

  • With Editor, an attacker can spin up crypto-mining VMs, read most Cloud Storage buckets, modify Cloud SQL instances, and tamper with your application data.
  • With Owner, they can also rewrite IAM policy to grant themselves persistent access, delete audit log sinks, lock you out of your own project, and rack up charges against your billing account.

Privilege escalation chains

Even without a leaked key, broad service account permissions enable lateral movement. A common GCP escalation path: an attacker who compromises a low-privilege identity uses iam.serviceAccounts.actAs to impersonate a more powerful service account. If that target account holds Editor, the attacker inherits Editor across the whole project. Owner makes it worse, because it includes the IAM management permissions needed to mint new keys and create backdoor bindings.

Warning: The Editor role includes permissions that let a principal modify other service accounts and, in some configurations, generate keys for them. Treating Editor as "harmless because it can't touch IAM" is a mistake. It can touch enough to be dangerous.

Compliance impact

CIS GCP Foundations Benchmark, SOC 2, and ISO 27001 all expect least-privilege access for automated identities. A service account with project-wide Owner is a near-guaranteed audit finding, and it undermines any claim that access is scoped to need.


How to fix it

The fix is to replace the basic role with the minimum set of predefined or custom roles the workload actually uses. This takes three steps: figure out what the account needs, grant those narrower roles, then remove the broad one.

Step 1: Find the over-privileged bindings

List every service account that holds Owner or Editor on a project:

gcloud projects get-iam-policy PROJECT_ID \
  --flatten="bindings[].members" \
  --filter="bindings.role:(roles/owner OR roles/editor) AND bindings.members:serviceAccount" \
  --format="table(bindings.role, bindings.members)"

Step 2: Determine what the account actually uses

Use the IAM Recommender, which analyzes the last 90 days of usage and suggests tighter roles. You can pull recommendations directly:

gcloud recommender recommendations list \
  --project=PROJECT_ID \
  --location=global \
  --recommender=google.iam.policy.Recommender \
  --format=json

Tip: The IAM Recommender is the fastest path to least privilege. Instead of guessing which predefined roles to attach, it tells you which permissions the account has actually exercised and proposes a replacement set. Review its suggestions in the Console under IAM & Admin > IAM, where over-privileged accounts show an "Excess permissions" badge.

Step 3: Grant scoped roles, then revoke the broad one

Add the narrower roles the account needs. For example, a service account that only deploys Cloud Run services and reads from a bucket:

gcloud projects add-iam-policy-binding PROJECT_ID \
  --member="serviceAccount:deployer@PROJECT_ID.iam.gserviceaccount.com" \
  --role="roles/run.developer"

gcloud projects add-iam-policy-binding PROJECT_ID \
  --member="serviceAccount:deployer@PROJECT_ID.iam.gserviceaccount.com" \
  --role="roles/storage.objectViewer"

Verify the workload still functions with the new roles, then remove the basic role:

Danger: Removing Owner or Editor from a service account that depends on it will break the workload immediately. Confirm the replacement roles are in place and tested before you run the command below. For production accounts, do this during a maintenance window and have a rollback binding ready.

gcloud projects remove-iam-policy-binding PROJECT_ID \
  --member="serviceAccount:deployer@PROJECT_ID.iam.gserviceaccount.com" \
  --role="roles/editor"

If no predefined role fits

When the predefined roles are still too broad, build a custom role with exactly the permissions in use:

# deployer-role.yaml
title: "Deployer"
description: "Minimal permissions for the deploy pipeline"
stage: "GA"
includedPermissions:
- run.services.create
- run.services.update
- run.services.get
- storage.objects.get
- storage.objects.list
gcloud iam roles create deployer \
  --project=PROJECT_ID \
  --file=deployer-role.yaml

How to prevent it from happening again

Manual cleanup does not stick unless you stop the next over-privileged binding from being created. Two levers do most of the work: org policy and policy-as-code in CI.

Block basic-role grants in Terraform with a CI gate

If you manage IAM with Terraform, scan plans before they apply. A Conftest/OPA policy can reject any binding that pairs a service account with a basic role:

package gcp.iam

deny[msg] {
  rb := input.resource_changes[_]
  rb.type == "google_project_iam_member"
  role := rb.change.after.role
  member := rb.change.after.member
  basic := {"roles/owner", "roles/editor"}
  basic[role]
  startswith(member, "serviceAccount:")
  msg := sprintf("Service account %v must not be granted %v", [member, role])
}
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
conftest test tfplan.json --policy policy/

Enforce with an Organization Policy

The iam.allowedPolicyMemberDomains constraint and the IAM deny policies let you set guardrails at the org level. A deny policy can prevent setting Owner or Editor on service accounts across all projects, so a manual gcloud grant fails before it ever lands.

Tip: IAM deny policies evaluate before allow policies, which makes them ideal as a backstop. Even a project Owner cannot bypass a deny rule, so a well-placed deny on roles/owner for service account principals closes the gap that human error tends to open.

Continuous monitoring

Run iam_saadminaccess on a schedule in Lensix so a regression surfaces within minutes rather than at audit time. Pair it with an alert on the Admin Activity audit log for SetIamPolicy calls that add basic roles, so you catch out-of-band changes that bypass your pipeline.


Best practices

  • One service account per workload. Avoid shared "do everything" accounts. Scoped, single-purpose identities keep blast radius small and make permissions easy to reason about.
  • Prefer Workload Identity Federation over keys. For CI runners and external workloads, federate instead of issuing long-lived JSON keys. There is no key to leak.
  • Use Workload Identity for GKE so pods assume scoped service accounts rather than relying on the node's default account.
  • Bind roles at the narrowest scope. A service account that only touches one bucket should be bound at the bucket level, not the project level.
  • Disable unused keys and accounts. Rotate keys you must keep, and disable service accounts that no longer run anything.
  • Review with the Recommender quarterly. Permission needs drift as code changes. A recurring review catches roles that were once needed and now sit idle.

Treat every service account as if its key has already leaked. If that assumption keeps you up at night, the account has too much access.

Basic roles exist for convenience, and convenience is rarely the right trade for an automated identity that runs unattended. Replace Owner and Editor with scoped roles, gate new grants in CI, and let an org-level deny policy catch the rest. The work is mostly one-time, and it removes one of the most reliable paths an attacker has into a GCP project.