Namespaces, RBAC and Kubernetes Security
Dividing and protecting a cluster: namespaces, role-based access control with Roles and RoleBindings, service accounts, Pod Security Standards and network policies.
Namespaces
A namespace is a named group of objects inside one cluster. Names only need to be unique within a namespace, and namespaces are where most controls attach: RBAC rules, resource quotas, Pod Security labels and network policies. Deleting a namespace deletes everything in it. Kubernetes Basics introduces the idea; this page is about using it to protect things.
Kestrel Energy uses one namespace per area of responsibility:
kestrel-portal (portal and meter-api, owned by Ravi
Kumar's team), kestrel-billing (the nightly billing
export, owned by Joost Bakker) and kestrel-data
(PostgreSQL, owned by Hamid Rahimi). Many organizations also
separate test and production this way within one cluster, but
separate clusters isolate far better.
Not everything lives in a namespace. Nodes, PersistentVolumes, StorageClasses, ClusterRoles and namespaces themselves are cluster-scoped:
$ kubectl api-resources --namespaced=false
NAME SHORTNAMES APIVERSION NAMESPACED KIND … namespaces ns v1 false Namespace nodes no v1 false Node persistentvolumes pv v1 false PersistentVolume … clusterrolebindings rbac.authorization.k8s.io/v1 false ClusterRoleBinding clusterroles rbac.authorization.k8s.io/v1 false ClusterRole storageclasses sc storage.k8s.io/v1 false StorageClass …
As on the other Kubernetes pages, cluster output here is illustrative, written from the Kubernetes documentation: the test machine for this topic could not run a cluster.
Who is asking, and are they allowed?
Every request to the API server — from kubectl, a
pipeline or a controller — passes three gates in order:
- Authentication: who are you? Kubernetes has no user database of its own. People are identified by a client certificate or, better, by a token from the organization's identity provider through OpenID Connect; programs inside the cluster use service account tokens.
- Authorization: may this identity do this verb on this resource in this namespace? In practically every cluster this is decided by RBAC, described below.
- Admission: is the object itself acceptable? Admission controllers can reject or adjust objects — Pod Security Admission, further down, is one of them.
Because users come from outside, removing a leaver from the identity provider removes their access — but a client certificate handed out earlier stays valid until it expires. That is one reason to prefer the identity provider for people.
Role-based access control
Kubernetes applies role-based access control with four object kinds. Permissions are purely additive: there are no deny rules, so if no rule allows a request, it is refused, and if any rule allows it, it is allowed.
Roles and ClusterRoles
A Role is a namespaced list
of rules: which verbs (get, list,
watch, create, update,
patch, delete) are allowed on which
resources (pods, configmaps,
deployments…) of which API groups, in that one
namespace.
A ClusterRole has the same rules
but is not tied to a namespace. It is used for cluster-scoped
resources such as nodes, and as a reusable template: define "pod
viewer" once and grant it in several namespaces. Kubernetes ships
ready-made ClusterRoles such as view,
edit, admin and
cluster-admin.
RoleBindings and ClusterRoleBindings
A RoleBinding grants the rules of one Role or ClusterRole to subjects — users, groups or service accounts — within the binding's namespace. A RoleBinding that refers to a ClusterRole still grants it only in that one namespace. A ClusterRoleBinding grants a ClusterRole in every namespace and on cluster-scoped resources, which is why there should be very few of them.
The billing export reads a tariff ConfigMap through the API while it runs, so its service account needs exactly that and nothing more:
apiVersion: v1
kind: ServiceAccount
metadata:
name: billing-export
namespace: kestrel-billing
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: billing-export-reader
namespace: kestrel-billing
rules:
- apiGroups: [""] # "" is the core API group (pods, configmaps, secrets…)
resources: ["configmaps"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: read-config
namespace: kestrel-billing
subjects:
- kind: ServiceAccount
name: billing-export
namespace: kestrel-billing
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: billing-export-reader
And the NOC (network operations center) group, Tomasz Nowak and Emeka Obi, may read pods and their logs in the portal namespace — granted with a reusable ClusterRole, bound in one namespace only:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: pod-viewer
rules:
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: noc-pod-viewer
namespace: kestrel-portal
subjects:
- kind: Group
name: kestrel-noc # the group name as the identity provider sends it
apiGroup: rbac.authorization.k8s.io
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: pod-viewer
How a request is decided
This sketch evaluates requests the way the RBAC authorizer does: look at every binding that applies to the subject in that namespace, and allow if any rule matches. (Real rules also match on API groups and can name individual objects; that is left out here.)
# Roles (namespaced) and ClusterRoles (cluster-wide) hold rules.
roles = {
("kestrel-billing", "billing-export-reader"): [
{"resources": ["configmaps"], "verbs": ["get", "list"]},
],
("kestrel-portal", "deployer"): [
{"resources": ["deployments"], "verbs": ["*"]},
],
}
cluster_roles = {
"pod-viewer": [{"resources": ["pods", "pods/log"], "verbs": ["get", "list"]}],
}
# Bindings give a role to subjects. A RoleBinding works in ONE namespace,
# even when it points at a ClusterRole.
role_bindings = [
("kestrel-billing", "Role", "billing-export-reader",
[("ServiceAccount", "kestrel-billing/billing-export")]),
("kestrel-portal", "Role", "deployer",
[("ServiceAccount", "kestrel-portal/ci-deployer")]),
("kestrel-portal", "ClusterRole", "pod-viewer", [("Group", "kestrel-noc")]),
]
def allows(rules, verb, resource):
return any((verb in r["verbs"] or "*" in r["verbs"]) and resource in r["resources"]
for r in rules)
def can(subject, verb, resource, namespace):
"""Permissions only add up: one matching rule allows, nothing denies."""
for ns, kind, name, subjects in role_bindings:
if ns != namespace or subject not in subjects:
continue
rules = roles.get((ns, name), []) if kind == "Role" else cluster_roles[name]
if allows(rules, verb, resource):
return "yes"
return "no"
billing = ("ServiceAccount", "kestrel-billing/billing-export")
noc = ("Group", "kestrel-noc")
questions = [
(billing, "get", "configmaps", "kestrel-billing"),
(billing, "list", "secrets", "kestrel-billing"),
(billing, "get", "configmaps", "kestrel-portal"),
(("ServiceAccount", "kestrel-portal/ci-deployer"), "delete", "deployments", "kestrel-portal"),
(noc, "get", "pods/log", "kestrel-portal"),
(noc, "get", "pods", "kestrel-billing"),
]
for subject, verb, resource, namespace in questions:
who = subject[1].split("/")[-1]
print(f"{who:<15} {verb:<6} {resource:<11} in {namespace:<16} {can(subject, verb, resource, namespace)}")
billing-export get configmaps in kestrel-billing yes billing-export list secrets in kestrel-billing no billing-export get configmaps in kestrel-portal no ci-deployer delete deployments in kestrel-portal yes kestrel-noc get pods/log in kestrel-portal yes kestrel-noc get pods in kestrel-billing no
The billing export cannot read Secrets, and its Role does nothing
outside its own namespace. The NOC can read pod logs in the portal
namespace but not in billing, although pod-viewer is a
ClusterRole: the RoleBinding limits it. In a real cluster you ask
the same question with kubectl auth can-i;
--as impersonates another identity, which itself
requires the impersonate permission:
$ kubectl auth can-i list secrets -n kestrel-billing \
--as=system:serviceaccount:kestrel-billing:billing-export
$ kubectl auth can-i get configmaps -n kestrel-billing \
--as=system:serviceaccount:kestrel-billing:billing-export
no yes
Service accounts
A
ServiceAccount
is the Kubernetes identity for a program: every pod runs as one, and
RBAC bindings can name it. Each namespace gets a service account
called default, which pods use unless their spec says
serviceAccountName. It is the Kubernetes form of the
general
service account; the identity side, including how cloud providers trust these
tokens for workload identity federation, is covered in
Identity for Applications and Machines.
By default the kubelet mounts a short-lived, automatically rotated token for the pod's service account into the container. Most applications never talk to the Kubernetes API and do not need it; for them, switch it off so a compromised container has no token to steal:
apiVersion: v1
kind: ServiceAccount
metadata:
name: portal
namespace: kestrel-portal
automountServiceAccountToken: false # the portal never calls the Kubernetes API
- Give each workload its own service account, so permissions and audit logs say which program did something.
-
Never bind roles to a namespace's
defaultservice account: every pod that forgets to set its own gets those rights. - Avoid long-lived token Secrets for service accounts; use the short-lived tokens Kubernetes issues.
Pod security
RBAC decides who may create a pod; pod security decides
what kind of pod may be created. A pod that runs as root,
is privileged, or mounts a directory of its node
(hostPath) gives an attacker who takes over the
container a short path to the node and from there to everything on
it. The Docker side of the same ideas — non-root users, dropped
capabilities, seccomp — is on
Container Security.
Pod Security Standards
The Pod Security Standards define three cumulative levels:
| Level | What it allows | Typical use |
|---|---|---|
privileged |
Everything; no restrictions | System components such as network and storage plugins |
baseline |
Blocks known privilege escalations: privileged containers,
host namespaces and ports, hostPath volumes,
extra capabilities
|
A minimum for ordinary applications |
restricted |
Baseline plus hardening: must run as non-root, must not allow privilege escalation, must use a seccomp profile, must drop all capabilities, only safe volume types | The target for application namespaces |
A billing-export CronJob that passes restricted:
apiVersion: batch/v1
kind: CronJob
metadata:
name: billing-export
namespace: kestrel-billing
spec:
schedule: "30 2 * * *"
timeZone: Europe/Amsterdam
concurrencyPolicy: Forbid
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
serviceAccountName: billing-export
restartPolicy: Never
securityContext: # for every container in the pod
runAsNonRoot: true
runAsUser: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: billing-export
image: registry.example.com/kestrel/billing-export:1.0.7
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true # not required by restricted, still good practice
The restricted rules can be checked mechanically. This sketch covers the main ones:
ALLOWED_VOLUMES = {"configMap", "csi", "downwardAPI", "emptyDir", "ephemeral",
"persistentVolumeClaim", "projected", "secret"}
def restricted_violations(pod):
"""Check a pod spec against part of the Pod Security Standard 'restricted'."""
problems = []
pod_ctx = pod.get("securityContext", {})
for volume in pod.get("volumes", []):
kind = next(k for k in volume if k != "name")
if kind not in ALLOWED_VOLUMES:
problems.append(f"volume {volume['name']}: type {kind} not allowed")
for c in pod["containers"]:
ctx = {**pod_ctx, **c.get("securityContext", {})} # container wins
name = c["name"]
if ctx.get("privileged"):
problems.append(f"{name}: privileged")
if ctx.get("allowPrivilegeEscalation") is not False:
problems.append(f"{name}: allowPrivilegeEscalation must be false")
if ctx.get("runAsNonRoot") is not True:
problems.append(f"{name}: runAsNonRoot must be true")
if ctx.get("runAsUser") == 0:
problems.append(f"{name}: runAsUser 0 (root)")
if ctx.get("seccompProfile", {}).get("type") not in ("RuntimeDefault", "Localhost"):
problems.append(f"{name}: seccompProfile must be RuntimeDefault or Localhost")
caps = ctx.get("capabilities", {})
if "ALL" not in caps.get("drop", []):
problems.append(f"{name}: must drop ALL capabilities")
if set(caps.get("add", [])) - {"NET_BIND_SERVICE"}:
problems.append(f"{name}: adds {sorted(caps['add'])}")
return problems
billing_export = {
"securityContext": {"runAsNonRoot": True, "runAsUser": 10001,
"seccompProfile": {"type": "RuntimeDefault"}},
"containers": [{"name": "billing-export", "securityContext": {
"allowPrivilegeEscalation": False, "capabilities": {"drop": ["ALL"]}}}],
"volumes": [{"name": "config", "configMap": {"name": "billing-export"}}],
}
quick_fix = {
"containers": [{"name": "debug", "securityContext": {
"runAsUser": 0, "capabilities": {"add": ["NET_ADMIN", "SYS_ADMIN"]}}}],
"volumes": [{"name": "host", "hostPath": {"path": "/"}}],
}
for name, pod in [("billing-export", billing_export), ("quick-fix", quick_fix)]:
problems = restricted_violations(pod)
print(f"{name}: {'passes' if not problems else str(len(problems)) + ' violations'}")
for problem in problems:
print(f" - {problem}")
billing-export: passes quick-fix: 7 violations - volume host: type hostPath not allowed - debug: allowPrivilegeEscalation must be false - debug: runAsNonRoot must be true - debug: runAsUser 0 (root) - debug: seccompProfile must be RuntimeDefault or Localhost - debug: must drop ALL capabilities - debug: adds ['NET_ADMIN', 'SYS_ADMIN']
Note what restricted does not require: a
read-only root file system. It is worth setting where the
application allows it, but older articles that list it as part of
the standard are wrong.
Pod Security Admission
Pod Security Admission
is the admission controller, built into Kubernetes and stable since
version 1.25, that applies the standards per namespace. You set it
with namespace labels of the form
pod-security.kubernetes.io/<mode>: <level>,
with three modes:
-
enforce: pods that violate the level are rejected. -
warn: the user sees a warning, but the object is accepted. -
audit: the violation is recorded in the API server's audit log.
apiVersion: v1
kind: Namespace
metadata:
name: kestrel-billing
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: v1.37 # pin the rules to a version
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/audit: restricted
Pinning enforce-version keeps the rules stable when the
cluster is upgraded; move the pin deliberately after testing. A pod
that breaks the rules is refused with a message that lists every
violation:
$ kubectl apply -f quick-fix-pod.yaml
Error from server (Forbidden): error when creating "quick-fix-pod.yaml": pods "quick-fix" is forbidden: violates PodSecurity "restricted:v1.37": allowPrivilegeEscalation != false (container "debug" must set securityContext.allowPrivilegeEscalation=false), …, runAsNonRoot != true (pod or container "debug" must set securityContext.runAsNonRoot=true), …
Network policies
By default every pod can connect to every other pod in the cluster, in every namespace, and to the outside world. A NetworkPolicy changes that for the pods it selects: once any policy selects a pod for a direction (ingress or egress), only traffic that some policy allows is let through in that direction. Like RBAC, policies only add allowances. This is microsegmentation inside the cluster, and a step toward zero trust.
The usual pattern is "deny everything, then allow what is needed". For the portal namespace:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: kestrel-portal
spec:
podSelector: {} # every pod in the namespace
policyTypes: ["Ingress", "Egress"]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: kestrel-portal
spec:
podSelector: {}
policyTypes: ["Egress"]
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: meter-api-from-portal
namespace: kestrel-portal
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: meter-api
policyTypes: ["Ingress"]
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: portal
ports:
- protocol: TCP
port: 8000 # the pod's port, not the Service port
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: portal-to-meter-api
namespace: kestrel-portal
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: portal
policyTypes: ["Egress"]
egress:
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: meter-api
ports:
- protocol: TCP
port: 8000
With egress denied, both sides of a connection need a rule: the
portal must be allowed out, and meter-api must be allowed in. The
traffic from the gateway to the portal, and from meter-api to
PostgreSQL in kestrel-data, need their own policies
too. Policies match pod ports, after the
Service
has translated port 80 to 8000.
A cluster hardening checklist
- Stay on a supported version. As of October 2026 Kubernetes 1.37, 1.36 and 1.35 receive patches, and 1.34 does until its end of life on 27 October 2026. Security fixes stop at end of life.
- People through the identity provider, with groups mapped to namespaced roles; few ClusterRoleBindings; no shared admin certificates.
-
One service account per workload, token automount
off where not needed, no rights on
default. -
Pod Security Admission at
restrictedfor application namespaces,baselineat the very least. - Default-deny network policies per namespace, with explicit allowances (DNS included), on a network plugin that enforces them.
- Secrets protected: encryption at rest, narrow RBAC, ideally an external secret store — see ConfigMaps, Secrets and Configuration.
- Audit logging on for the API server, so "who deleted that Deployment?" has an answer.
- Check against a benchmark: the CIS (Center for Internet Security) Kubernetes Benchmark (v2.1.0 as of October 2026; check which Kubernetes versions it covers), with separate benchmarks for managed services such as EKS and AKS.