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 cluster-admin to any ServiceAccount (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 identified in step 1, review what permissions it actually needs by inspecting the workloads that use it (run on any machine with kubectl access). Replace VIOLATING_NAMESPACE and VIOLATING_SA with actual names:

    kubectl get pods -A -o json | jq -r '
    .items[]
    | select(.spec.serviceAccountName == "VIOLATING_SA" and .metadata.namespace == "VIOLATING_NAMESPACE")
    | "namespace=\(.metadata.namespace) pod=\(.metadata.name)"
    '
  3. Define a narrowly-scoped Role or ClusterRole that grants only the minimal verbs/resources required by those workloads (run on any machine with kubectl access). Example template to save as minimal-role.yaml and edit for correct namespace, apiGroups, resources, and verbs:

    apiVersion: rbac.authorization.k8s.io/v1
    kind: Role
    metadata:
    name: minimal-violation-role
    namespace: VIOLATING_NAMESPACE
    rules:
    - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list"]

    Apply it:

    kubectl apply -f minimal-role.yaml
  4. Bind the violating ServiceAccount to the new narrowly-scoped Role or ClusterRole (run on any machine with kubectl access). Example for a namespaced Role:

    cat << 'EOF' > minimal-rolebinding.yaml
    apiVersion: rbac.authorization.k8s.io/v1
    kind: RoleBinding
    metadata:
    name: minimal-violation-rolebinding
    namespace: VIOLATING_NAMESPACE
    subjects:
    - kind: ServiceAccount
    name: VIOLATING_SA
    namespace: VIOLATING_NAMESPACE
    roleRef:
    apiGroup: rbac.authorization.k8s.io
    kind: Role
    name: minimal-violation-role
    EOF

    kubectl apply -f minimal-rolebinding.yaml
  5. After confirming that workloads still function with the new, reduced privileges, remove the ClusterRoleBinding that granted cluster-admin to that ServiceAccount (run on any machine with kubectl access). Replace CLUSTERROLEBINDING_NAME with the name from step 1:

    kubectl delete clusterrolebinding CLUSTERROLEBINDING_NAME
  6. Verification (run on any machine with kubectl access): confirm that no ServiceAccount is bound to cluster-admin and that the check now reports compliance:

    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'
Using kubectl

On any machine with kubectl access:

  1. Identify violating ClusterRoleBindings
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'

Note the name= of each violating ClusterRoleBinding.

  1. (Optional but recommended) Create a narrowly-scoped Role/ClusterRole and binding
    Adapt the following to the actual verbs and resources the workload requires.

Example namespaced Role and RoleBinding for a ServiceAccount my-sa in namespace my-namespace:

# save as rbac-my-sa.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: my-sa-role
namespace: my-namespace
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]

---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: my-sa-rolebinding
namespace: my-namespace
subjects:
- kind: ServiceAccount
name: my-sa
namespace: my-namespace
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: my-sa-role

Apply it:

kubectl apply -f rbac-my-sa.yaml

For cluster-scoped access, use ClusterRole and ClusterRoleBinding instead of Role/RoleBinding.

  1. Delete ClusterRoleBindings that bind ServiceAccounts to cluster-admin

For each violating binding name (replace BINDING_NAME with the actual name):

kubectl delete clusterrolebinding BINDING_NAME

You can delete multiple at once:

kubectl delete clusterrolebinding BINDING_NAME_1 BINDING_NAME_2
  1. Verification

Run the audit command again and confirm it returns only is_compliant=true:

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
# Purpose: Remove any ServiceAccount bindings to the cluster-admin ClusterRole
# Platform: AKS (or any Kubernetes cluster reachable by kubectl)
# Requirements: kubectl, jq; run on any machine with kubectl access

set -euo pipefail

echo "[INFO] Verifying kubectl access..."
kubectl auth can-i list clusterrolebindings >/dev/null 2>&1 || {
echo "[ERROR] Current identity cannot list ClusterRoleBindings."
exit 1
}

echo "[INFO] Ensuring jq is available..."
if ! command -v jq >/dev/null 2>&1; then
echo "[ERROR] jq is required but not installed."
exit 1
fi

echo "[INFO] Discovering ClusterRoleBindings that bind ServiceAccounts to cluster-admin..."

# Find all ClusterRoleBindings that:
# - reference roleRef.name == "cluster-admin"
# - have at least one subject of kind ServiceAccount
violating_crbs=$(
kubectl get clusterrolebindings -o json |
jq -r '
.items[]
| select(.roleRef.name == "cluster-admin")
| select((.subjects // [])[]? | .kind == "ServiceAccount")
| .metadata.name
' | sort -u
)

if [[ -z "${violating_crbs}" ]]; then
echo "[INFO] No ClusterRoleBindings found that bind ServiceAccounts to cluster-admin."
else
echo "[INFO] The following ClusterRoleBindings bind ServiceAccounts to cluster-admin and will be deleted:"
echo "${violating_crbs}" | sed 's/^/ - /'

# Delete each violating ClusterRoleBinding. This is idempotent: deletions are skipped if already gone.
while IFS= read -r crb_name; do
[[ -z "${crb_name}" ]] && continue
echo "[INFO] Deleting ClusterRoleBinding: ${crb_name}"
# Use --ignore-not-found to keep script idempotent
kubectl delete clusterrolebinding "${crb_name}" --ignore-not-found
done <<< "${violating_crbs}"
fi

echo "[INFO] Verification: re-running compliance check..."

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
'