Kubernetes RBAC: Granting Read-Only Developer Access via ServiceAccount Tokens
When developers need to view logs, check pod status, or inspect resources in a production-like cluster, the instinct is to give them the admin kubeconfig. That’s unnecessary blast radius. With RBAC, you can give exactly the access that’s needed — nothing more.
The RBAC Permission Model
%%{ init: { 'look': 'handDrawn' } }%%
graph TD
A[Developer] -->|uses| B[kubeconfig<br/>with ServiceAccount token]
B --> C[API Server<br/>authentication]
C -->|token identifies| D[ServiceAccount<br/>dev-reader]
D --> E[RoleBinding<br/>binds to ClusterRole]
E --> F[ClusterRole<br/>read-only permissions]
F -->|allows| G[get/list/watch<br/>pods, logs, events]
F -->|denies| H[delete/create<br/>any resource]
The flow: developer authenticates with a token → API server maps token to ServiceAccount → RoleBinding links ServiceAccount to ClusterRole → ClusterRole defines allowed verbs on allowed resources.
The Setup Script
#!/bin/bash
set -euo pipefail
# =============================================================================
# CONFIGURATION — Required environment variables
# =============================================================================
: "${K8S_CONTEXT:?Need K8S_CONTEXT}"
: "${CLUSTERNAME:?Need CLUSTERNAME}"
: "${NAMESPACE:?Need NAMESPACE}"
: "${USERNAME:?Need USERNAME}"
# =============================================================================
# STEP 1 — Create namespace
# =============================================================================
kubectl --context "$K8S_CONTEXT" create namespace "$NAMESPACE" --dry-run=client -o yaml | \
kubectl --context "$K8S_CONTEXT" apply -f -
# =============================================================================
# STEP 2 — Create ServiceAccount
# =============================================================================
kubectl --context "$K8S_CONTEXT" apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
name: ${USERNAME}
namespace: ${NAMESPACE}
EOF
# =============================================================================
# STEP 3 — Create ClusterRole with read-only permissions
# =============================================================================
kubectl --context "$K8S_CONTEXT" apply -f - <<EOF
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: ${USERNAME}-read-only
rules:
- apiGroups: [""]
resources: ["pods", "pods/log", "pods/status", "services", "events", "configmaps", "secrets"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "replicasets", "statefulsets", "daemonsets"]
verbs: ["get", "list", "watch"]
- apiGroups: ["batch"]
resources: ["jobs", "cronjobs"]
verbs: ["get", "list", "watch"]
EOF
# =============================================================================
# STEP 4 — Bind ClusterRole to ServiceAccount (cluster-wide read)
# =============================================================================
kubectl --context "$K8S_CONTEXT" apply -f - <<EOF
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: ${USERNAME}-read-only-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: ${USERNAME}-read-only
subjects:
- kind: ServiceAccount
name: ${USERNAME}
namespace: ${NAMESPACE}
EOF
# =============================================================================
# STEP 5 — Retrieve ServiceAccount token
# =============================================================================
SECRET=$(kubectl --context "$K8S_CONTEXT" get sa "$USERNAME" -n "$NAMESPACE" \
-o jsonpath='{.secrets[0].name}')
TOKEN=$(kubectl --context "$K8S_CONTEXT" get secret "$SECRET" -n "$NAMESPACE" \
-o jsonpath='{.data.token}' | base64 -d)
CA_CERT=$(kubectl --context "$K8S_CONTEXT" get secret "$SECRET" -n "$NAMESPACE" \
-o jsonpath='{.data.ca\.crt}' | base64 -d)
# =============================================================================
# STEP 6 — Generate kubeconfig file
# =============================================================================
cat > "${USERNAME}-kubeconfig" <<EOF
apiVersion: v1
kind: Config
preferences: {}
clusters:
- name: ${CLUSTERNAME}
cluster:
certificate-authority-data: ${CA_CERT}
server: https://${CLUSTERNAME}:6443
contexts:
- name: ${USERNAME}@${CLUSTERNAME}
context:
cluster: ${CLUSTERNAME}
user: ${USERNAME}
namespace: ${NAMESPACE}
current-context: ${USERNAME}@${CLUSTERNAME}
users:
- name: ${USERNAME}
user:
token: ${TOKEN}
EOF
# =============================================================================
# SUMMARY
# =============================================================================
echo "Kubeconfig written to ${USERNAME}-kubeconfig"
echo "Insecure skip TLS verify is NOT used — TLS cert is embedded correctly"
Usage
# Set required variables and run
K8S_CONTEXT=prod-cluster \
CLUSTERNAME=prod.example.com \
NAMESPACE=dev-team \
USERNAME=alice \
./setup-k8s-rbac.sh
# Distribute the generated kubeconfig
kubectl --kubeconfig ./alice-kubeconfig get pods
What This Permissions Covers
%%{ init: { 'look': 'handDrawn' } }%%
graph TD
A[alice-kubeconfig] -->|can| B[kubectl get pods]
A -->|can| C[kubectl logs <pod>]
A -->|can| D[kubectl describe pod]
A -->|can| E[kubectl top pod]
A -->|cannot| F[kubectl delete pod]
A -->|cannot| G[kubectl exec -it]
A -->|cannot| H[kubectl apply]
A -->|cannot| I[Read secret values]
The developer can see what’s running, inspect logs, and check resource status — but cannot modify anything, exec into pods (which would bypass RBAC), or read secret contents (secrets require separate explicit permission).
Scoping to a Single Namespace
If the developer only needs access to one namespace, use RoleBinding instead of ClusterRoleBinding:
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: developer-read-only-binding
namespace: frontend
subjects:
- kind: ServiceAccount
name: alice
namespace: dev-team
roleRef:
kind: ClusterRole
name: ${USERNAME}-read-only
apiGroup: rbac.authorization.k8s.io
This restricts the developer to the frontend namespace only — useful for multi-tenant clusters where teams should only see their own workloads.
Key Security Notes
- TLS verification is embedded in the kubeconfig — the script extracts the cluster’s CA cert and includes it in the kubeconfig. Using
--insecure-skip-tls-verifywould defeat the purpose. - Tokens don’t expire by default in k3s (unlike GKE which has token rotation). For production use, consider rotating tokens periodically or using a tool like
t tokensfromkubeloginfor short-lived OIDC tokens. - Audit logging — every API call with this token is logged in the Kubernetes audit log, so you can trace exactly what a developer queried.