When we closed module 7 we left the Rutas Norte platform fully observable: probes that detect a sick process, metrics in Prometheus, dashboards in Grafana, alerts routed by Alertmanager and every log centralised in Elasticsearch. We can see absolutely everything that happens. And yet, right now, anyone with access to the cluster can read the Secret holding the credentials of bookings-postgres —the database that stores the name, the ID number, the phone and the email of every customer who has bought a ticket—, can deploy a privileged container that escapes to the node, or can push an image nobody has reviewed. And we would not know who did it.
This whole module is devoted to closing those doors. And the first one, the most important, is this: deciding who can do what. In Kubernetes that is called RBAC (Role-Based Access Control) and it is the mechanism that turns a cluster where everyone can do everything into a cluster where each person and each process has exactly the permissions they need and not one more.
In module 3 we left two loose threads that we now pick up. When talking about Secrets we said that who can read a secret is decided by RBAC. When talking about ServiceAccounts we said that the ServiceAccount answers who you are and RBAC answers what you can do, and we saw a 403 Forbidden proving that Kubernetes denies by default. This lesson explains exactly why that 403 happened and how to grant, with surgical precision, only what is strictly necessary.
Important warning. This lesson teaches the mechanics of RBAC and proposes an example design for a fictional company. The real permission design of a production cluster must be reviewed by a security professional, and if the cluster handles personal data —as is the case with
bookings-postgres— also by the organisation's compliance officer. A permissions mistake goes unnoticed until somebody takes advantage of it.
Contents
- The three gates: authentication, authorization and admission
- The subjects: users, groups and ServiceAccounts
- The four RBAC objects
- Anatomy of a rule: apiGroups, resources, verbs and resourceNames
- Subresources: the hidden power of
pods/execandpods/log - Default roles and aggregated roles
- Checking permissions with
kubectl auth can-i - The Rutas Norte RBAC design
- Why
liston secrets is the same as reading them - Privilege escalation and Kubernetes' protections
- Auditing and periodic permission reviews
- Common mistakes and tips
- Exercises
- Conclusion
- The three gates: authentication, authorization and admission
Everything in Kubernetes goes through the apiserver. When you type kubectl get pods, when the kubelet reports a node's status, when the Deployment controller creates a ReplicaSet, when occupancy-reports queries the list of pods: they are all HTTP requests to the same API. And every request crosses three gates before reaching etcd.
flowchart LR
A["HTTP request<br/>kubectl / SDK / curl"] --> B{"1. Authentication<br/>who are you?"}
B -->|401 Unauthorized| X1["Rejected"]
B -->|Valid identity| C{"2. Authorization<br/>may you do it?<br/>RBAC ACTS HERE"}
C -->|403 Forbidden| X2["Rejected"]
C -->|Allowed| D{"3. Admission control<br/>is the object acceptable?"}
D -->|Mutating: modifies| D
D -->|Validating: rejects| X3["Rejected (422 / 400)"]
D -->|Accepted| E["Schema validation"]
E --> F[("etcd")]
Let us go gate by gate.
Gate 1: authentication
It answers who are you?. The apiserver examines the credentials that come with the request and produces an identity: a user name, a list of groups and, optionally, some extra attributes. The usual mechanisms are:
| Mechanism | How the identity arrives | Typical use |
|---|---|---|
| X.509 client certificate | The certificate's CN is the user; each O is a group |
Administrators, small clusters, kubeadm |
| ServiceAccount token (JWT) | Signed by the cluster; identifies system:serviceaccount:<ns>:<name> |
Processes inside the cluster |
| OIDC (external provider) | A token from a provider such as Keycloak, Okta, Entra ID or Google | People in medium and large organisations |
| Authentication webhook | The apiserver asks an external service | Bespoke integrations |
| Cloud providers (EKS, AKS, GKE) | The provider translates its IAM identity into a Kubernetes user/groups | Managed Kubernetes (see 10-06) |
If none of them works, the request stays as system:anonymous and is normally rejected with 401 Unauthorized.
Gate 2: authorization
It answers may you do this?. The apiserver takes the identity from the previous gate and the details of the request (verb, API group, resource, namespace, name) and consults the configured authorizers. RBAC is the authorizer that practically everyone uses, although others exist (Node, ABAC, Webhook, AlwaysAllow). If no authorizer says "yes", the answer is 403 Forbidden. There is no implicit permission: Kubernetes denies by default. That was exactly the 403 we saw in 03-06.
One essential detail: RBAC only grants, it never denies. There are no deny rules. A subject's effective permission is the union of everything granted by all of their bindings. That is why removing a permission means removing the binding that grants it, not adding a rule that forbids it.
Gate 3: admission control
It answers is this object acceptable exactly as it arrives?. This is where the mutating admission controllers act (they can modify the object, like the one that injects the default ServiceAccount) and the validating ones (which only accept or reject). It is where the ResourceQuota of module 3, Pod Security Admission and the Kyverno policies live.
This third gate is the topic of 08-03; here we only name it so you can see where RBAC fits. Remember the key difference:
RBAC decides whether you have the right to perform the operation. Admission decides whether the object you send complies with the rules. You may have permission to create pods (RBAC says yes) and still see a privileged pod rejected (admission says no).
- The subjects: users, groups and ServiceAccounts
RBAC grants permissions to subjects. There are exactly three kinds.
Users (User)
Here comes the surprise that confuses almost everybody at the start:
Kubernetes does not store users. There is no
Userobject. You cannot runkubectl create user anna. There is no database of people inside the cluster.
A user is simply a text string produced by the authenticator. If you authenticate with a certificate whose CN=anna.garcia, then as far as RBAC is concerned you are the user anna.garcia. If you authenticate with OIDC and the provider returns the email [email protected], that will be your user name. The cluster trusts the authenticator, full stop.
The practical consequence matters: adding and removing people is the identity provider's responsibility, not Kubernetes'. If Anna leaves the company and you only delete her RoleBinding, but her certificate is still valid and she had permissions left through another group binding, she still gets in.
Groups (Group)
Same as users: they are strings produced by the authenticator. With an X.509 certificate, each O (organisation) field becomes a group. With OIDC, a claim such as groups is usually configured.
You should almost always grant permissions to groups, not to users. If Rutas Norte hires someone new on the platform team, you add them to the platform group in the identity provider and they already have the permissions. Not a single cluster YAML needs touching.
Kubernetes reserves some groups with a meaning of their own:
| Group | Who has it |
|---|---|
system:authenticated |
Anyone who has passed authentication |
system:unauthenticated |
Anonymous requests |
system:masters |
Full access, bypassing RBAC (emergency back door) |
system:serviceaccounts |
Every ServiceAccount in the cluster |
system:serviceaccounts:<ns> |
Every ServiceAccount in a namespace |
Extreme care with
system:masters. The apiserver grants it full permission before consulting RBAC, so it cannot be limited or revoked with a manifest. It is the identity of the administratorkubeconfiggenerated bykubeadm. That file must be guarded like a master key: kept off everybody's laptop, with logged access, and used only to recover the cluster when RBAC is broken.
ServiceAccounts
They are the identities of the processes running in the cluster, and they are indeed real Kubernetes objects (we created them in 03-06). In RBAC they are referenced in two equivalent ways:
# Recommended form inside a binding
subjects:
- kind: ServiceAccount
name: occupancy-reports
namespace: rutas-norte-pro# Equivalent form, treating it as a user
subjects:
- kind: User
name: system:serviceaccount:rutas-norte-pro:occupancy-reports
apiGroup: rbac.authorization.k8s.ioComparative summary:
| Users and groups | ServiceAccounts | |
|---|---|---|
| Do they exist as an object? | No | Yes (kubectl get sa) |
| Who manages them? | External identity provider or the cluster CA | Kubernetes |
| Namespaced? | No, they are global | Yes, they belong to a namespace |
| What for | People and external systems | Pods and controllers |
| Credential | Certificate, OIDC token | Projected JWT token |
- The four RBAC objects
RBAC has only four object types, and all of them live in the API group rbac.authorization.k8s.io/v1. The idea has a very clean symmetry:
RoleandClusterRoledescribe what can be done. They are lists of permissions, and they mention nobody.RoleBindingandClusterRoleBindingdescribe who can do it. They join some subjects to a role.
flowchart LR
subgraph What can be done
R["Role<br/>(one namespace)"]
CR["ClusterRole<br/>(the whole cluster)"]
end
subgraph Who can do it
RB["RoleBinding<br/>(one namespace)"]
CRB["ClusterRoleBinding<br/>(the whole cluster)"]
end
S["Subjects:<br/>users, groups,<br/>ServiceAccounts"]
R --> RB
CR --> RB
CR --> CRB
S --> RB
S --> CRB
The table of the four combinations
Look closely, because 90% of the confusion with RBAC is right here:
| Binding | Referenced role | Effective scope | Example in Rutas Norte |
|---|---|---|---|
RoleBinding |
Role (same namespace) |
The role's permissions, only in the binding's namespace | development manages objects in rutas-norte-dev |
RoleBinding |
ClusterRole |
The role's permissions, confined to the binding's namespace | support uses the view role only in rutas-norte-pro |
ClusterRoleBinding |
ClusterRole |
The permissions in every namespace and over global resources | platform administers the cluster |
ClusterRoleBinding |
Role |
Does not exist. Kubernetes rejects it | — |
The second row is the surprising one and at the same time the most useful. A ClusterRole grants nothing on its own: it is just a permission template. When you reference it from a RoleBinding, its rules apply only inside that binding's namespace. This lets you write the role once and reuse it in many namespaces.
# A SINGLE reusable ClusterRole: read access to the application
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: rutasnorte-application-read
rules:
- apiGroups: [""]
resources: ["pods", "services", "configmaps", "events"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets", "replicasets", "daemonsets"]
verbs: ["get", "list", "watch"]
---
# Applied ONLY to rutas-norte-pro through a RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: support-read-pro
namespace: rutas-norte-pro # <-- the scope is confined here
subjects:
- kind: Group
name: support
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole # we reference a ClusterRole...
name: rutasnorte-application-read
apiGroup: rbac.authorization.k8s.ioWith those two manifests, the support group can list pods in rutas-norte-pro and nowhere else. If tomorrow we wanted to give it read access in rutas-norte-pre too, another identical RoleBinding with a different namespace would be enough: the ClusterRole is not touched.
When you really need a ClusterRole
Some resources do not belong to any namespace: Node, PersistentVolume, StorageClass, Namespace, ClusterRole, CustomResourceDefinition... To grant permissions over them a ClusterRole referenced from a ClusterRoleBinding is mandatory. A RoleBinding can never give access to a global resource, no matter how much the ClusterRole mentions it.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: rutasnorte-infrastructure-read
rules:
- apiGroups: [""]
resources: ["nodes", "persistentvolumes", "namespaces"]
verbs: ["get", "list", "watch"]
- apiGroups: ["storage.k8s.io"]
resources: ["storageclasses", "volumeattachments"]
verbs: ["get", "list", "watch"]One detail about roleRef worth knowing: it is immutable. You cannot change which role an existing binding points to; you have to delete it and create it again. The subjects list, on the other hand, can be modified.
- Anatomy of a rule: apiGroups, resources, verbs and resourceNames
An RBAC rule is the intersection of four dimensions. All of them must match for the permission to apply.
rules:
- apiGroups: ["apps"] # 1. from which API group?
resources: ["deployments"] # 2. which kind of resource?
resourceNames: ["bookings-api"] # 3. which specific object? (optional)
verbs: ["get", "patch", "update"] # 4. which operations?apiGroups: which API family we are talking about
Every Kubernetes resource belongs to an API group. The core group (also called legacy) is historical and is represented by the empty string "", not by core. That is where Pods, Services, ConfigMaps, Secrets, ServiceAccounts, Nodes, PersistentVolumeClaims and Namespaces live.
| Group | Usual resources |
|---|---|
"" (core) |
pods, services, configmaps, secrets, serviceaccounts, persistentvolumeclaims, events, nodes, namespaces |
apps |
deployments, statefulsets, daemonsets, replicasets |
batch |
jobs, cronjobs |
networking.k8s.io |
ingresses, networkpolicies, ingressclasses |
rbac.authorization.k8s.io |
roles, rolebindings, clusterroles, clusterrolebindings |
storage.k8s.io |
storageclasses, volumeattachments, csidrivers |
autoscaling |
horizontalpodautoscalers |
policy |
poddisruptionbudgets |
monitoring.coreos.com |
servicemonitors, prometheusrules (CRDs from module 7) |
Writing apiGroups: ["core"] is a very common silent mistake: it does not fail when applying the manifest, it simply grants nothing, because there is no group called core.
To find out the group and the exact plural name of any resource:
# Lists every resource with its group (APIVERSION), whether it is namespaced and its KIND
kubectl api-resourcesNAME SHORTNAMES APIVERSION NAMESPACED KIND
configmaps cm v1 true ConfigMap
pods po v1 true Pod
secrets v1 true Secret
deployments deploy apps/v1 true Deployment
statefulsets sts apps/v1 true StatefulSet
cronjobs cj batch/v1 true CronJob
ingresses ing networking.k8s.io/v1 true Ingress
networkpolicies netpol networking.k8s.io/v1 true NetworkPolicy
nodes no v1 false Node
storageclasses sc storage.k8s.io/v1 false StorageClassWhen APIVERSION is just v1 (no slash), the group is the empty one "". When it is apps/v1, the group is apps. The NAMESPACED column tells you whether you need a Role or necessarily a ClusterRole.
In a rule you always use the lowercase plural name from the NAME column, never the KIND nor the short name. resources: ["Pod"] or resources: ["po"] grant nothing.
verbs: which operations are allowed
| Verb | HTTP operation | What it allows |
|---|---|---|
get |
GET on an object | Read one object by its name |
list |
GET on the collection | List objects, including their full contents |
watch |
GET with ?watch=true |
Receive changes in real time |
create |
POST | Create new objects |
update |
PUT | Replace a whole object |
patch |
PATCH | Partially modify an object |
delete |
DELETE on an object | Delete one by name |
deletecollection |
DELETE on the collection | Delete every object matching a selector |
And three special verbs that do not correspond to a normal operation:
| Special verb | On which resource | What it means |
|---|---|---|
bind |
roles, clusterroles |
Allows creating bindings to that role even without holding its permissions |
escalate |
roles, clusterroles |
Allows creating or editing roles with permissions you do not have |
impersonate |
users, groups, serviceaccounts |
Allows acting on behalf of another identity |
All three are, in practice, privilege escalation routes. We will come back to them in section 10.
An important warning about deletecollection: many people overlook it when building a role for "being able to delete specific things". If you grant delete with resourceNames but also deletecollection without restriction, you have opened the door to deleting everything, because resourceNames does not apply to collection operations.
resourceNames: restricting to specific objects
It lets you confine a rule to objects with specific names:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: web-store-config-read
namespace: rutas-norte-pro
rules:
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["web-store-config"] # ONLY this ConfigMap
verbs: ["get"]With this role you can run kubectl get configmap web-store-config but not kubectl get configmap any-other-one.
The fundamental limitation of
resourceNames. It does not work withlist,watch,createordeletecollection. The reason is simple: when you ask for a list, you do not yet know which objects exist, so the authorizer cannot filter by name —it can only allow or deny the whole request—. RBAC is not a content filter.
Practical consequence: resourceNames restricts individual reads, but it does not prevent a listing if you have granted list. If your intention is "they should only see this ConfigMap", you have to grant get with resourceNames and not grant list. The trade-off is that kubectl get configmaps (without a name) will fail with a 403, which baffles users; it has to be documented.
Even with that limitation, resourceNames is very valuable in one specific case: giving a process permission to update exactly one object. We will use it with ci-rutasnorte.
- Subresources: the hidden power of
pods/exec and pods/log
pods/exec and pods/logSome resources have subresources: child API paths with their own access control. They are written with a slash in the resources field.
| Subresource | What it allows | Risk |
|---|---|---|
pods/log |
Read the container's logs | Medium: logs may contain sensitive data |
pods/exec |
Open a process inside the container | Very high: full interactive access to the container |
pods/attach |
Connect to the main process | Very high: in practice equivalent to exec |
pods/portforward |
Open a tunnel to a pod port | High: reaches internal services from a laptop |
pods/ephemeralcontainers |
Inject ephemeral containers (kubectl debug, see 07-06) |
Very high |
pods/status |
Update the pod's status | Low, used by controllers |
deployments/scale |
Change only the number of replicas | Low, very useful |
serviceaccounts/token |
Issue a token for a ServiceAccount | Critical: impersonate that process |
nodes/proxy |
Talk to the kubelet API | Critical |
The key point that is constantly forgotten:
get podsandget pods/logare different permissions. Being able to see that a pod exists gives no right to read its logs. Andpods/execis not granted by thegetverb onpods: it has to be named explicitly. This is good news, because it lets you give visibility without giving access.
An example directly applicable to Rutas Norte: the support team needs to see whether a pod is down and read its logs to answer a customer who says the confirmation email never arrived. It does not need to get inside the container.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: rutasnorte-support
rules:
# See the status of the pods and of the objects that govern them
- apiGroups: [""]
resources: ["pods", "services", "events", "endpoints"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets", "replicasets"]
verbs: ["get", "list", "watch"]
# Read logs: explicit subresource
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get", "list"]
# NOTE: there is no "secrets", no "pods/exec", no "pods/portforward",
# and no "pods/ephemeralcontainers". That is deliberate and is the heart of the role.Notice what is not there. A security role is judged as much by what it omits as by what it includes. If support had pods/exec on bookings-api, it could open a shell, read the process's environment variables and obtain the connection string to the customer database. The boundary between "seeing the status" and "reading personal data" is exactly that line of YAML.
And be careful with pods/log even so: in 07-05 we explicitly forbade logging personal data precisely because a log-read permission is far easier to grant than a database-read one. The two decisions reinforce each other.
- Default roles and aggregated roles
Every cluster ships with dozens of predefined ClusterRole objects. Many are internal (system:kube-scheduler, system:node...), but four are meant for people:
| ClusterRole | Recommended scope | What it allows | Dangers |
|---|---|---|---|
cluster-admin |
The whole cluster | Absolutely everything, including modifying RBAC | It is * on *. Emergencies only |
admin |
One namespace (via RoleBinding) |
Manage everything in the namespace, including Secrets, Roles and RoleBindings | Can read every Secret in the namespace |
edit |
One namespace | Create and modify most objects; cannot touch Roles or RoleBindings | It can read and create Secrets |
view |
One namespace | Read-only on most objects; does not see Secrets | The safest of the four |
Two nuances that decide entire designs:
editcan read Secrets. Many people granteditin production thinking "it only edits, it does not administer", and with that they have handed over the customer database credentials. If this matters to you —and at Rutas Norte it matters a great deal—, you cannot useeditas-is inrutas-norte-pro.viewdoes not see Secrets (that was an explicit project decision), but it does see ConfigMaps. This reinforces what we said in 03-01 and 03-02: sensitive data goes in Secrets, never in ConfigMaps, even if it is "only a connection string without a password".
Checking exactly what a role does before using it is a mandatory habit:
kubectl describe clusterrole view | head -30
kubectl get clusterrole edit -o yaml | grep -A3 secretsAggregated roles (aggregationRule)
Some default roles have no rules of their own: they are composed automatically from the rules of every ClusterRole carrying certain labels. That is the aggregationRule.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: view
aggregationRule:
clusterRoleSelectors:
- matchLabels:
rbac.authorization.k8s.io/aggregate-to-view: "true"
rules:
- ... # filled in automatically by the controllerThe aggregation controller watches the ClusterRole objects with that label and copies their rules into view. This is enormously useful when you install CRDs: you can make whoever has view also see your custom resources without editing the view role (which would be overwritten on every cluster upgrade).
A real example for Rutas Norte: in module 7 we installed the Prometheus operator, which brings ServiceMonitor and PrometheusRule. We want support and development to be able to see them.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: rutasnorte-aggregate-monitoring-read
labels:
# These labels make the rules add up to the default roles
rbac.authorization.k8s.io/aggregate-to-view: "true"
rbac.authorization.k8s.io/aggregate-to-edit: "true"
rbac.authorization.k8s.io/aggregate-to-admin: "true"
rules:
- apiGroups: ["monitoring.coreos.com"]
resources: ["servicemonitors", "prometheusrules", "podmonitors"]
verbs: ["get", "list", "watch"]Once applied, anyone who has view in any namespace starts seeing the ServiceMonitor objects of that namespace, without touching a single binding. Note the hierarchy: admin aggregates what edit has, and edit aggregates what view has, so if you only set aggregate-to-view, all three inherit it anyway. Setting all three is explicit and does no harm.
- Checking permissions with
kubectl auth can-i
kubectl auth can-iWriting RBAC without verifying it is like writing tests without running them. kubectl auth can-i answers the exact question the apiserver asks itself.
The truly powerful part is --as and --as-group, which ask on behalf of another identity (this uses the impersonation mechanism, so you need the impersonate verb or to be an administrator):
# Can the support team get inside a production container?
kubectl auth can-i create pods/exec \
--as-group support \
--as [email protected] \
--namespace rutas-norte-pro# Can it read the logs? (this one must work)
kubectl auth can-i get pods/log \
--as-group support \
--as [email protected] \
--namespace rutas-norte-proYou can also check ServiceAccounts, which is how a process's permissions are audited:
kubectl auth can-i list secrets \
--as system:serviceaccount:rutas-norte-pro:occupancy-reports \
--namespace rutas-norte-proAnd a full view of everything a subject can do in a namespace:
kubectl auth can-i --list \
--as system:serviceaccount:rutas-norte-pro:occupancy-reports \
--namespace rutas-norte-proResources Non-Resource URLs Resource Names Verbs
selfsubjectreviews.authentication.k8s.io [] [] [create]
selfsubjectaccessreviews.authorization.k8s.io [] [] [create]
pods [] [] [get list]
configmaps [] [reports-config] [get]
[/api/*] [] [get]That output is exactly the "permission sheet" to review in every audit. If something you did not expect shows up, you have one binding too many.
kubectl auth whoami
Stable since 1.28, it answers "who does the cluster think I am?". It is the tool for diagnosing authentication problems, which are constantly mistaken for authorization problems.
ATTRIBUTE VALUE
Username [email protected]
Groups [development system:authenticated]A useful diagnostic pattern:
| Symptom | Check | Likely cause |
|---|---|---|
401 Unauthorized |
kubectl auth whoami fails |
Expired or misconfigured credential |
403 Forbidden and whoami shows the expected groups |
kubectl auth can-i |
A binding is missing or the role does not cover the verb/resource |
403 and whoami shows unexpected groups |
OIDC configuration | The provider is not sending the right groups |
403 in one namespace only |
RoleBinding in the namespace |
The binding exists in another namespace |
A verification script worth keeping in the repository next to the manifests, so it can be run after every RBAC change:
#!/usr/bin/env bash
# k8s/rbac/verify-rbac.sh
# Checks that the Rutas Norte RBAC grants and denies what is expected.
set -euo pipefail
failures=0
check() {
local expected="$1"; shift
local description="$1"; shift
local result
result=$(kubectl auth can-i "$@" 2>/dev/null || true)
if [[ "$result" == "$expected" ]]; then
echo "OK $description (=$expected)"
else
echo "FAIL $description: expected '$expected', got '$result'"
failures=$((failures + 1))
fi
}
# What support MUST be able to do
check yes "support reads logs in pro" \
get pods/log --as-group support --as reviewer --namespace rutas-norte-pro
# What support must NEVER be able to do
check no "support does NOT enter pro containers" \
create pods/exec --as-group support --as reviewer --namespace rutas-norte-pro
check no "support does NOT read pro secrets" \
get secrets --as-group support --as reviewer --namespace rutas-norte-pro
# What development must NOT be able to do in production
check no "development does NOT delete deployments in pro" \
delete deployments --as-group development --as reviewer --namespace rutas-norte-pro
check no "development does NOT read pro secrets" \
get secrets --as-group development --as reviewer --namespace rutas-norte-pro
# What CI MUST and MUST NOT be able to do
check yes "CI updates deployments in pro" \
patch deployments --as system:serviceaccount:rutas-norte-pro:ci-rutasnorte \
--namespace rutas-norte-pro
check no "CI does NOT delete deployments in pro" \
delete deployments --as system:serviceaccount:rutas-norte-pro:ci-rutasnorte \
--namespace rutas-norte-pro
exit $failuresRunning this script on every change turns RBAC into something verifiable. Without it, a badly copied roleRef can go unnoticed for months.
- The Rutas Norte RBAC design
Now we apply everything to the company's four groups of people and to the processes' ServiceAccounts. The guiding principle is least privilege: each subject gets just enough for its job.
Summary of the design:
| Subject | rutas-norte-dev |
rutas-norte-pre |
rutas-norte-pro |
Global resources |
|---|---|---|---|---|
development |
Broad (own edit, with Secrets) |
Read + logs | Read-only, no Secrets, no exec | None |
platform |
Administration | Administration | Administration | Read nodes, PVs, SCs |
support |
— | — | Read pods and logs, no Secrets or exec | None |
ci-rutasnorte |
Deploy | Deploy | Only update Deployments/StatefulSets | None |
The development team
In rutas-norte-dev they need to work freely. We use the edit ClusterRole confined with a RoleBinding:
# k8s/rbac/development-dev.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: development-edit-dev
namespace: rutas-norte-dev
labels:
app.kubernetes.io/part-of: rutas-norte
subjects:
- kind: Group
name: development
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: edit # includes reading and creating Secrets: acceptable in dev
apiGroup: rbac.authorization.k8s.ioIt is acceptable because in rutas-norte-dev there is no real customer data: only synthetic data generated for testing. That statement is a design decision that must be documented and verified, not an assumption. If one day somebody copied a production dump into development, this binding would become a serious incident. It is exactly the kind of rule the compliance officer must be aware of.
In rutas-norte-pro the approach changes completely. Developers need to investigate —see the status, read logs, check events— but not touch anything or read credentials.
# k8s/rbac/development-pro.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: rutasnorte-diagnostics-read
rules:
- apiGroups: [""]
resources:
["pods", "services", "configmaps", "events", "endpoints",
"persistentvolumeclaims", "serviceaccounts"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets", "daemonsets", "replicasets"]
verbs: ["get", "list", "watch"]
- apiGroups: ["batch"]
resources: ["jobs", "cronjobs"]
verbs: ["get", "list", "watch"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses", "networkpolicies"]
verbs: ["get", "list", "watch"]
- apiGroups: ["autoscaling"]
resources: ["horizontalpodautoscalers"]
verbs: ["get", "list", "watch"]
# Logs yes; exec, attach, portforward and ephemeral containers NO.
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get", "list"]
# "secrets" appears in no rule. Deliberate.
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: development-read-pro
namespace: rutas-norte-pro
subjects:
- kind: Group
name: development
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: rutasnorte-diagnostics-read
apiGroup: rbac.authorization.k8s.ioAnd the same ClusterRole, plus the log permission, is reused in rutas-norte-pre without writing new rules. That is the advantage of the RoleBinding → ClusterRole combination.
The platform team (SRE)
It administers the three namespaces. We could give them cluster-admin, but that is excessive and makes it impossible to tell a routine operation from an anomalous one in the audit log (08-06). We prefer admin per namespace plus a ClusterRole for reading infrastructure:
# k8s/rbac/platform.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: platform-admin
namespace: rutas-norte-pro
subjects:
- kind: Group
name: platform
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: admin # full management of the namespace, including Secrets and local RBAC
apiGroup: rbac.authorization.k8s.io
---
# (the same RoleBinding replicated in rutas-norte-dev and rutas-norte-pre)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: platform-infrastructure
subjects:
- kind: Group
name: platform
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: rutasnorte-infrastructure-read # nodes, PVs, StorageClasses: read-only
apiGroup: rbac.authorization.k8s.iocluster-admin is reserved for an emergency procedure with logged access and prior approval. In many organisations this is implemented with temporary access (just-in-time): an external system creates the ClusterRoleBinding, announces it on a public channel and deletes it automatically after two hours.
Customer support (support)
We already wrote its ClusterRole in section 5. Only the binding is missing, exclusively in production:
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: support-pro
namespace: rutas-norte-pro
subjects:
- kind: Group
name: support
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: rutasnorte-support
apiGroup: rbac.authorization.k8s.ioThe continuous integration pipeline (ci-rutasnorte)
This is the most interesting case, because CI is a very tempting target: if somebody compromises the CI system and it has cluster-admin, they have the cluster. We give it exactly what it needs in order to deploy: update the image of the existing Deployments and StatefulSets. Nothing else.
# k8s/rbac/ci.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: ci-rutasnorte
namespace: rutas-norte-pro
automountServiceAccountToken: false # nobody mounts this token in a pod (see 03-06)
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ci-deploy
namespace: rutas-norte-pro
rules:
# Update existing workloads: yes. Create or delete them: no.
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets"]
verbs: ["get", "list", "patch", "update"]
# Check the result of the deployment (kubectl rollout status)
- apiGroups: ["apps"]
resources: ["replicasets"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["pods", "events"]
verbs: ["get", "list"]
# There is NO: create, delete, deletecollection, secrets, pods/exec,
# roles, rolebindings, serviceaccounts.
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ci-deploy
namespace: rutas-norte-pro
subjects:
- kind: ServiceAccount
name: ci-rutasnorte
namespace: rutas-norte-pro
roleRef:
kind: Role
name: ci-deploy
apiGroup: rbac.authorization.k8s.ioLet us analyse the decisions:
- No
create: new objects are created by someone fromplatformafter review. CI only updates what already exists and has been reviewed. - No
deleteordeletecollection: a compromised CI cannot delete the platform. - No
secrets: CI does not read cluster credentials. Its own live in the pipeline's secret manager. - No
pods/exec: there is no way for CI to open a shell in production.
If we wanted to be stricter still, we could confine it with resourceNames to the exact workload names:
- apiGroups: ["apps"]
resources: ["deployments"]
resourceNames: ["web-store", "bookings-api", "notifications-worker"]
verbs: ["get", "patch", "update"]
# ...and a separate rule with only "list" (no resourceNames), because
# resourceNames does not apply to list, so that CI can list.
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["list"]That is the correct pattern when listing is needed: two rules, a restrictive one for the by-name operations and another with only list.
The processes' ServiceAccounts
The occupancy-reports CronJob generates a nightly report on seat occupancy. It does not need to talk to the Kubernetes API at all, so the right thing is for it not even to mount the token:
apiVersion: v1
kind: ServiceAccount
metadata:
name: occupancy-reports
namespace: rutas-norte-pro
automountServiceAccountToken: false
# No Role and no RoleBinding: it needs no permissions.A process having no binding at all is the ideal situation and more common than people think. web-store, notifications-worker and redis-cache are in the same position.
bookings-api does need to read one specific ConfigMap in order to reload its route configuration on the fly:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: bookings-api-config
namespace: rutas-norte-pro
rules:
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["bookings-api-routes"] # exactly that one, no other
verbs: ["get", "watch"]And the PostgreSQL operator from module 6, which does need broad permissions because its job is to manage StatefulSets, Services and PVCs. Remember what we said in 06-07: before adopting an operator you have to read what RBAC it asks for. This is the reasonable minimum, confined to one namespace:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: postgres-operator
namespace: rutas-norte-pro
rules:
- apiGroups: ["database.rutasnorte.example"]
resources: ["postgresclusters", "postgresclusters/status", "postgresclusters/finalizers"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["apps"]
resources: ["statefulsets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["services", "persistentvolumeclaims", "configmaps"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# The operator creates the database credentials: it needs secrets.
# It is confined to THIS namespace, not to the whole cluster. That is the
# difference between "it can read the customer DB credentials" and
# "it can read EVERY credential in the cluster".
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]If the operator you are evaluating demands a ClusterRole with secrets across the whole cluster and its documentation does not explain why, that is a legitimate reason to reject it or to deploy it scoped to a single namespace, if it supports that.
- Why
list on secrets is the same as reading them
list on secrets is the same as reading themThis is one of the points most people misunderstand, and it has direct consequences for Rutas Norte's customer data.
When you run kubectl get secrets, kubectl issues a GET /api/v1/namespaces/<ns>/secrets. The apiserver returns a SecretList with the complete objects, including the data field with all the values. That kubectl shows you a summarised table without the values is merely a client-side presentation decision. The data already travelled over the network to your machine.
# This uses "list", not "get", and returns the values of ALL the secrets
kubectl get secrets -n rutas-norte-pro -o yamlapiVersion: v1
items:
- apiVersion: v1
kind: Secret
metadata:
name: bookings-postgres-credentials
namespace: rutas-norte-pro
data:
POSTGRES_PASSWORD: <base64 value, perfectly readable>
POSTGRES_USER: <base64 value>
type: Opaque
kind: ListAs we said in 03-02, base64 is not encryption: it is encoding. A single command reverses it.
A rule that admits no exceptions: granting
listonsecretsis granting read access to every secret in that scope. There is no way to list secrets "without seeing their contents". If a role haslistonsecretsinrutas-norte-pro, whoever holds it can read the customer database credentials. Treat it as seriously as handing over the password.
The same applies to watch, which delivers the complete objects on every change.
Why wildcards are almost always a mistake
That is cluster-admin. But even "small" wildcards are dangerous:
| Rule with a wildcard | What people think it grants | What it really grants |
|---|---|---|
resources: ["*"], verbs: ["get","list"] |
"Read things" | Every Secret, tokens, and any future resource |
apiGroups: ["*"], resources: ["deployments"] |
Deployments | Deployments from any group, present and future |
verbs: ["*"] on pods |
Manage pods | Includes pods/exec, pods/attach, pods/portforward |
The last row is especially treacherous: a wildcard in verbs on pods does not include the subresources (they must be named), but resources: ["pods/*"] does include exec, attach and portforward. The difference is worth memorising.
And there is a time-related problem: a wildcard grants permissions over resources that do not yet exist. If you install a new CRD next month, anyone with resources: ["*"] will control it from day one, without anybody having decided anything.
The correct alternative is always to enumerate. It takes longer to write and is vastly easier to audit.
- Privilege escalation and Kubernetes' protections
The privilege escalation prevention
By default, Kubernetes stops you from creating or modifying a role with permissions you do not have. If you could, anyone with permission over roles would be a de facto cluster-admin: they would write a role with * and assign it to themselves.
# Someone from "development" (who only has "edit" in dev) tries this
kubectl create role escalation --verb='*' --resource='*' -n rutas-norte-devError from server (Forbidden): roles.rbac.authorization.k8s.io "escalation" is forbidden:
user "[email protected]" (groups=["development" "system:authenticated"]) is attempting
to grant RBAC permissions not currently held:
{APIGroups:["*"], Resources:["*"], Verbs:["*"]}The exact rule is: to create or modify a role, you must already hold every permission that role grants (in the corresponding scope). There are two deliberate exceptions:
| Verb | Effect | When it is legitimately used |
|---|---|---|
escalate on roles/clusterroles |
Allows creating roles with permissions you do not have | Controllers that manage RBAC (Argo CD, operators) |
bind on a specific ClusterRole |
Allows creating bindings to that role without holding its permissions | Delegating "you may grant role X" without granting X |
bind, confined with resourceNames, is the elegant mechanism for delegation. At Rutas Norte we could let team leads grant the support role without being administrators:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: delegate-support-role
namespace: rutas-norte-pro
rules:
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["rolebindings"]
verbs: ["create", "get", "list", "delete"]
- apiGroups: ["rbac.authorization.k8s.io"]
resources: ["clusterroles"]
resourceNames: ["rutasnorte-support"] # can ONLY bind this role
verbs: ["bind"]Whoever holds this role can create bindings to the rutasnorte-support role and to no other. They cannot bind admin, nor cluster-admin. It is a safe delegation.
Being able to create pods is the same as using any ServiceAccount in the namespace
This is probably the most underestimated implication in all of RBAC:
If you can create pods in a namespace, you can run code with the identity of any ServiceAccount in that namespace, simply by putting its name in
spec.serviceAccountName.
# A pod may declare any ServiceAccount from ITS namespace
apiVersion: v1
kind: Pod
metadata:
name: anything
namespace: rutas-norte-pro
spec:
serviceAccountName: postgres-operator # it inherits ALL of its permissions!
containers:
- name: app
image: registry.rutasnorte.example/utilities:1.4.2Creating pods is not protected by the "you cannot grant what you do not have" rule, because technically you are not creating a role: you are creating a pod. But the practical effect is identical to inheriting that ServiceAccount's permissions.
Concrete consequences for our design:
create podsinrutas-norte-prois a high-privilege permission. That is whyci-rutasnortedoes not have it and neither doesdevelopmentin production.- Never put a highly privileged ServiceAccount and ordinary workloads in the same namespace. The PostgreSQL operator, which can read secrets, shares a namespace with
web-store. Whoever can create a pod there inherits the operator's permissions. The right thing is to isolate operators in their own namespace. - Verbs that create pods indirectly count just the same: creating Deployments, StatefulSets, DaemonSets, Jobs or CronJobs is creating pods by delegation.
editincludes all of them.
Other escalation routes worth knowing so you can close them:
| Permission | Why it is high-privilege |
|---|---|
create pods |
Impersonate any SA in the namespace (above) |
create pods/exec |
Get into a pod that already uses a privileged SA |
create serviceaccounts/token |
Issue a token for any SA in the namespace |
impersonate on users/groups |
Act as another person, including system:masters |
escalate on roles |
Grant yourself any permission |
create on certificatesigningrequests/approval |
Issue client certificates with whatever group they like |
get nodes/proxy |
Talk to the kubelet and execute in any pod on the node |
patch on validatingwebhookconfigurations |
Disable the admission controls (08-03) |
Auditing these specific permissions is a routine task with a very good return:
# Who can create pods in production?
kubectl get rolebindings,clusterrolebindings -A -o json | jq -r '
.items[]
| select(.metadata.namespace == "rutas-norte-pro" or .kind == "ClusterRoleBinding")
| "\(.kind)/\(.metadata.name) -> \(.roleRef.kind)/\(.roleRef.name) : " +
([.subjects[]? | "\(.kind):\(.name)"] | join(", "))'RoleBinding/platform-admin -> ClusterRole/admin : Group:platform
RoleBinding/support-pro -> ClusterRole/rutasnorte-support : Group:support
RoleBinding/development-read-pro -> ClusterRole/rutasnorte-diagnostics-read : Group:development
RoleBinding/ci-deploy -> Role/ci-deploy : ServiceAccount:ci-rutasnorte
ClusterRoleBinding/platform-infrastructure -> ClusterRole/rutasnorte-infrastructure-read : Group:platformThat list should fit on one screen and every line should have a known justification. The day it no longer fits, the trouble begins.
- Auditing and periodic permission reviews
RBAC decays over time. A temporary permission nobody withdrew, a binding created to debug an incident that stayed, a group that grew. The only defence is systematic review.
Who has cluster-admin
kubectl get clusterrolebindings -o json | jq -r '
.items[]
| select(.roleRef.name == "cluster-admin")
| "\(.metadata.name): " + ([.subjects[]? | "\(.kind)/\(.name)"] | join(", "))'cluster-admin: Group/system:masters
platform-emergency-2026-08: User/[email protected]The second line is exactly what needs catching: an emergency access that was meant to last two hours and is still there. The convention of putting the date in the binding's name makes it stand out.
Who can read the production secrets
for verb in get list watch; do
echo "=== verb: $verb ==="
for group in development platform support; do
printf " %-12s %s\n" "$group" \
"$(kubectl auth can-i "$verb" secrets --as-group "$group" --as reviewer -n rutas-norte-pro)"
done
done=== verb: get ===
development no
platform yes
support no
=== verb: list ===
development no
platform yes
support no
=== verb: watch ===
development no
platform yes
support noOnly platform. That is the result we were after and we must be able to prove it at any moment: this output, saved with a date, is valid evidence for a compliance audit.
Detecting orphaned bindings
When a person leaves the company, their binding stays. When a ServiceAccount is deleted, its bindings stay too. A simple check:
# ServiceAccounts referenced in bindings that no longer exist
kubectl get rolebindings -A -o json | jq -r '
.items[]
| . as $rb
| .subjects[]?
| select(.kind == "ServiceAccount")
| "\(.namespace // $rb.metadata.namespace) \(.name) \($rb.metadata.namespace)/\($rb.metadata.name)"' \
| while read -r ns sa binding; do
kubectl get sa "$sa" -n "$ns" >/dev/null 2>&1 || echo "ORPHANED: $binding -> sa $ns/$sa"
doneAnalysis tools
There are dedicated utilities that make this job far more comfortable:
| Tool | What it brings |
|---|---|
kubectl who-can (krew) |
"Who can do X on Y?" in a single command |
rbac-tool (krew) |
Visualises the permission graph, detects redundant rules |
kubectl-rbac-lookup (krew) |
Lists a subject's permissions across the whole cluster |
| Kubescape / Trivy | Include checks for excessive RBAC (see 08-06) |
ROLEBINDING NAMESPACE SUBJECT TYPE SA-NAMESPACE
platform-admin rutas-norte-pro platform Group
postgres-operator rutas-norte-pro op-postgres ServiceAccount rutas-norte-proThe review process
A reasonable cadence for a platform like Rutas Norte:
| Frequency | Review | Owner |
|---|---|---|
| On every change | The verify-rbac.sh script in the pipeline |
Automatic |
| Weekly | New or modified ClusterRoleBinding objects |
platform |
| Quarterly | Who can read rutas-norte-pro Secrets and who has cluster-admin |
platform + security |
| Quarterly | Orphaned bindings and expired emergency accesses | platform |
| Annually | Full review of the design with security and compliance | Management |
Every RBAC manifest must live in the repository, under k8s/base/rbac/, and be applied only from there. A binding created by hand with kubectl create rolebinding is invisible to code review and is exactly where the problems come in. In module 10 we will see, with GitOps, how to make the cluster effectively reject anything that is not in Git.
Common Mistakes and Tips
Writing apiGroups: ["core"]. It does not exist. The core group is the empty string "". The manifest applies without error and grants nothing; then you spend an hour looking for the reason behind a 403.
Using the Kind or the short name in resources. You must use the lowercase plural: pods, not Pod or po; deployments, not Deployment. Check it with kubectl api-resources.
Believing that a RoleBinding to a ClusterRole gives global permissions. It does not: it confines them to the binding's namespace. That is the exact opposite of what the name suggests, and it is the most useful of the four combinations.
Trying to change the roleRef of an existing binding. It is immutable. You have to delete and recreate. A kubectl apply that changes the roleRef fails with a rather uninformative error.
Granting edit in production "because it only edits". edit reads and creates Secrets, and creates pods (which means it can impersonate any ServiceAccount in the namespace). In a namespace holding personal data it is an administration permission in disguise.
Assuming that resourceNames protects listings. It does not apply to list, watch, create or deletecollection. If you grant list without names, everything is visible.
Forgetting that list on Secrets is reading them. There is no way to list them without seeing the contents.
Adding wildcards "to get by for now". The temporary permission stays, and on top of that it grants access to resources that do not yet exist. If you need to unblock somebody quickly, grant the exact permission they are missing and open a ticket to review it.
Granting permissions to users instead of groups. Every joiner and every leaver becomes a manifest change, and leavers are always forgotten.
Confusing 401 with 403. 401 is authentication (I do not know who you are), 403 is authorization (I know who you are and you may not). kubectl auth whoami tells the two cases apart in a second.
Not verifying. Always write the negative checks: it is not enough to confirm that support can read logs, you have to confirm that it cannot exec or read Secrets. Security failures live in the extra permissions, not the missing ones.
Putting a privileged operator in the same namespace as the applications. Whoever can create a pod there inherits the operator's permissions. Separate namespace.
Golden tip: always start from zero permissions and add only what fails. It is slower on the first day and vastly safer on all the others. With kubectl auth can-i --list you will know exactly where you stand at any moment.
Exercises
Exercise 1: read-only role with logs, without exec or secrets
Rutas Norte takes on an intern in the support team. You need a ClusterRole called rutasnorte-support-junior and a binding that applies it only to rutas-norte-pre. It must be able to:
- List and view pods, services and deployments.
- Read the pods' logs.
And it must not be able to:
- Read Secrets.
- Run
kubectl execorkubectl port-forward. - Modify anything.
Write the manifests and the verification commands that prove the three prohibitions.
Exercise 2: minimal RBAC for a metrics collector
A new metrics agent, inventory-agent, runs as a Deployment in rutas-norte-pro with its own ServiceAccount. It needs to list pods and nodes across the whole cluster in order to build a resource inventory, and nothing more. Write the ServiceAccount, the ClusterRole, the ClusterRoleBinding and a check that proves it cannot read Secrets or create anything.
Additional question: why does this case really need a ClusterRoleBinding rather than just a RoleBinding?
Exercise 3: audit and fix a dangerous role
You find this Role applied in rutas-norte-pro:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: analytics-team
namespace: rutas-norte-pro
rules:
- apiGroups: ["*"]
resources: ["*"]
verbs: ["get", "list", "watch"]It is bound to the analytics group, whose sole remit is to consult the occupancy metrics through the ServiceMonitor objects and to see the status of the pods.
- List all the security problems with this role.
- Rewrite it with least privilege.
- Write the checks that prove the new version still serves its purpose and no longer exposes personal data.
Solutions
Solution 1
# k8s/rbac/support-junior.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: rutasnorte-support-junior
labels:
app.kubernetes.io/part-of: rutas-norte
rules:
- apiGroups: [""]
resources: ["pods", "services", "events"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "replicasets"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get", "list"]
# Deliberately absent: secrets, pods/exec, pods/attach,
# pods/portforward, pods/ephemeralcontainers and every write verb.
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: support-junior-pre
namespace: rutas-norte-pre # the ClusterRole is confined to this namespace
subjects:
- kind: Group
name: support-junior
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: rutasnorte-support-junior
apiGroup: rbac.authorization.k8s.ioVerification:
kubectl apply -f k8s/rbac/support-junior.yaml
SUBJECT=(--as intern --as-group support-junior)
# Must be able to
kubectl auth can-i get pods/log "${SUBJECT[@]}" -n rutas-norte-pre # yes
kubectl auth can-i list deployments "${SUBJECT[@]}" -n rutas-norte-pre # yes
# Must NOT be able to
kubectl auth can-i get secrets "${SUBJECT[@]}" -n rutas-norte-pre # no
kubectl auth can-i create pods/exec "${SUBJECT[@]}" -n rutas-norte-pre # no
kubectl auth can-i create pods/portforward "${SUBJECT[@]}" -n rutas-norte-pre # no
kubectl auth can-i list pods "${SUBJECT[@]}" -n rutas-norte-pro # no (another namespace)The last check is the one that proves the RoleBinding confines the ClusterRole: in rutas-norte-pro it has absolutely nothing.
Solution 2
# k8s/rbac/inventory-agent.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: inventory-agent
namespace: rutas-norte-pro
labels:
app.kubernetes.io/part-of: rutas-norte
# Here the token IS mounted: the process needs to talk to the API.
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: rutasnorte-inventory
rules:
- apiGroups: [""]
resources: ["pods", "nodes"]
verbs: ["get", "list", "watch"]
# Nothing else. No secrets, no configmaps, no write verb whatsoever.
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: inventory-agent
subjects:
- kind: ServiceAccount
name: inventory-agent
namespace: rutas-norte-pro
roleRef:
kind: ClusterRole
name: rutasnorte-inventory
apiGroup: rbac.authorization.k8s.ioVerification:
SA=system:serviceaccount:rutas-norte-pro:inventory-agent
kubectl auth can-i list pods --as "$SA" -A # yes
kubectl auth can-i list nodes --as "$SA" # yes
kubectl auth can-i get secrets --as "$SA" -n rutas-norte-pro # no
kubectl auth can-i create pods --as "$SA" -n rutas-norte-pro # no
kubectl auth can-i delete pods --as "$SA" -A # no
kubectl auth can-i --list --as "$SA" -n rutas-norte-proyes
yes
no
no
no
Resources Non-Resource URLs Resource Names Verbs
pods [] [] [get list watch]
nodes [] [] [get list watch]
...Why a ClusterRoleBinding is needed: for two independent reasons.
nodesis a non-namespaced resource. ARoleBindingcan never grant access to global resources, not even by referencing aClusterRolethat mentions them.- The agent must list pods from every namespace. A
RoleBindingwould confine the read access to the binding's namespace, and it would have to be replicated in every existing and future namespace.
Had the requirement been only "pods in rutas-norte-pro", a RoleBinding would have been the correct and safer option.
Solution 3
1. Problems with the original role:
| Problem | Consequence |
|---|---|
resources: ["*"] includes secrets |
The analytics group can read the bookings-postgres credentials, and with them access every customer's personal data. A data protection incident. |
list on secrets |
With a single command it sees the contents of every secret in the namespace. |
apiGroups: ["*"] |
It covers resources that do not yet exist: any CRD installed tomorrow will be exposed without anybody deciding it. |
resources: ["*"] includes serviceaccounts |
It makes it easy to scout which privileged identities exist in the namespace. |
| It does not match the stated remit | None of this is needed to see metrics and pod status. |
| A rule impossible to audit | Nobody can answer "what can this group see?" at a glance. |
2. Least-privilege version:
# k8s/rbac/analytics.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: rutasnorte-analytics
labels:
app.kubernetes.io/part-of: rutas-norte
rules:
# Workload status
- apiGroups: [""]
resources: ["pods", "services"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets", "replicasets"]
verbs: ["get", "list", "watch"]
# Monitoring objects from module 7
- apiGroups: ["monitoring.coreos.com"]
resources: ["servicemonitors", "podmonitors", "prometheusrules"]
verbs: ["get", "list", "watch"]
# Usage metrics (metrics-server, 07-02)
- apiGroups: ["metrics.k8s.io"]
resources: ["pods", "nodes"]
verbs: ["get", "list"]
# Deliberately absent: secrets, configmaps, serviceaccounts,
# pods/log, pods/exec and every write verb.
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: analytics-pro
namespace: rutas-norte-pro
subjects:
- kind: Group
name: analytics
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: rutasnorte-analytics
apiGroup: rbac.authorization.k8s.ioNotice that we have also removed pods/log: the stated remit is to consult metrics, and logs could contain customer information (even though we forbade it in 07-05, the minimum permission does not depend on that prohibition always being honoured).
3. Verification:
kubectl delete role analytics-team -n rutas-norte-pro
kubectl apply -f k8s/rbac/analytics.yaml
A=(--as analyst --as-group analytics -n rutas-norte-pro)
echo "--- Must still work ---"
kubectl auth can-i list pods "${A[@]}"
kubectl auth can-i list servicemonitors.monitoring.coreos.com "${A[@]}"
kubectl auth can-i list deployments "${A[@]}"
echo "--- Must no longer work ---"
kubectl auth can-i get secrets "${A[@]}"
kubectl auth can-i list secrets "${A[@]}"
kubectl auth can-i watch secrets "${A[@]}"
kubectl auth can-i get pods/log "${A[@]}"
kubectl auth can-i list configmaps "${A[@]}"
kubectl auth can-i create pods "${A[@]}"Since the old role granted access to the customer database credentials, fixing the role is not enough: you have to review the audit log to find out whether anybody actually read that Secret, and if so, rotate the credentials and notify the compliance officer. How that query is done is the subject of 08-06.
Conclusion
RBAC is the first and most important line of defence of a Kubernetes cluster, and you now have the complete mental model:
- Every request crosses three gates: authentication (who you are), authorization (what you may do, where RBAC acts) and admission control (whether the object is acceptable).
- The subjects are users and groups, which Kubernetes does not store and which arrive from the authenticator, and ServiceAccounts, which really are cluster objects.
- Four objects:
Role/ClusterRolesay what,RoleBinding/ClusterRoleBindingsay who. TheRoleBinding→ClusterRolecombination is the most useful: write the role once, confine it wherever you like. - A rule is the intersection of
apiGroups(with the empty core group""),resources(including subresources such aspods/logandpods/exec),verbsand optionallyresourceNames(which does not apply tolist). liston Secrets is reading them. Wildcards grant more than you think, including over resources that do not yet exist.- Creating pods is equivalent to being able to use any ServiceAccount in the namespace; that is why privileged operators go in their own namespace.
kubectl auth can-i --as/--as-groupandkubectl auth whoamiturn RBAC into something verifiable, and those checks must run automatically on every change.
At Rutas Norte we have gone from "anyone can read the customer database credentials" to a design where only platform can do so, support sees the status and the logs without being able to get inside the containers, development investigates production without touching it, and the continuous integration pipeline can only update the image of workloads that already exist.
But we have just uncovered an uncomfortable limit: RBAC decides whether you have the right to create a pod, not what that pod looks like. Someone from platform, with perfectly legitimate permissions, can deploy a container today with privileged: true, mounting the node's / with hostPath and running as root. RBAC will say yes, because they have permission to create pods. And from there the whole node —and every pod running on it, bookings-postgres included— is laid bare.
The next lesson, 08-02, Security Contexts and Container Hardening, tackles exactly that problem: what a container really isolates, why it is not a virtual machine, and how the securityContext of each Rutas Norte component is configured so that, even if somebody manages to run code inside a container, they cannot get out of it.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
