Lesson 05 ServiceAccounts & RBAC
Theme:

ServiceAccounts & RBAC

Pod identity, API server authentication, and role-based access control in Helm charts.

Introduction

In Kubernetes, every pod that communicates with the API server needs an identity. That identity comes from a ServiceAccount. Without explicit configuration, pods use the default ServiceAccount in their namespace, which typically has no meaningful permissions when RBAC is enabled. For any application that needs to interact with Kubernetes resources — reading ConfigMaps, watching for changes, managing custom resources — you must create a dedicated ServiceAccount and bind it to appropriate RBAC rules.

Helm charts that follow best practices always include ServiceAccount and RBAC templates as first-class resources. This lesson covers how ServiceAccounts work, how the RBAC authorization model is structured, and how to template both in Helm with proper conditional logic and least-privilege defaults.

ServiceAccount Purpose and Mechanics

A ServiceAccount is a Kubernetes resource that provides an identity for processes running inside pods. When RBAC is enabled (the default on all modern clusters), the API server uses the ServiceAccount's token to determine what the pod is allowed to do.

How Authentication Works

When a pod starts, Kubernetes can automatically mount a projected service account token at /var/run/secrets/kubernetes.io/serviceaccount/token. This is a short-lived JWT (JSON Web Token) that the kubelet refreshes automatically. Client libraries like the official Kubernetes Go client or the Python kubernetes package detect and use this token transparently.

The token contains the ServiceAccount name, namespace, and a unique identifier. The API server validates the token, identifies the ServiceAccount, and then checks RBAC policies to determine whether the request should be allowed.

The automountServiceAccountToken Field

By default, Kubernetes mounts the service account token into every pod. This is convenient but violates the principle of least privilege: most application pods never need to call the Kubernetes API. If a pod is compromised, the mounted token gives the attacker a starting point for lateral movement.

Security best practice: Set automountServiceAccountToken: false on the ServiceAccount or the pod spec by default. Only enable it for pods that genuinely need API access. This single setting significantly reduces your attack surface.

You can control token mounting at two levels:

Creating ServiceAccount Templates in Helm

A standard Helm ServiceAccount template includes conditional creation, configurable annotations (useful for cloud provider integrations like AWS IAM Roles for Service Accounts), and image pull secrets.

# templates/serviceaccount.yaml
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
  name: {{ include "myapp.serviceAccountName" . }}
  labels:
    {{- include "myapp.labels" . | nindent 4 }}
  {{- with .Values.serviceAccount.annotations }}
  annotations:
    {{- toYaml . | nindent 4 }}
  {{- end }}
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
{{- if .Values.serviceAccount.imagePullSecrets }}
imagePullSecrets:
  {{- toYaml .Values.serviceAccount.imagePullSecrets | nindent 2 }}
{{- end }}
{{- end }}

The corresponding helper template resolves the ServiceAccount name:

# templates/_helpers.tpl
{{- define "myapp.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "myapp.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

When serviceAccount.create is true and no explicit name is given, the chart generates a name from the release. When create is false, the chart references an existing ServiceAccount (falling back to default).

Image Pull Secrets on ServiceAccounts

If your container images are stored in a private registry, you need authentication credentials. Rather than adding imagePullSecrets to every pod spec, you can attach them to the ServiceAccount. Every pod using that ServiceAccount inherits the pull secrets automatically.

# values.yaml
serviceAccount:
  create: true
  imagePullSecrets:
    - name: ghcr-credentials
    - name: ecr-credentials

This approach centralizes credential management. When you rotate registry credentials, you update the Secret and the ServiceAccount — no pod redeployment needed.

The RBAC Model

RBAC in Kubernetes revolves around four resource types. Understanding the distinction between namespace-scoped and cluster-scoped resources is essential for writing secure Helm charts.

Role vs. ClusterRole

AspectRoleClusterRole
ScopeSingle namespaceCluster-wide
Can access namespace resourcesYes (own namespace only)Yes (all namespaces when bound with ClusterRoleBinding)
Can access cluster resourcesNoYes (nodes, PVs, namespaces, etc.)
Use caseApplication-specific permissionsOperators, controllers, admin roles

RoleBinding vs. ClusterRoleBinding

A RoleBinding grants the referenced Role or ClusterRole within a specific namespace. A ClusterRoleBinding grants a ClusterRole across the entire cluster. An important pattern: you can use a RoleBinding to reference a ClusterRole, which limits the ClusterRole's permissions to the RoleBinding's namespace. This is useful for reusing standard permission sets without granting cluster-wide access.

Common confusion: A ClusterRole bound with a RoleBinding only applies to the RoleBinding's namespace. You only get cluster-wide permissions when using a ClusterRoleBinding. This distinction matters for security and is a frequent interview question.

RBAC Rules Structure

Each RBAC rule specifies three components:

An optional resourceNames field further restricts access to specific named objects:

rules:
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["db-credentials", "api-key"]
    verbs: ["get"]

Common RBAC Patterns

Read-Only Access to Config

The most common pattern for applications that read configuration from ConfigMaps or Secrets at runtime:

rules:
  - apiGroups: [""]
    resources: ["configmaps", "secrets"]
    verbs: ["get", "list", "watch"]

Namespace Admin

For operators or admin tools that manage all resources within a namespace:

rules:
  - apiGroups: ["", "apps", "batch", "networking.k8s.io"]
    resources: ["*"]
    verbs: ["*"]
Avoid wildcard rules in production. Using "*" for resources and verbs is convenient during development but dangerous in production. Always enumerate the specific resources and verbs your application needs.

Specific Resource Access

When an app needs access to a single, known Secret (e.g., database credentials):

rules:
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["myapp-db-creds"]
    verbs: ["get"]

Templating RBAC Conditionally

Not every deployment needs RBAC resources. Some clusters have pre-existing roles, and some pods never call the API. A well-designed Helm chart makes RBAC creation optional via values.yaml:

# values.yaml
rbac:
  create: true
  rules:
    - apiGroups: [""]
      resources: ["configmaps", "secrets"]
      verbs: ["get", "list", "watch"]
# templates/role.yaml
{{- if .Values.rbac.create -}}
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: {{ include "myapp.fullname" . }}
  labels:
    {{- include "myapp.labels" . | nindent 4 }}
rules:
  {{- toYaml .Values.rbac.rules | nindent 2 }}
{{- end }}

This pattern lets users pass custom rules through values overrides while the chart handles the structural boilerplate. The toYaml function converts the values list directly into valid YAML rules.

Binding ServiceAccounts to Roles

The RoleBinding connects a ServiceAccount to a Role. The subjects field must include the namespace, even for namespace-scoped RoleBindings, to avoid ambiguity:

# templates/rolebinding.yaml
{{- if .Values.rbac.create -}}
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: {{ include "myapp.fullname" . }}
  labels:
    {{- include "myapp.labels" . | nindent 4 }}
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: {{ include "myapp.fullname" . }}
subjects:
  - kind: ServiceAccount
    name: {{ include "myapp.serviceAccountName" . }}
    namespace: {{ .Release.Namespace }}
{{- end }}
Always use .Release.Namespace for the subject namespace rather than hardcoding a value. This ensures the binding works correctly regardless of which namespace the chart is installed into.

Least Privilege in Practice

The principle of least privilege states that every component should have only the permissions it needs to perform its function, and nothing more. In Kubernetes RBAC, this translates to concrete practices:

  1. Default to no API access. Set automountServiceAccountToken: false and only enable it for pods that call the API.
  2. Prefer Role over ClusterRole. Namespace-scoped permissions limit blast radius.
  3. Enumerate verbs explicitly. Never use ["*"] in production.
  4. Use resourceNames when possible. Restricting access to specific named objects is more secure than granting access to all objects of a type.
  5. Audit regularly. Use kubectl auth can-i --list --as=system:serviceaccount:NAMESPACE:NAME to verify what a ServiceAccount can actually do.

Example: App That Reads ConfigMaps and Secrets

Consider an application that loads its configuration from a ConfigMap and reads database credentials from a Secret at startup. The complete set of resources needed in Helm:

  1. A ServiceAccount with automountServiceAccountToken: true (since this app needs API access).
  2. A Role granting get, list, and watch on configmaps and secrets.
  3. A RoleBinding connecting the ServiceAccount to the Role.
  4. The Deployment referencing the ServiceAccount by name.

The Deployment spec references the ServiceAccount:

spec:
  template:
    spec:
      serviceAccountName: {{ include "myapp.serviceAccountName" . }}
      containers:
        - name: myapp
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"

With this setup, the application can read ConfigMaps and Secrets in its own namespace but cannot modify them, cannot access resources in other namespaces, and cannot interact with any other resource types. If the pod is compromised, the attacker gains read access to config data in one namespace — a limited blast radius.

Further Reading