Skip to main content

No ServiceAccount Should Be Bound To cluster-admin

More Info:

Verifies no ServiceAccount is bound to the cluster-admin ClusterRole. Such a binding hands full cluster control to any workload using that account.

Risk Level

Critical

Address

Security

Compliance Standards

  • Cloudanix Best Practice

Triage and Remediation

Remediation

Manual Steps
  1. List all ClusterRoleBindings that bind ServiceAccounts to cluster-admin (run on any machine with kubectl access):

    kubectl get clusterrolebindings -o json | jq -r '
    [ .items[]
    | select(.roleRef.name == "cluster-admin")
    | .metadata as $m
    | ((.subjects // [])[] | select(.kind == "ServiceAccount"))
    | "kind=ClusterRoleBinding name=\($m.name) uid=\($m.uid) apiVersion=rbac.authorization.k8s.io/v1"
    + (if ($m.creationTimestamp // "") == "" then "" else " created=\($m.creationTimestamp)" end)
    + " sa=\(.namespace)/\(.name) roleRef=cluster-admin is_compliant=false"
    ] as $rows
    | if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'
  2. For each violating ServiceAccount, review what it actually needs to do (on any machine with kubectl access):

    • Inspect workloads using the ServiceAccount:
      kubectl get pods --all-namespaces -o json | jq -r '
      .items[]
      | select(.spec.serviceAccountName == "SERVICEACCOUNT_NAME")
      | "ns=\(.metadata.namespace) pod=\(.metadata.name)"
      '
    • Replace SERVICEACCOUNT_NAME with the name from step 1, and note the namespaces and pods.
  3. Design and create a minimally-privileged Role/ClusterRole that grants only the necessary permissions (on any machine with kubectl access). Example template (edit apiGroups, resources, verbs, and scope before applying):

    cat << 'EOF' > sa-limited-role.yaml
    apiVersion: rbac.authorization.k8s.io/v1
    kind: ClusterRole
    metadata:
    name: sa-limited-role
    rules:
    - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list"]
    EOF

    kubectl apply -f sa-limited-role.yaml

    If access is only needed within one namespace, change kind: ClusterRole to kind: Role and add metadata.namespace: TARGET_NAMESPACE.

  4. Bind the ServiceAccount to the new limited Role/ClusterRole (on any machine with kubectl access). Adjust names and namespace as needed:

    kubectl create clusterrolebinding sa-limited-binding \
    --clusterrole=sa-limited-role \
    --serviceaccount=SERVICEACCOUNT_NAMESPACE:SERVICEACCOUNT_NAME

    For namespace-scoped permissions instead, use:

    kubectl create rolebinding sa-limited-binding \
    --role=sa-limited-role \
    --serviceaccount=SERVICEACCOUNT_NAMESPACE:SERVICEACCOUNT_NAME \
    --namespace=SERVICEACCOUNT_NAMESPACE
  5. Remove the insecure cluster-admin binding once you have confirmed workloads still function as expected (on any machine with kubectl access). For each violating ClusterRoleBinding name from step 1:

    kubectl delete clusterrolebinding CLUSTERROLEBINDING_NAME
  6. Verify no ServiceAccount is bound to cluster-admin anymore (on any machine with kubectl access):

    kubectl get clusterrolebindings -o json | jq -r '
    [ .items[]
    | select(.roleRef.name == "cluster-admin")
    | .metadata as $m
    | ((.subjects // [])[] | select(.kind == "ServiceAccount"))
    | "kind=ClusterRoleBinding name=\($m.name) uid=\($m.uid) apiVersion=rbac.authorization.k8s.io/v1"
    + (if ($m.creationTimestamp // "") == "" then "" else " created=\($m.creationTimestamp)" end)
    + " sa=\(.namespace)/\(.name) roleRef=cluster-admin is_compliant=false"
    ] as $rows
    | if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'

    Compliance is achieved when the output is exactly:

    is_compliant=true
Using kubectl

On any machine with kubectl access:

  1. Identify ServiceAccounts bound to cluster-admin:
kubectl get clusterrolebindings -o json | jq -r '
[ .items[]
| select(.roleRef.name == "cluster-admin")
| .metadata as $m
| ((.subjects // [])[] | select(.kind == "ServiceAccount"))
| "kind=ClusterRoleBinding name=\($m.name) uid=\($m.uid) apiVersion=rbac.authorization.k8s.io/v1"
+ (if ($m.creationTimestamp // "") == "" then "" else " created=\($m.creationTimestamp)" end)
+ " sa=\(.namespace)/\(.name) roleRef=cluster-admin is_compliant=false"
] as $rows
| if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'
  1. For each violating ClusterRoleBinding, inspect it to understand what it’s used for:
kubectl get clusterrolebinding <clusterrolebinding-name> -o yaml
  1. (Optional but recommended) Create a narrowly-scoped Role/ClusterRole and binding for the workload, based on its actual needs. Example pattern (replace placeholders with real values):

Namespace-scoped permissions:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: <minimal-role-name>
namespace: <sa-namespace>
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: <minimal-rolebinding-name>
namespace: <sa-namespace>
subjects:
- kind: ServiceAccount
name: <sa-name>
namespace: <sa-namespace>
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: <minimal-role-name>

Cluster-scoped permissions (only if truly required):

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: <minimal-clusterrole-name>
rules:
- apiGroups: [""]
resources: ["nodes"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: <minimal-clusterrolebinding-name>
subjects:
- kind: ServiceAccount
name: <sa-name>
namespace: <sa-namespace>
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: <minimal-clusterrole-name>

Apply your minimal RBAC:

kubectl apply -f minimal-rbac.yaml
  1. Delete the ClusterRoleBinding that grants cluster-admin to a ServiceAccount:
kubectl delete clusterrolebinding <clusterrolebinding-name>

Repeat for every offending binding reported by the audit.

  1. Verification (cluster is compliant when only is_compliant=true is printed):
kubectl get clusterrolebindings -o json | jq -r '
[ .items[]
| select(.roleRef.name == "cluster-admin")
| .metadata as $m
| ((.subjects // [])[] | select(.kind == "ServiceAccount"))
| "kind=ClusterRoleBinding name=\($m.name) uid=\($m.uid) apiVersion=rbac.authorization.k8s.io/v1"
+ (if ($m.creationTimestamp // "") == "" then "" else " created=\($m.creationTimestamp)" end)
+ " sa=\(.namespace)/\(.name) roleRef=cluster-admin is_compliant=false"
] as $rows
| if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'
Automation
#!/usr/bin/env bash
#
# Remove any ServiceAccount bindings to the cluster-admin ClusterRole.
# Scope: run on any machine with kubectl and jq configured for the target GKE cluster.
set -euo pipefail

# Fail fast if required tools are missing
command -v kubectl >/dev/null 2>&1 || { echo "kubectl not found in PATH"; exit 1; }
command -v jq >/dev/null 2>&1 || { echo "jq not found in PATH"; exit 1; }

echo "Scanning for ClusterRoleBindings that bind ServiceAccounts to cluster-admin..."

# Find offending ClusterRoleBindings (those with roleRef=cluster-admin and at least one ServiceAccount subject)
mapfile -t CRBS_WITH_SA < <(
kubectl get clusterrolebindings -o json \
| jq -r '
.items[]
| select(.roleRef.name == "cluster-admin")
| select((.subjects // [])[]? | .kind == "ServiceAccount")
| .metadata.name
' \
| sort -u
)

if [ "${#CRBS_WITH_SA[@]}" -eq 0 ]; then
echo "No ClusterRoleBindings bind ServiceAccounts to cluster-admin. Nothing to remediate."
else
echo "The following ClusterRoleBindings bind ServiceAccounts to cluster-admin and will be deleted:"
for crb in "${CRBS_WITH_SA[@]}"; do
echo " - ${crb}"
done

# Delete the offending ClusterRoleBindings (idempotent: delete is a no-op if already gone)
for crb in "${CRBS_WITH_SA[@]}"; do
echo "Deleting ClusterRoleBinding ${crb}..."
# Use --ignore-not-found to allow safe re-runs
kubectl delete clusterrolebinding "${crb}" --ignore-not-found
done
fi

echo "Verifying compliance..."

# Re-run the audit logic to confirm there are no ServiceAccounts bound to cluster-admin
AUDIT_OUTPUT="$(
kubectl get clusterrolebindings -o json | jq -r '
[ .items[]
| select(.roleRef.name == "cluster-admin")
| .metadata as $m
| ((.subjects // [])[] | select(.kind == "ServiceAccount"))
| "kind=ClusterRoleBinding name=\($m.name) uid=\($m.uid) apiVersion=rbac.authorization.k8s.io/v1"
+ (if ($m.creationTimestamp // "") == "" then "" else " created=\($m.creationTimestamp)" end)
+ " sa=\(.namespace)/\(.name) roleRef=cluster-admin is_compliant=false"
] as $rows
| if ($rows | length) == 0 then "is_compliant=true" else $rows[] end'
)"

echo "${AUDIT_OUTPUT}"

if grep -q '^is_compliant=true$' <<< "${AUDIT_OUTPUT}"; then
echo "Remediation successful: no ServiceAccounts are bound to cluster-admin."
exit 0
else
echo "Remediation incomplete: some ServiceAccount bindings to cluster-admin remain."
exit 1
fi