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

  1. The three gates: authentication, authorization and admission
  2. The subjects: users, groups and ServiceAccounts
  3. The four RBAC objects
  4. Anatomy of a rule: apiGroups, resources, verbs and resourceNames
  5. Subresources: the hidden power of pods/exec and pods/log
  6. Default roles and aggregated roles
  7. Checking permissions with kubectl auth can-i
  8. The Rutas Norte RBAC design
  9. Why list on secrets is the same as reading them
  10. Privilege escalation and Kubernetes' protections
  11. Auditing and periodic permission reviews
  12. Common mistakes and tips
  13. Exercises
  14. Conclusion

  1. 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).

  1. 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 User object. You cannot run kubectl 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 administrator kubeconfig generated by kubeadm. 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.io

Comparative 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

  1. 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:

  • Role and ClusterRole describe what can be done. They are lists of permissions, and they mention nobody.
  • RoleBinding and ClusterRoleBinding describe 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.io

With 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.

  1. 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-resources
NAME                  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        StorageClass

When 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 with list, watch, create or deletecollection. 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.

  1. Subresources: the hidden power of pods/exec and pods/log

Some 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 pods and get pods/log are different permissions. Being able to see that a pod exists gives no right to read its logs. And pods/exec is not granted by the get verb on pods: 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.

  1. 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:

  1. edit can read Secrets. Many people grant edit in 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 use edit as-is in rutas-norte-pro.
  2. view does 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 secrets

Aggregated 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.

kubectl get clusterrole view -o yaml
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 controller

The 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.

  1. Checking permissions with kubectl auth can-i

Writing RBAC without verifying it is like writing tests without running them. kubectl auth can-i answers the exact question the apiserver asks itself.

# Can I, with my current kubeconfig, create deployments here?
kubectl auth can-i create deployments
yes
# Can I read secrets in production?
kubectl auth can-i get secrets --namespace rutas-norte-pro
no

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
no
# 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-pro
yes

You 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-pro
no

And 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-pro
Resources                                       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.

kubectl auth whoami
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 $failures

Running this script on every change turns RBAC into something verifiable. Without it, a badly copied roleRef can go unnoticed for months.

  1. 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.io

It 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.io

And the same ClusterRole, plus the log permission, is reused in rutas-norte-pre without writing new rules. That is the advantage of the RoleBindingClusterRole 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.io

cluster-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.io

The 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.io

Let us analyse the decisions:

  • No create: new objects are created by someone from platform after review. CI only updates what already exists and has been reviewed.
  • No delete or deletecollection: 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.

  1. Why list on secrets is the same as reading them

This 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 yaml
apiVersion: 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: List

As we said in 03-02, base64 is not encryption: it is encoding. A single command reverses it.

A rule that admits no exceptions: granting list on secrets is granting read access to every secret in that scope. There is no way to list secrets "without seeing their contents". If a role has list on secrets in rutas-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

# NEVER do this outside a lab
rules:
  - apiGroups: ["*"]
    resources: ["*"]
    verbs: ["*"]

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.

  1. 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-dev
Error 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.2

Creating 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:

  1. create pods in rutas-norte-pro is a high-privilege permission. That is why ci-rutasnorte does not have it and neither does development in production.
  2. 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.
  3. Verbs that create pods indirectly count just the same: creating Deployments, StatefulSets, DaemonSets, Jobs or CronJobs is creating pods by delegation. edit includes 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:platform

That list should fit on one screen and every line should have a known justification. The day it no longer fits, the trouble begins.

  1. 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      no

Only 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"
  done

Analysis 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)
kubectl krew install who-can
kubectl who-can get secrets -n rutas-norte-pro
ROLEBINDING          NAMESPACE          SUBJECT      TYPE   SA-NAMESPACE
platform-admin       rutas-norte-pro    platform     Group
postgres-operator    rutas-norte-pro    op-postgres  ServiceAccount  rutas-norte-pro

The 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 exec or kubectl 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.

  1. List all the security problems with this role.
  2. Rewrite it with least privilege.
  3. 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.io

Verification:

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)
yes
yes
no
no
no
no

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.io

Verification:

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-pro
yes
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.

  1. nodes is a non-namespaced resource. A RoleBinding can never grant access to global resources, not even by referencing a ClusterRole that mentions them.
  2. The agent must list pods from every namespace. A RoleBinding would 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.io

Notice 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[@]}"
--- Must still work ---
yes
yes
yes
--- Must no longer work ---
no
no
no
no
no
no

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/ClusterRole say what, RoleBinding/ClusterRoleBinding say who. The RoleBindingClusterRole combination 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 as pods/log and pods/exec), verbs and optionally resourceNames (which does not apply to list).
  • list on 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-group and kubectl auth whoami turn 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

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved