
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.
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 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:
| Subresource | Effect |
|---|---|
pods/exec | A shell in the container. Total control of the workload. |
pods/attach | Same, on the running process. |
pods/portforward | Network 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 KubeGlanceThe 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