KubeGlanceDownload
RBAC for a read-only Kubernetes user (and why view isn't enough)

security

RBAC for a read-only Kubernetes user (and why view isn't enough)

The built-in view role has three surprises: no cluster-scoped resources, no secrets, and it is aggregated — so your cloud provider may have quietly widened it.

· 10 min read

Kubernetes has no “read-only” switch. There is no flag, no mode, and no account type. Read-only is just a set of verbs — get, list, watch — and everything depends on which resources you attach them to.

The obvious move is to use the built-in view ClusterRole. It is the right starting point, but it has three properties that catch people out, and the third one is a security problem rather than an inconvenience.

Surprise 1: view cannot see nodes

view grants read across the workload API — pods, deployments, services, configmaps, jobs, ingresses — all namespaced. It grants almost nothing that is cluster-scoped.

No nodes. No persistentvolumes. No storageclasses. No customresourcedefinitions.

For a human poking at one namespace that is fine. For anything dashboard-shaped it is not, because “why is this pod pending” is usually answered on the node, and the node list comes back empty with no error that explains why.

Surprise 2: view deliberately excludes secrets

This one is intentional and correct. view omits secrets because reading a secret is equivalent to holding the credential inside it — there is no meaningful difference between “can read the database password” and “has the database password”.

Worth knowing that this boundary is leakier than it looks. view includes pods/log, and applications log secrets constantly. Read access to logs is genuinely useful and genuinely a data-exposure route; there is no RBAC rule that fixes that, only application discipline.

Surprise 3: your view may not be the view in the documentation

view is an aggregated ClusterRole. It has an empty rule set of its own, and the aggregation controller continuously fills it in by copying the rules of every ClusterRole labelled:

rbac.authorization.k8s.io/aggregate-to-view: "true"

The point of this is extensibility — an operator ships a ClusterRole for its own CRDs with that label, and view picks up read access to them automatically. That is a good design, and cert-manager, Flux and metrics-server all use it correctly.

The problem is that anyone who can create a labelled ClusterRole can widen view for everybody already bound to it, retroactively and silently.

This is not hypothetical. On an OVH Managed Kubernetes cluster we checked while writing this, the provider ships a ClusterRole called cluster-view carrying that label, and its rules are:

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

Aggregated into view, that grants read on every resource in the cluster, including secrets, cluster-wide, to anyone holding what the documentation describes as a namespaced, secret-free role. Confirmed rather than assumed:

$ kubectl auth can-i list secrets --as=<user-bound-only-to-view> --all-namespaces
yes

Audit yours in two commands

Do not trust the documentation for this. Ask your cluster:

# What does view actually contain right now?
kubectl get clusterrole view -o jsonpath='{range .rules[*]}{.apiGroups}{" | "}{.resources}{" | "}{.verbs}{"\n"}{end}'

# Which roles are feeding into it?
kubectl get clusterrole -l rbac.authorization.k8s.io/aggregate-to-view=true

If the first prints a rule with ["*"] for both apiGroups and resources, view is a cluster-wide read-everything role on your cluster. Then check who holds it:

kubectl get clusterrolebinding -o json | jq -r '
  .items[] | select(.roleRef.name=="view") |
  "\(.metadata.name) → \([.subjects[]?.name] | join(", "))"'

And test the specific thing you care about, because can-i evaluates the real merged rules rather than your reading of them:

kubectl auth can-i list secrets --as=alice --all-namespaces
kubectl auth can-i --list --as=alice

Scoping: the part people get wrong

The binding decides the scope, not the role. One ClusterRole can be granted cluster-wide or in a single namespace.

The rule that saves the most work: a ClusterRole bound with a RoleBinding grants its permissions only inside that namespace. So you define your permission set once, as a ClusterRole, and then choose the scope per binding.

That means you never need per-namespace copies of the same Role. It also means the difference between “read the whole cluster” and “read one namespace” is one word in one object, which is exactly where you want that decision to live.

The one thing it cannot do: a RoleBinding can never grant cluster-scoped resources. Nodes and PersistentVolumes do not live in a namespace, so no namespaced binding can ever reach them, whatever role it points at.

A read-only role that actually works for a dashboard

view plus the cluster-scoped reads it is missing, and explicitly no secrets:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: readonly-dashboard
rules:
  # Core reads. Note: no secrets.
  - apiGroups: [""]
    resources:
      - pods
      - pods/log
      - pods/status
      - services
      - endpoints
      - configmaps
      - namespaces
      - persistentvolumeclaims
      - replicationcontrollers
      - serviceaccounts
      - events
      - limitranges
      - resourcequotas
    verbs: ["get", "list", "watch"]
  # Cluster-scoped — the part view is missing
  - apiGroups: [""]
    resources: ["nodes", "persistentvolumes"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["storage.k8s.io"]
    resources: ["storageclasses", "volumeattachments"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["apps"]
    resources: ["deployments", "replicasets", "statefulsets", "daemonsets", "controllerrevisions"]
    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"]
  - apiGroups: ["policy"]
    resources: ["poddisruptionbudgets"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["discovery.k8s.io"]
    resources: ["endpointslices"]
    verbs: ["get", "list", "watch"]
  # CPU/memory numbers, if metrics-server is installed
  - apiGroups: ["metrics.k8s.io"]
    resources: ["pods", "nodes"]
    verbs: ["get", "list", "watch"]

Bind it cluster-wide:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: readonly-dashboard
subjects:
  - kind: ServiceAccount
    name: readonly-dashboard
    namespace: kube-system
roleRef:
  kind: ClusterRole
  name: readonly-dashboard
  apiGroup: rbac.authorization.k8s.io

Or swap that for a RoleBinding in one namespace, with the identical roleRef, to scope the same permissions down.

What “read-only” does not cover

Three subresources are reached with verbs that look harmless and are not. All of them are create, not get:

SubresourceEffect
pods/execA shell in the container. Total control of the workload.
pods/attachSame, on the running process.
pods/portforwardNetwork access to anything the pod can reach, from your laptop.

If a role grants create on any of those, it is not a read-only role, regardless of what it is called. When you audit a role, grep for exec, attach, portforward and impersonate before you grep for delete.

Also worth flagging: escalate and bind on roles/clusterroles let a holder grant themselves anything, and impersonate lets them act as anyone. None of them contain the word “write”.

Handing that access to a person or a tool

Since Kubernetes 1.24, ServiceAccounts no longer get a permanent token Secret automatically. Request a short-lived one:

kubectl create serviceaccount readonly-dashboard -n kube-system
kubectl create token readonly-dashboard -n kube-system --duration=8h

Then build a kubeconfig around it:

SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
CA=$(kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')
TOKEN=$(kubectl create token readonly-dashboard -n kube-system --duration=8h)

cat > readonly.kubeconfig <<EOF
apiVersion: v1
kind: Config
clusters:
- name: target
  cluster: { server: ${SERVER}, certificate-authority-data: ${CA} }
users:
- name: readonly
  user: { token: ${TOKEN} }
contexts:
- name: readonly
  context: { cluster: target, user: readonly }
current-context: readonly
EOF

Verify it before you hand it over — as the token, not as yourself:

KUBECONFIG=./readonly.kubeconfig kubectl auth can-i --list
KUBECONFIG=./readonly.kubeconfig kubectl auth can-i delete pods -A     # must be "no"
KUBECONFIG=./readonly.kubeconfig kubectl auth can-i list secrets -A    # must be "no"

Bounded tokens expire, which is the point. If you need one that outlives the session, create a kubernetes.io/service-account-token Secret explicitly and accept that you now own a long-lived credential that needs rotating.

Keep that file separate rather than merging it into your main config — it stays distinct, and it cannot be shadowed by a name collision, for the reasons in how KUBECONFIG merges multiple files.

Why this is our favourite topic

KubeGlance is read-only by default and runs entirely client-side: it reads your kubeconfig, talks to the API server directly, and installs nothing in the cluster. There is no agent with its own service account, and no backend of ours holding your credentials — so your RBAC is the whole security boundary, not one control among several.

That is the honest version of a read-only guarantee. An app promising not to write is a promise. A token that cannot write is a fact, enforced by the API server, and it applies equally to us, to kubectl, and to anything else holding that kubeconfig. If you want the guarantee rather than the promise, scope the token — and the role above is a reasonable place to start.

It also means the cheapest security review of any Kubernetes client is kubectl auth can-i --list with its credentials. If the answer includes things you did not intend to grant, that is worth knowing before you evaluate whether the tool is trustworthy — and, as surprise 3 shows, before you assume view means what the docs say it means.

For more on what a kubeconfig actually carries and how contexts resolve, see how KUBECONFIG merges multiple files; and for what the cluster is telling you once you can read it, how kubectl decides what to put in the STATUS column.

KubeGlance pairs well with a view-only kubeconfig: a native Kubernetes dashboard for iPhone, iPad and Mac that reads your clusters and never writes to them.

Get KubeGlance
#rbac #security #serviceaccount #kubeconfig #read-only

The Kubernetes dashboard that fits in your pocket

KubeGlance is a native Kubernetes client for iPhone and iPad — the real dashboard, not a companion — with a full Mac app on the same core. Pods, workloads, logs and events, straight from your kubeconfig.

Download KubeGlance