Skip to content
ioob.dev
Go back

Kubernetes Beginner Series 10 — RBAC and Security: The Principle of Least Privilege

· 7 min read
Kubernetes Series (10/12)
  1. Kubernetes Beginner Series 1 — What Is Kubernetes
  2. Kubernetes Beginner Series 2 — Cluster Architecture
  3. Kubernetes Beginner Series 3 — Pod
  4. Kubernetes Beginner Series 4 — Controllers
  5. Kubernetes Beginner Series 5 — Services and Networking
  6. Kubernetes Beginner Series 6 — Ingress and Gateway API
  7. Kubernetes Beginner Series 7 — ConfigMap and Secret
  8. Kubernetes Beginner Series 8 — Storage: PV, PVC, StorageClass
  9. Kubernetes Beginner Series 9 — Resource Management and Autoscaling
  10. Kubernetes Beginner Series 10 — RBAC and Security: The Principle of Least Privilege
  11. Kubernetes Beginner Series 11 — Observability: Logs, Metrics, and Traces
  12. Kubernetes Beginner Series 12 — Helm and Package Management
Table of contents

Table of contents

Why the Defaults Are Dangerous

When you first set up Kubernetes, everything is configured to “just work.” Pods within a namespace can freely communicate with each other, processes inside Pods run as root, and the default ServiceAccount is automatically attached. This is convenient for learning, but leaving it as-is in production is dangerous.

If one service is compromised, “lateral movement” attacks across the entire cluster become easy. If there’s a container escape vulnerability, the host node itself can be taken over. If someone gains kubectl access, the defaults let them do just about anything.

The foundation of Kubernetes security is the Principle of Least Privilege. Narrowing things down to “this subject can perform only this action on this resource.” In this part, we’ll look at the tools for that narrowing.

flowchart LR
    A[Subject<br/>User / SA] -->|What permissions?| B[RBAC]
    C[Pod<br/>Network traffic] -->|What's allowed?| D[NetworkPolicy]
    E[Container<br/>Runtime environment] -->|How to restrict?| F[Pod Security]

Three axes. RBAC controls who can do what, NetworkPolicy controls who can communicate with whom, and Pod Security Standards control how containers are allowed to run.

Authentication vs Authorization

Let’s clarify the terminology first.

Kubernetes has two types of subjects: Users and ServiceAccounts (SA). Humans authenticate via kubeconfig or OIDC; processes inside Pods authenticate via ServiceAccounts.

The API server follows this sequence for every incoming request:

sequenceDiagram
    participant C as Client
    participant A as API Server
    participant Au as Authenticator
    participant Az as Authorizer
    participant Ad as Admission
    participant E as etcd

    C->>A: Request (token/certificate)
    A->>Au: Who is this?
    Au-->>A: user=alice, groups=[dev]
    A->>Az: Can alice perform this action?
    Az-->>A: Allow/Deny
    A->>Ad: Validate/mutate resource spec
    Ad-->>A: OK
    A->>E: Store
    E-->>A: OK
    A-->>C: Response

While there are several authorization mechanisms, in practice it’s almost always RBAC (Role-Based Access Control). Users/ServiceAccounts are bound to Roles, and Roles are granted permissions on resources. You declaratively define “who (subject) can perform which actions (verbs) on which resources.”

ServiceAccount — A Pod’s Identity Card

If you don’t specify a serviceAccountName for a Pod, the namespace’s default SA is automatically attached. This SA’s token is mounted at /var/run/secrets/kubernetes.io/serviceaccount/token and used when communicating with the API server from inside the Pod.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: app-sa
  namespace: backend
automountServiceAccountToken: true

If your application doesn’t need to call the Kubernetes API directly, it’s best to disable automatic token mounting.

apiVersion: v1
kind: Pod
metadata:
  name: app
spec:
  serviceAccountName: app-sa
  automountServiceAccountToken: false    # Disable if not needed
  containers:
    - name: app
      image: myapp:1.0

Why disable it? Because if a container is compromised, the attacker gets a ready-made ticket to call the API server. If the app is just a regular web server that doesn’t use the Kubernetes API, there’s no reason to mount the token.

Role vs ClusterRole

The resources that define permissions in RBAC are Role and ClusterRole. The difference is scope:

A Role that grants read-only access to Pods within a namespace looks like this:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: pod-reader
  namespace: backend
rules:
  - apiGroups: [""]
    resources: ["pods", "pods/log"]
    verbs: ["get", "list", "watch"]

Understand these three keywords:

To see all available actions, check the Kubernetes documentation or run kubectl api-resources -o wide.

RoleBinding — Linking Subjects to Permissions

A Role only defines “these permissions exist.” A RoleBinding connects them to actual subjects.

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: app-sa-pod-reader
  namespace: backend
subjects:
  - kind: ServiceAccount
    name: app-sa
    namespace: backend
roleRef:
  kind: Role
  name: pod-reader
  apiGroup: rbac.authorization.k8s.io

Now app-sa can get/list/watch Pods in the backend namespace. It cannot create or delete them. Only those two verbs are permitted.

A RoleBinding can also reference a ClusterRole. This combination means “grant the permissions defined in this ClusterRole, but only within this namespace’s scope.” It’s used when granting built-in ClusterRoles like view, edit, or admin in a specific namespace.

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: alice-edit-on-backend
  namespace: backend
subjects:
  - kind: User
    name: alice@example.com
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole        # References a ClusterRole, but
  name: edit               # applied only within the backend namespace
  apiGroup: rbac.authorization.k8s.io

ClusterRoleBinding — Cluster-Wide Permissions

ClusterRoleBinding is a binding that operates across the entire cluster. Use it with caution.

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: platform-admins
subjects:
  - kind: Group
    name: platform-team
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: cluster-admin
  apiGroup: rbac.authorization.k8s.io

cluster-admin is a superuser with all actions on all resources. Never hand this out carelessly to individuals. You can check what the current user can do with kubectl auth can-i --list.

# Check my permissions
kubectl auth can-i --list

# Check if a specific action is allowed
kubectl auth can-i delete pods -n production
kubectl auth can-i '*' '*' --as=system:serviceaccount:backend:app-sa

Practical Pattern — Creating an SA for CI/CD

The most common combination in practice is a ServiceAccount for deployment pipelines. You narrow it down to “this SA can only update Deployments in this namespace.”

apiVersion: v1
kind: ServiceAccount
metadata:
  name: deployer
  namespace: backend
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: deployer-role
  namespace: backend
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "patch", "update"]
  - apiGroups: [""]
    resources: ["configmaps", "services"]
    verbs: ["get", "list", "watch", "update"]
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list", "watch"]   # For log access
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: deployer-binding
  namespace: backend
subjects:
  - kind: ServiceAccount
    name: deployer
    namespace: backend
roleRef:
  kind: Role
  name: deployer-role
  apiGroup: rbac.authorization.k8s.io

Register this SA’s token in your CI system, and the pipeline can update only Deployments in this namespace — nothing more. It can’t read Secrets or access other namespaces. Even if the CI credentials are leaked, the blast radius is contained to this namespace.

NetworkPolicy — Controlling Inter-Pod Communication

By default, Kubernetes allows all Pods to communicate with each other. They can reach Pods in other namespaces and even outside the cluster. This is convenient but means that if a breach occurs, an attacker can easily move laterally to neighboring Pods.

NetworkPolicy narrows this down using a whitelist approach. “Pods with this label only accept traffic from Pods with that label.”

Start with a default deny policy:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: backend
spec:
  podSelector: {}      # Applies to all Pods in the namespace
  policyTypes:
    - Ingress
    - Egress

With this policy applied, the namespace is completely isolated — nothing can come in from outside, and nothing can go out. From here, you open only what’s needed.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-frontend-to-api
  namespace: backend
spec:
  podSelector:
    matchLabels:
      app: api
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              name: frontend
          podSelector:
            matchLabels:
              app: web
      ports:
        - protocol: TCP
          port: 8080

The app=api Pods in the backend namespace only accept traffic on port 8080 from app=web Pods in the frontend namespace. All other inbound traffic is blocked.

flowchart LR
    A[frontend ns<br/>app=web] -->|8080 OK| B[backend ns<br/>app=api]
    C[frontend ns<br/>app=admin] -.->|Blocked| B
    D[other ns] -.->|Blocked| B

Egress Control

Controlling outbound traffic is equally important. You can block attacks that exfiltrate data to external DNS servers, or enforce that only internal services are called.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-egress
  namespace: backend
spec:
  podSelector:
    matchLabels:
      app: api
  policyTypes:
    - Egress
  egress:
    # Allow DNS
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - protocol: UDP
          port: 53
    # Allow only PostgreSQL in the database namespace
    - to:
        - namespaceSelector:
            matchLabels:
              name: database
          podSelector:
            matchLabels:
              app: postgres
      ports:
        - protocol: TCP
          port: 5432

The api Pod can only perform DNS lookups and access PostgreSQL. External internet is blocked, and other internal services are also blocked.

Important: NetworkPolicy requires CNI plugin support. Calico, Cilium, and Weave Net support it, but the default Flannel configuration does not. Check your cluster’s CNI first.

Pod Security Standards — Container Runtime Policies

If a container runs as root with the host filesystem mounted, an attacker who compromises that container can take over the entire node. Pod Security Standards (PSS) prevents such dangerous configurations.

The old PodSecurityPolicy (PSP) was removed in v1.25 and replaced by PSS and Pod Security Admission.

There are three standard profiles:

ProfileDescription
privilegedNo restrictions. For cluster infrastructure Pods
baselineBlocks known privilege escalation vectors. The minimum bar for general applications
restrictedBest-practice level. Recommended for end-user workloads

Apply them by labeling a namespace:

apiVersion: v1
kind: Namespace
metadata:
  name: backend
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/audit: restricted

enforce rejects Pod creation on violation. warn only shows a warning, and audit records in the audit log. Suddenly turning on restricted as enforce may cause existing Pods to be rejected, so it’s safer to start with warn to absorb the shock and gradually ramp up.

What the restricted Profile Requires

To meet the restricted level, your Pod spec needs settings like these:

apiVersion: v1
kind: Pod
metadata:
  name: secure-app
spec:
  securityContext:
    runAsNonRoot: true          # Run as a non-root UID
    runAsUser: 1000
    runAsGroup: 3000
    fsGroup: 2000
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: app
      image: myapp:1.0
      securityContext:
        allowPrivilegeEscalation: false   # Prohibit setuid
        capabilities:
          drop:
            - ALL                         # Drop all Linux capabilities
        readOnlyRootFilesystem: true      # Make root fs read-only
      volumeMounts:
        - name: tmp
          mountPath: /tmp
  volumes:
    - name: tmp
      emptyDir: {}

Key points to note:

Image Security

Finally, you should also attend to the security of images themselves. No matter how well the code is written, if the base image has CVEs, those vulnerabilities ship with it.

Here are some recommended practices:

# Block unsigned images with Kyverno (example)
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-images
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-signature
      match:
        any:
          - resources:
              kinds: ["Pod"]
      verifyImages:
        - imageReferences:
            - "registry.company.com/*"
          attestors:
            - entries:
                - keys:
                    publicKeys: |-
                      -----BEGIN PUBLIC KEY-----
                      ...
                      -----END PUBLIC KEY-----

Checklist Summary

When creating a new namespace, run through this list to cover the essentials:

  1. Create a dedicated ServiceAccount and assign it to the Pod. Don’t use the default SA
  2. Disable automatic token mounting. Only enable it for Pods that genuinely need API access
  3. Grant only the minimum required permissions with Role and RoleBinding
  4. Apply a NetworkPolicy default-deny and whitelist only necessary communications
  5. Apply Pod Security Standards with restricted enforce
  6. Add image scanning and signature verification to the CI/CD pipeline
  7. Integrate Secrets with an external secret manager (see Part 7)

When first adopting these practices, it may feel like overkill. But after experiencing even one security incident, you’ll realize how dangerous the defaults really were. The upfront effort of locking things down pays off many times over in the long run.


The next part covers the most important operational concern: observability. We’ll look at how to collect logs, gather metrics, set up distributed tracing, and review the essential kubectl commands for debugging.

-> Part 11: Observability — Logs, Metrics, and Traces


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Kubernetes Beginner Series 9 — Resource Management and Autoscaling
Next Post
Kubernetes Beginner Series 11 — Observability: Logs, Metrics, and Traces