Nodes and clusters

RBAC least privilege with resourceNames, the secure way

The app needed one Secret, so the Role said resourceNames: [db-password]. Then the client library tried to list, got a 403, and someone deleted the resourceNames line to make the error go away. Now it can read every Secret in the namespace.

The short answer

Use resourceNames for get, update, patch and delete on the exact objects a workload needs. Never grant list or watch on Secrets without it, and teach clients to use a metadata.name field selector when they must watch. resourceNames cannot limit create or deletecollection, so limit create with a ValidatingAdmissionPolicy, and check every Role with kubectl auth can-i.

Updated Houssam Hammoudi, CTOTested with Kubernetes 1.34.0 (kind)

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

What goes wrong

An RBAC rule grants verbs on resources. resourceNames narrows a rule to named objects, so a workload can read one Secret instead of all of them. It works for requests that name a single object: get, update, patch, delete, and subresources such as pods/exec.

It does not work the same way for the rest:

  • create cannot be limited by name: the name may not be known when RBAC decides, so a top-level create request carries no name. A rule with resourceNames never matches an empty name, so a rule with resourceNames and create allows no creates.
  • deletecollection cannot be limited by name either.
  • list and watch are allowed with resourceNames only if the client asks with a metadata.name field selector. Most informers and kubectl get secrets do not, so they get 403 Forbidden.

The 403 is where least privilege usually dies. Someone drops resourceNames to make the error go away, and list on Secrets returns the full contents of every Secret in the namespace. The rule looks harmless in review because it says "list", not "get".

What the docs say

You cannot restrict deletecollection or top-level create requests by resource name.

Source: Kubernetes docs, Using RBAC Authorization

If you restrict list or watch by resourceName, clients must include a metadata.name field selector in their list or watch request (that matches the specified resourceName) in order to be authorized.

Source: Kubernetes docs, Using RBAC Authorization

It is also important to note that list and watch access also effectively allow for users to reveal the Secret contents.

Source: Kubernetes docs, Role Based Access Control Good Practices

The RBAC page explains the limits but offers no way to limit create by name. That job belongs to admission control, and the page does not point there.

The secure configuration

1. A workload that reads one Secret and watches one ConfigMap.

yaml
# k8s-rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: web-config
  namespace: app
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["web-db-credentials"]
    verbs: ["get"]                        # no list, no watch: they reveal contents
  - apiGroups: [""]
    resources: ["configmaps"]
    resourceNames: ["web-settings"]
    verbs: ["get", "list", "watch"]       # list and watch only with a metadata.name field selector
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: web-config
  namespace: app
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: web-config
subjects:
  - kind: ServiceAccount
    name: web
    namespace: app
---
# 2. A deployer that may roll out exactly one Deployment.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: web-deployer
  namespace: app
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    resourceNames: ["web"]
    verbs: ["get", "patch", "update"]     # change the image of "web", nothing else
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["create"]                     # cannot be scoped by name; see the policy below
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: web-deployer
  namespace: app
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: web-deployer
subjects:
  - kind: ServiceAccount
    name: ci-deployer
    namespace: app

3. Limit create by name with a ValidatingAdmissionPolicy. The CI account may create only ConfigMaps whose names start with release-:

yaml
# k8s-create-policy.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: ci-deployer-configmap-names
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE"]
        resources: ["configmaps"]
  matchConditions:
    - name: only-ci-deployer
      expression: "request.userInfo.username == 'system:serviceaccount:app:ci-deployer'"
  validations:
    - expression: "object.metadata.name.startsWith('release-')"
      message: "ci-deployer may only create ConfigMaps named release-*"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: ci-deployer-configmap-names
spec:
  policyName: ci-deployer-configmap-names
  validationActions: ["Deny"]
  matchResources:
    namespaceSelector:
      matchLabels:
        kubernetes.io/metadata.name: app

4. Watch one object the way RBAC allows. In kubectl:

bash
kubectl -n app get configmaps --field-selector metadata.name=web-settings --watch

In a client-go informer, set the same field selector in the list options (fields.OneTermEqualSelector("metadata.name", "web-settings")).

Prove it

Run on a lab cluster (Kubernetes 1.34) with the two manifests above, plus the ServiceAccounts, web-settings and web-db-credentials they refer to. Real output:

1. What the web ServiceAccount can and cannot do:

bash
SA=system:serviceaccount:app:web
kubectl -n app auth can-i get secret/web-db-credentials --as=$SA
kubectl -n app auth can-i get secret/other-secret --as=$SA
kubectl -n app auth can-i list secrets --as=$SA
kubectl -n app auth can-i watch configmap/web-settings --as=$SA
text
yes
no
no
yes

2. List needs the field selector:

bash
kubectl -n app get configmaps --as=$SA
kubectl -n app get configmaps --field-selector metadata.name=web-settings --as=$SA
text
Error from server (Forbidden): configmaps is forbidden: User "system:serviceaccount:app:web" cannot list resource "configmaps" in API group "" in the namespace "app"
NAME           DATA   AGE
web-settings   1      4s

3. The deployer touches only its Deployment, and create is policed:

bash
CI=system:serviceaccount:app:ci-deployer
kubectl -n app auth can-i patch deployment/web --as=$CI
kubectl -n app auth can-i patch deployment/api --as=$CI
kubectl -n app create configmap scratch --from-literal=a=b --as=$CI
kubectl -n app create configmap release-42 --from-literal=a=b --as=$CI
text
yes
no
error: failed to create configmap: configmaps "scratch" is forbidden: ValidatingAdmissionPolicy 'ci-deployer-configmap-names' with binding 'ci-deployer-configmap-names' denied request: ci-deployer may only create ConfigMaps named release-*
configmap/release-42 created

4. Nobody lists Secrets by accident:

bash
kubectl auth can-i --list -n app --as=$SA
text
Resources                                       Non-Resource URLs                      Resource Names         Verbs
configmaps                                      []                                     [web-settings]         [get list watch]
secrets                                         []                                     [web-db-credentials]   [get]

Mistakes people make

Deleting resourceNames to fix a 403

The 403 came from a list without a field selector. Fix the client (field selector, or get by name), not the Role. Without resourceNames, list returns every Secret's data.

Adding resourceNames to a create rule

A rule with create and resourceNames matches no create request, so it grants nothing. People then add a second rule without resourceNames, which grants create for any name. Use admission policy for names on create.

Forgetting subresources

pods/exec, pods/portforward and serviceaccounts/token are separate resources. A rule on pods does not grant them, and a rule on pods/exec without resourceNames grants exec into every pod in the namespace. Scope them by name like any other object.

Wildcards "for now"

resources: ["*"] also covers every resource type added later, including CRDs that hold credentials. Name the resources.

Trusting the Role name

A Role called read-only that grants list on Secrets is not read-only. Review with kubectl auth can-i --list --as=..., not by name.

Checklist

  • No Role grants list or watch on Secrets without resourceNames.
  • Every rule for a workload names the exact objects with resourceNames where the verb allows it.
  • Clients that watch single objects use a metadata.name field selector.
  • create is never combined with resourceNames; names on create are enforced by a ValidatingAdmissionPolicy.
  • Subresources (pods/exec, pods/portforward, serviceaccounts/token) are granted by name only.
  • No rule uses * for resources or verbs outside cluster-admin.
  • Every ServiceAccount's permissions were checked with kubectl auth can-i --list --as=....

`resourceNames` is a scalpel that only cuts in some directions. Know which verbs it covers, use admission policy for the rest, and never trade it for a quiet 403.

H2-CIAE

Learn it on a live range

Authorization as code, in Identity and Access Engineering: a real host in your browser, and every objective checked on the machine.

Start free

The Secure Way

More on nodes and clusters

Talos Linux, Kubernetes API hardening, service account tokens, RBAC and the cloud underneath.

All nodes and clusters guides