Kubernetes · Core

Kubernetes Config & Access

ConfigMaps that update in place except when they do not, Secrets that are encoded rather than encrypted, namespaces that isolate less than people assume, and RBAC's one rule — purely additive, no deny — that explains every access surprise.

27 min read Level: core → advanced Kubernetes 02 / 05
The model

FOUR GATES BETWEEN A REQUEST AND A RUNNING POD

Authentication, authorisation, admission, then quota — in that order, each refusing in its own words.

IDENTITYUser / OIDCServiceAccountGroupAUTHNCertificate / token / OIDCAUTHZRBAC: Role + RoleBindingClusterRole + ClusterRoleBindingADMISSIONMutating webhooksPod Security AdmissionValidating webhooksResourceQuotaBOUNDARYNamespaceCONFIGConfigMapSecretCONSUMED ASenv / envFromvolume mountprojected volume

Only the bottom half is namespaced. ClusterRoles, nodes and PersistentVolumes sit outside any namespace, which is the source of most "but I gave them access" confusion.

Diagrams

THREE VIEWS OF THE SAME SYSTEM

The diagram above is the high level: what the pieces are. These two are the ones you want when something is wrong — what is inside one of those boxes, and the path a request really takes through them.

Low levelWhat does the API server do to every single request?
APISERVER REQUEST PIPELINEkubectl /controllerkubeconfigidentity201 + objectwatch event to allWHO ARE YOUauthenticationcert, token, OIDC→ user + groupsno user objects existMAY YOURBAC authorisationpurely additive, no deny→ allow or 403first matching rule winsMUTATE THEN VALIDATEmutating admissionwebhooks, defaultingschema validationOpenAPIvalidating admissionPod Security, policyquotathe last gatePERSISTetcd writeserialised, encrypted at rest if configuredwatch fan-outevery controller sees it
Four gates, in this order, on every request including a controller's. A 403 came from gate two and mentions the verb and resource; a 422 came from gate three and mentions the field.
ConnectionWhy can a Secret be read by someone you never granted it to?
ServiceAccounttokenauthenticationidentity establishedRoleBindings in nsnamespace-scopedClusterRoleBindingscluster-wide, easy to missunion of all rulesadditive — no deny existsget secrets →allowedbase64, not encryptedmounted in a podreadable by the process
RBAC has no deny rule. Access is the union of every binding that matches, so auditing 'who can read this Secret' means enumerating bindings, not reading one policy.
Core

CONFIG, SECRETS, BOUNDARIES AND PERMISSIONS

The four objects every cluster uses, and the property of each that is not what people assume.

A ConfigMap is a key/value object consumed in one of two ways, and the two behave completely differently at runtime:

Consumed asOn ConfigMap update
env / envFromNever changes. Environment is set once at container start.
Volume mountUpdated in place, typically within a minute (kubelet sync period).
Volume mount with subPathNever changes. subPath breaks the symlink swap the update relies on.

"We updated the ConfigMap and nothing happened" is almost always row 1 or row 3. Neither is a bug; both are documented; both are invisible unless you know to look.

Making a change actually roll

If the app cannot reload config itself, the honest answer is to restart it — and the tidy way is to put a hash of the config in the pod template annotation, so changing the ConfigMap changes the Deployment and triggers a normal rolling update.

three ways to make config changes take effect
1. immediate, manual
$ kubectl rollout restart deploy/api
2. automatic — annotate the POD TEMPLATE with a hash of the config
spec: { template: { metadata: { annotations: {
checksum/config: "<sha256 of the configmap>" } } } }
changing the ConfigMap changes the hash, which changes the template,
which is a new revision — a normal rolling update, no special tooling
3. confirm what the container currently sees, rather than assuming:
$ kubectl exec deploy/api -- cat /etc/app/config.yaml
$ kubectl exec deploy/api -- env | grep LOG_LEVEL

A Secret is a ConfigMap with a different name and base64-encoded values. base64 is encoding, not encryption — anyone who can read the object can read the value, and by default the bytes sit in etcd in the clear.

Three things actually protect a Secret, and all three are opt-in:

  • Encryption at rest. An EncryptionConfiguration on the API server encrypts Secrets before they reach etcd — ideally with a KMS provider rather than a local key that sits next to the data it protects.
  • RBAC. get secrets in a namespace means every secret in it. There is no per-object RBAC in core Kubernetes, so "read one secret" is really "read them all" unless you split namespaces.
  • Not mounting them. Since v1.24, ServiceAccount tokens are short-lived projected volumes rather than permanent Secret objects — a real improvement, and worth checking you are not still creating long-lived token Secrets by hand.

The audit question you will eventually be asked

"Who can read the database password?" is an RBAC query, not a Secret query — and it has a precise answer.

answering 'who can read this' properly
$ kubectl auth can-i get secrets -n prod --as=system:serviceaccount:prod:api-sa
everyone with the verb, across all subjects:
$ kubectl get rolebindings,clusterrolebindings -A -o json | jq -r '.items[]
| select(.roleRef.name|test("admin|edit|secret";"i"))
| "\(.kind)\t\(.metadata.namespace // "-")/\(.metadata.name)\t-> \(.roleRef.name)"'
is encryption at rest actually on? read a raw value straight out of etcd:
$ kubectl -n kube-system exec etcd-master-0 -- etcdctl \
get /registry/secrets/prod/db-creds | hexdump -C | head -3
plaintext you can read = not encrypted. 'k8s:enc:' prefix = encrypted.
A Secret in git is a Secret in git

Sealed Secrets, SOPS or an external store (Vault, a cloud secret manager via the Secrets Store CSI driver) all solve this. Base64 in a committed manifest solves nothing — it is a rendering choice, not a security boundary, and the repo history keeps it after you delete the file.

A namespace scopes names, and gives you something to attach RBAC, ResourceQuota and LimitRange to. That is genuinely useful, and it is also the whole list.

What a namespace does not isolate, and each has bitten someone:

  • The network. Every pod can reach every other pod in every namespace until a NetworkPolicy says otherwise.
  • Nodes. Pods from different namespaces share hardware, page cache and kernel.
  • Cluster-scoped objects. Nodes, PersistentVolumes, StorageClasses, CRDs and ClusterRoles belong to nobody's namespace.
  • Resources, unless you say so. Without a ResourceQuota one namespace can consume the whole cluster.

So "we put the untrusted tenant in its own namespace" is not a security statement by itself. It becomes one when you add a default-deny NetworkPolicy, a quota, a restricted Pod Security Admission level and — for genuinely untrusted workloads — separate nodes or a separate cluster.

a namespace that actually holds a boundary
1. quota, so it cannot eat the cluster
$ kubectl create quota team-a --hard=cpu=20,memory=64Gi,pods=100 -n team-a
2. defaults, so pods without requests do not get unlimited
$ kubectl apply -n team-a -f limitrange.yaml
3. Pod Security Admission — one label, enforced at admission
$ kubectl label ns team-a \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/warn=restricted
4. default-deny ingress, then re-allow what is needed
$ kubectl apply -n team-a -f default-deny.yaml
and check what is actually enforced rather than what you intended:
$ kubectl get ns team-a -o jsonpath='{.metadata.labels}' | jq
$ kubectl describe quota -n team-a

Four objects, two axes. Role and RoleBinding are namespaced; ClusterRole and ClusterRoleBinding are not:

CombinationGrants
Role + RoleBindingThose verbs in that one namespace
ClusterRole + RoleBindingThe ClusterRole's verbs, but only in the binding's namespace — the useful one people forget
ClusterRole + ClusterRoleBindingEvery namespace, plus cluster-scoped resources
Role + ClusterRoleBindingInvalid. Silently grants nothing.

The rule that explains the surprises

RBAC is purely additive and has no deny. Effective permission is the union of every binding that matches you. So you cannot subtract a permission with another rule — you have to find and remove the binding that granted it. And a user in three groups has all three groups' permissions, which is why "I thought we removed their access" is usually a second binding nobody looked for.

Escalation paths that do not look like admin

Several innocuous-looking verbs are effectively cluster-admin: create pods (mount any secret, or a hostPath), escalate/bind (grant yourself more), impersonate (become anyone), and get secrets where a privileged ServiceAccount token lives.

stop reading bindings and ask the API
$ kubectl auth can-i --list -n prod --as=jane
$ kubectl auth can-i create pods -n prod --as=jane
the service-account form, which is where this usually matters:
$ kubectl auth can-i list secrets -A \
--as=system:serviceaccount:prod:api-sa
every binding that mentions a subject — the 'second binding' problem:
$ kubectl get rolebindings,clusterrolebindings -A -o json \
| jq -r --arg who jane '.items[] | select(.subjects[]?.name==$who)
| "\(.kind) \(.metadata.namespace // "cluster")/\(.metadata.name) -> \(.roleRef.name)"'
who holds cluster-admin, which should be a very short list:
$ kubectl get clusterrolebindings -o json | jq -r '.items[]
| select(.roleRef.name=="cluster-admin") | .subjects[]?.name'
In practice

ADVANCED TROUBLESHOOTING

Access failures and config failures look identical from the outside — the pod does not work — and are diagnosed completely differently. The refusal wording tells you which gate said no.

What you seeWhich gateNext command
Unauthorized / 401Authentication — the identity did not resolvekubectl auth whoami; check the kubeconfig context
Forbidden: User "x" cannot <verb>RBACkubectl auth can-i --list --as=x
violates PodSecurity "restricted"Pod Security Admissionkubectl get ns <n> -o jsonpath='{.metadata.labels}'
exceeded quotaResourceQuotakubectl describe quota -n <n>
admission webhook ... deniedA validating webhookkubectl get validatingwebhookconfigurations
Pod runs, config is staleenv vars or a subPath mountkubectl exec -- env; then the volume's subPath
CreateContainerConfigErrorA referenced ConfigMap or Secret is missingkubectl describe pod names the key it could not find

The webhook that takes the cluster down with it

A validating or mutating webhook with failurePolicy: Fail whose backing service is unavailable rejects every matching API write. If its own namespace is in scope, you cannot deploy the fix — including the webhook itself. This is a genuine "cannot deploy anything" outage and the escape is to delete the webhook configuration.

when the cluster refuses every write
$ kubectl get validatingwebhookconfigurations,mutatingwebhookconfigurations
$ kubectl get validatingwebhookconfiguration <name> -o json \
| jq '.webhooks[] | {name, failurePolicy, namespaceSelector}'
if the backing service is down and failurePolicy is Fail, remove the config.
back it up first — this IS the emergency brake, not a fix:
$ kubectl get validatingwebhookconfiguration <name> -o yaml > /tmp/whc.yaml
$ kubectl delete validatingwebhookconfiguration <name>
auth can-i --as is the whole debugging story for RBAC

Reading Roles and bindings by hand gets the answer wrong, because effective permission is the union of every matching binding including ones granted through groups. kubectl auth can-i --list --as=<subject> asks the API server to do the resolution it will actually do.

Reference

CHEATSHEET

CommandWhat it answers
kubectl auth can-i --list -n <ns> --as=<user>Effective permissions, resolved by the API server
kubectl auth can-i <verb> <res> --as=system:serviceaccount:<ns>:<sa>The service-account form
kubectl auth whoamiWhich identity this kubeconfig presents
kubectl get clusterrolebindings -o json | jq ... cluster-adminWho holds the keys
kubectl describe quota -n <ns>Used versus hard, per resource
kubectl get ns <n> -o jsonpath='{.metadata.labels}'Which Pod Security level is enforced
kubectl exec deploy/<x> -- envWhat the container actually received
kubectl get cm,secret -n <ns>What exists to be referenced
kubectl create secret generic x --from-literal=k=v --dry-run=client -o yamlGenerate without applying
kubectl get validatingwebhookconfigurationsWhat can reject your writes
kubectl rollout restart deploy/<x>Pick up changed env-var config