RBAC questions are gift questions if the model is exact and time sinks if it is fuzzy. Secrets are the other half of the competency. The interesting part is not the object but the three sanctioned ways to keep secrets out of git.
make up secmake gitopsOrientation
Every Forbidden error you will ever see (in a workflow node, an operator log, a CI job, an exam task) decomposes the same way: subject, verb, resource, namespace. Learn to read the message and half the domain answers itself.
A request arrives authenticated as a subject with groups. Every RBAC rule is an allow; there is no deny. Your permission set is the union of all matching rules. If nothing allows it, it is forbidden, which is why debugging RBAC is always "find the missing rule or the missing binding", never "find the rule that blocked me".
The RBAC model
| Object | Scope | Contains |
|---|---|---|
| Role | one namespace | rules: apiGroups × resources × verbs (+ resourceNames) |
| ClusterRole | cluster-wide definition | same, plus cluster-scoped resources and non-resource URLs |
| RoleBinding | grants in one namespace | subjects + a roleRef to a Role or a ClusterRole |
| ClusterRoleBinding | grants everywhere | subjects + a roleRef to a ClusterRole |
A RoleBinding can reference a ClusterRole, granting its rules within that one namespace only. That is how you define "developer" once and bind it per tenant. A ClusterRoleBinding grants everywhere and is almost always the wrong default. If a task's third check is "and they must not have this in another namespace", that check exists to catch a ClusterRoleBinding.
Four combinations of role and binding, and where each one actually grants:
Subjects
Users and Groups are asserted by the authentication layer (client certificates, OIDC claims, proxy headers) and are not objects, which is why you can bind to dev-a with no dev-a existing anywhere, and why --as=dev-a works for testing. ServiceAccounts are objects, and they are how software gets identity: system:serviceaccount:<ns>:<name>, in group system:serviceaccounts:<ns>. Every controller failure that says Forbidden traces to some SA's missing rule.
Modern SA tokens are short-lived, audience-bound, and delivered by projected volume, not the old forever-Secrets, which also makes them the basis of workload identity (section 5.5's SPIFFE IDs are built from the SA name). Setting automountServiceAccountToken: false on pods that never call the API server is a cheap, real hardening step.
Verbs and rules, the details that decide tasks
get,list,watchare separate verbs. A dashboard that lists needslist; a controller needswatchtoo. Grantinggetand expectingkubectl get pods(plural) to work is a classic mistake.- Subresources are their own resource strings:
pods/exec,pods/log,pods/portforward,deployments/scale,*/status. "Can read logs but not exec" is expressible, and is a good tenant default. resourceNamesnarrows a rule to named objects: the way to allow editing one ConfigMap. Note it cannot restrictcreateordeletecollection, and a plainlistorwatchonly passes with a matchingmetadata.namefield selector.- Wildcards (
*) exist; least privilege says not to use them.escalateandbindare the special verbs that let a subject grant permissions they do not themselves hold; treat them as admin-only. - Aggregated ClusterRoles (
aggregationRulewith label selectors) are how the built-inview/edit/adminroles absorb new CRDs: label your ClusterRolerbac.authorization.k8s.io/aggregate-to-view: "true"and every viewer gains read access to your new kind. That is the platform-engineering move when you ship a CRD. - Built-ins to know:
view(read, no secrets),edit(write, no RBAC),admin(edit + manage RBAC in the namespace),cluster-admin(everything).
kubectl auth can-i <verb> <resource> --as=<user> -n <ns>
kubectl auth can-i --list --as=system:serviceaccount:<ns>:<sa> -n <ns>outputcaptured 2026-08-26
$ kubectl auth can-i create deployments --as=dev-a -n team-a
yes
$ kubectl auth can-i --list --as=system:serviceaccount:team-a:reporter -n team-a
Resources Non-Resource URLs Resource Names Verbs
selfsubjectreviews.authentication.k8s.io [] [] [create]
selfsubjectaccessreviews.authorization.k8s.io [] [] [create]
selfsubjectrulesreviews.authorization.k8s.io [] [] [create]
pods [] [] [get list]
[/.well-known/openid-configuration/] [] [get]
[/.well-known/openid-configuration] [] [get]
[/api/*] [] [get]
[/api] [] [get]
[/apis/*] [] [get]
[/apis] [] [get]
[/healthz] [] [get]
[/healthz] [] [get]
[/livez] [] [get]
[/livez] [] [get]
[/openapi/*] [] [get]
[/openapi] [] [get]
[/openid/v1/jwks/] [] [get]
[/openid/v1/jwks] [] [get]
[/readyz] [] [get]
[/readyz] [] [get]
[/version/] [] [get]
[/version/] [] [get]
[/version] [] [get]
[/version] [] [get]Every RBAC task should end with one of these as proof. --list is the one that finds surprises: it prints the full effective matrix, including everything inherited from group bindings you forgot about.
RBAC edges that decide tasks
Verbs and rules people get wrong
- Escalation prevention. You can create or update a Role only if you already hold every permission it grants, or hold the
escalateverb onroles/clusterroles. You can create a binding only if you hold the permissions being bound, or hold thebindverb on the role (optionally narrowed withresourceNames). The error isattempt to grant extra privileges. A tenant admin who "cannot bind the view role" needsbindon that one ClusterRole, notcluster-admin. impersonateonusers,groups,serviceaccounts(anduserextras) is whatkubectl --asand--as-groupneed. It is granted tocluster-adminand to nobody else by default; a tenant testing their own SA with--asneeds it explicitly.kubectl auth whoami(theSelfSubjectReviewAPI) prints the username, uid, groups and extra attributes the API server sees after authentication. It is the first command when an OIDC user or a workload gets Forbidden and you are not sure which identity arrived: the prefix, the group names and the impersonation all show up here.nonResourceURLs(/metrics,/healthz,/logs/*) exist only in ClusterRoles; a scraper hitting the API server's/metricsneedsgeton that URL, and the wildcard is a suffix glob.- Default ClusterRoles.
viewreads most namespaced objects but not Secrets, Roles or RoleBindings.editadds writes and, less obviously, can read and write Secrets and run pods as any ServiceAccount in the namespace, which is why it is often too much for a developer.adminadds Role and RoleBinding management inside the namespace;cluster-adminis everything. All four aggregate labeled roles (rbac.authorization.k8s.io/aggregate-to-view|edit|admin).system:authenticatedholdssystem:basic-userandsystem:discovery; ifsystem:unauthenticatedcan read discovery, that is thesystem:public-info-viewerbinding. - Auto-reconciliation. Default roles and bindings are re-created at API server start unless annotated
rbac.authorization.kubernetes.io/autoupdate: "false"; edits toviewitself are overwritten, which is why aggregation exists.kubectl auth reconcile -f rbac.yamlapplies RBAC manifests additively and reports what changed. kubectl create roleforms.--verb=get,list --resource=pods,pods/log,--resource=deployments.appsfor a group-qualified resource,--resource-name=xfor named objects, andkubectl create clusterrole --aggregation-rule=.... Generating and then reading the YAML beats writing it from memory.
Identity mechanics
- Tokens. Pods get a projected token (
serviceAccountTokensource withexpirationSecondsandaudience) refreshed by the kubelet through theTokenRequestAPI; it is bound to the pod and dies with it.kubectl create token <sa> --duration=1hmints one for scripts;--bound-object-kind Pod --bound-object-name xbinds it to an object. Secret-based long-lived tokens are not auto-created since 1.24 and are cleaned up when unused; a task that says "the CI system needs a token" wantskubectl create tokenor an explicit Secret of typekubernetes.io/service-account-tokenonly if it truly needs a non-expiring one.automountServiceAccountToken: falseon the pod or the SA removes the volume. - OIDC. Legacy flags:
--oidc-issuer-url,--oidc-client-id,--oidc-username-claim(defaultsub),--oidc-username-prefix,--oidc-groups-claim,--oidc-groups-prefix. The structured form is anAuthenticationConfigurationfile passed with--authentication-config:jwt[].issuer.url/audiences,claimMappings.username.claim|prefix,claimMappings.groups.claim|prefix, CELclaimValidationRules, several issuers. Groups arrive with the prefix (oidc:platform-team), so a RoleBinding toplatform-teamwithout the prefix silently matches nobody;kubectl auth whoamishows the real string. - Groups you get for free.
system:serviceaccounts(all SAs),system:serviceaccounts:<ns>(one namespace),system:masters(bypasses RBAC entirely; never bind a real user into it),system:nodes.
Three variants: "user X must be able to do Y in namespace Z and nothing else", "the CI ServiceAccount must be able to submit workflows and read their status", and "explain why user X gets Forbidden although a binding exists" (prefixes, group names, the binding referencing a Role in the wrong namespace, or resourceNames on a list). End every one with kubectl auth can-i --as and, for real identities, kubectl auth whoami.
Secrets: the controls half
A stock Secret is base64, not encryption. Anyone with get secrets in the namespace has the plaintext. So does anyone with etcd access, unless encryption-at-rest is configured on the API server (EncryptionConfiguration, optionally backed by a KMS; worth knowing as a phrase). Hence three rules: do not grant get secrets casually, prefer projected/short-lived tokens over long-lived ones, and never let a plain Secret near git.
| Approach | What git holds | Who can decrypt | Good for |
|---|---|---|---|
| Sealed Secrets | ciphertext (a SealedSecret CR) | only the in-cluster controller's private key | self-contained clusters, no external store |
| External Secrets (ESO) | a reference (SecretStore + ExternalSecret) | whoever the store authorizes | an existing vault/cloud secret manager is the source of truth |
| SOPS | partially-encrypted YAML | key holders (age/PGP/KMS) | Flux-native workflows, reviewable diffs |
The distinction to be able to state: sealed = encrypted at rest in git; ESO = git holds only pointers, the store holds truth, and rotation happens outside your repo. Both remove plaintext from version control; only ESO gives you central rotation and audit.
ESO's object model, since it is the one you are most likely to meet
SecretStore(namespaced) orClusterSecretStore(cluster-wide, referenced withkind: ClusterSecretStore) declares where the secrets live and how to authenticate: a provider block (vault, aws, gcp, azure, kubernetes,fakefor practice) plus credentials, usually a ServiceAccount token or a Secret reference.ExternalSecretdeclares what to materialize:secretStoreRef, arefreshInterval, and either explicitdata[]entries (remote key → local key) ordataFromto pull a whole path.target.namenames the Secret it creates,target.creationPolicydecides whether ESO owns it, andtarget.templatelets you assemble a purpose-built Secret (a full config file, a connection string) instead of raw key-value pairs.- Status conditions to read:
SecretSyncedfor success; a failure names the store, the missing key, or the auth error, in that order of likelihood. PushSecretgoes the other way, for the rare case where the cluster is the source of truth.
The skill the exam tests is the same one as everywhere else in this domain: read the CRD, wire two objects together, then prove it with the materialized Secret rather than the apply exit code.
The sealed value is encrypted for a specific controller and (by default) a specific namespace/name. Rebuild the cluster without restoring the controller's key and every SealedSecret in git becomes undecryptable, so the sealing key is a backup item.
Secrets: the fields and formats
Encryption at rest
An EncryptionConfiguration (apiserver.config.k8s.io/v1) passed with --encryption-provider-config lists resources[], each with the resource names to cover (secrets, configmaps, custom resources such as externalsecrets.external-secrets.io, or wildcards like *.*) and an ordered providers[]. The first provider encrypts new writes; all listed providers can decrypt, which is how rotation works: add the new key first, restart, then kubectl get secrets -A -o json | kubectl replace -f - to rewrite every object, then remove the old key. Providers: identity (no encryption; the default if the file is absent), aescbc, aesgcm (needs rotation every 200k writes), secretbox, and kms with apiVersion: v2 for an external KMS plugin over a Unix socket (v1 is deprecated). Wildcards need 1.27+, custom resources 1.26+. Reading etcd directly shows k8s:enc:aescbc:v1:key1: prefixes when it is working.
External Secrets Operator, the decisive fields
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata: { name: db, namespace: team-a }
spec:
secretStoreRef: { name: vault, kind: ClusterSecretStore }
refreshPolicy: Periodic # CreatedOnce | Periodic (default) | OnChange
refreshInterval: 1h # 0 means create once, never update
target:
name: db-credentials
creationPolicy: Owner # Owner | Orphan | Merge | None
deletionPolicy: Retain # Retain | Delete | Merge
template:
engineVersion: v2
data:
DSN: "postgres://{{ .username }}:{{ .password }}@db:5432/app"
data:
- secretKey: username
remoteRef: { key: team-a/db, property: username }
dataFrom:
- extract: { key: team-a/db } # every property becomes a keyrefreshPolicy:Periodicsyncs everyrefreshIntervaland on spec change;OnChangeonly when the ExternalSecret changes;CreatedOnceonce per object (recreating the ExternalSecret re-fetches, so pair it withtarget.immutable: truefor credentials nothing may overwrite).kubectl annotate es x force-sync=$(date +%s)triggers an immediate sync.creationPolicy:Owner(ESO owns the Secret, deletes it with the ExternalSecret),Orphan(leaves it),Merge(adds keys to an existing Secret you own),None(never creates).deletionPolicydecides what happens to the Secret when the remote key disappears.dataFrom.extractpulls a whole JSON object as keys;dataFrom.findselects by path regex or tags;rewriterenames keys.template(engine v2, Go templates with Sprig) builds config files and connection strings;templateFromreads the template out of a ConfigMap;mergePolicy: Mergekeeps the raw keys alongside the templated ones.- Scoping:
SecretStoreis namespaced;ClusterSecretStorecan restrict who may use it withconditions[].namespaceSelectorornamespaces.ClusterExternalSecretstamps one ExternalSecret into every namespace matchingnamespaceSelectors.PushSecretreverses the flow (cluster Secret to store) withupdatePolicy: Replace|IfNotExists. - Status: condition
Readywith reasonSecretSyncedorSecretSyncedErrorand a message naming the store, the missing key or the auth error, in that order of likelihood. The store has its ownReadycondition (Validreason) that fails first when credentials are wrong.
The other three tools, in fields
- Sealed Secrets. Scope decides where a ciphertext may be unsealed:
strict(default; name and namespace are part of the encryption, so renaming givesdecryption error),namespace-wide(annotationsealedsecrets.bitnami.com/namespace-wide: "true", any name in that namespace),cluster-wide(any namespace).kubeseal --scope,--fetch-certto seal offline,--merge-intoto add one key,--rawfor a single value. The controller rotates its sealing key every 30 days and keeps old ones, so old ciphertexts still open; back up thesealed-secrets-key*Secrets inkube-system. Annotating an existing Secretsealedsecrets.bitnami.com/managed: "true"lets a SealedSecret take it over;.../patch: "true"merges instead of replacing. - SOPS with Flux. A
.sops.yamlwithcreation_rules(path_regex,ageorpgporkmsrecipients,encrypted_regex: ^(data|stringData)$so metadata stays readable) andsops --encrypt --in-place secret.yaml. The Kustomization declaresspec.decryption: { provider: sops, secretRef: { name: sops-age } }, where the Secret holds the age key file (age.agekey) or a GPG private key; kustomize-controller decrypts in memory before applying. Diffs stay reviewable because only values are ciphertext. - Secrets Store CSI driver. A
SecretProviderClass(providervault,azure,aws,gcp) plus a pod volumecsi.driver: secrets-store.csi.k8s.iowithvolumeAttributes.secretProviderClass; values are mounted as files, never stored in etcd unlesssecretObjectsasks the driver to sync them into a Kubernetes Secret (for env vars). Rotation is--enable-secret-rotationand--rotation-poll-intervalon the driver. Pick it when "the secret must never exist in etcd"; pick ESO when workloads already read Kubernetes Secrets.
list and watch on Secrets return the values, not just the names. A "read-only" role that includes them has full access to every secret in the namespace. The Kubernetes good-practices page says to grant get on named Secrets (resourceNames) and reserve list/watch for controllers that need them; that sentence is a defensible exam answer on its own.
Exercises
examples/multitenancy/team-a.yaml binds Role developer to user dev-a. Predict, then check, each of these:
kubectl auth can-i create deployments --as=dev-a -n team-a
kubectl auth can-i delete secrets --as=dev-a -n team-a
kubectl auth can-i list secrets --as=dev-a -n team-a
kubectl auth can-i create pods --as=dev-a -n team-b
kubectl auth can-i list nodes --as=dev-aoutputcaptured 2026-08-26
$ kubectl auth can-i create deployments --as=dev-a -n team-a
yes
$ kubectl auth can-i delete secrets --as=dev-a -n team-a
no
$ kubectl auth can-i list secrets --as=dev-a -n team-a
yes
$ kubectl auth can-i create pods --as=dev-a -n team-b
no
$ kubectl auth can-i list nodes --as=dev-a
Warning: resource 'nodes' is not namespace scoped
noConvert developer into a ClusterRole and bind it into team-b with a RoleBinding for user dev-b.
kubectl auth can-i create deployments --as=dev-b -n team-b yes, -n team-a no. One definition, per-tenant grants; this pattern is the answer to most "design RBAC for tenants" prompts.Create SA reporter in team-a allowed only to get,list pods, then prove it from inside. One obstacle first: team-a's deny-all egress also blocks the API server (you met this in 3.3), so open that path by applying the allow-apiserver CiliumNetworkPolicy from the comments in examples/crossplane/pg-cluster.yaml, and delete it when done:
kubectl -n team-a create sa reporter
kubectl -n team-a create role pod-reader --verb=get,list --resource=pods
kubectl -n team-a create rolebinding reporter --role=pod-reader --serviceaccount=team-a:reporter
kubectl -n team-a run api-probe --image=bitnami/kubectl:latest --restart=Never \
--overrides='{"spec":{"serviceAccountName":"reporter"}}' -- get pods
kubectl -n team-a logs api-probeoutputcaptured 2026-08-26
$ kubectl -n team-a create sa reporter
serviceaccount/reporter created
$ kubectl -n team-a create role pod-reader --verb=get,list --resource=pods
role.rbac.authorization.k8s.io/pod-reader created
$ kubectl -n team-a create rolebinding reporter --role=pod-reader --serviceaccount=team-a:reporter
rolebinding.rbac.authorization.k8s.io/reporter created
$ kubectl -n team-a run api-probe --image=bitnami/kubectl:latest --restart=Never \
--overrides='{"spec":{"serviceAccountName":"reporter"}}' -- get pods
Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "api-probe" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "api-probe" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "api-probe" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "api-probe" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
pod/api-probe created
$ kubectl -n team-a logs api-probe
NAME READY STATUS RESTARTS AGE
api-probe 1/1 Running 0 13s
chatty 0/1 Completed 0 5m8s
demo-5cfc49f565-6zqmt 1/1 Running 0 97s
demo-5cfc49f565-nx4r8 1/1 Running 0 7m34s
demo-5cfc49f565-trpgb 1/1 Running 0 7m34s
demo-5cfc49f565-w7c85 1/1 Running 0 97s
staging-demo-66955f8974-2xxd2 1/1 Running 0 26m
staging-demo-66955f8974-pzxdh 1/1 Running 0 26m
staging-demo-66955f8974-tzpqx 1/1 Running 0 26m(The image's entrypoint is already kubectl, so the args are just get pods; doubling it up runs kubectl kubectl.)
-- get secrets and read the Forbidden message, noting it names the SA, the verb, and the resource. That message format is the same one you met in workflow failures (3.4) and will meet again in operator logs.The controller from make sec is in kube-system:
kubectl -n team-a create secret generic db-pass --from-literal=password=hunter2 \
--dry-run=client -o yaml | kubeseal --controller-namespace kube-system --controller-name sealed-secrets -o yaml > sealed.yaml
grep -c hunter2 sealed.yaml # must print 0
kubectl apply -f sealed.yaml
kubectl -n team-a get secret db-pass -o jsonpath='{.data.password}' | base64 -doutputcaptured 2026-08-26
$ kubectl -n team-a create secret generic db-pass --from-literal=password=hunter2 \
--dry-run=client -o yaml | kubeseal --controller-namespace kube-system --controller-name sealed-secrets -o yaml > sealed.yaml
$ grep -c hunter2 sealed.yaml # must print 0
0
$ kubectl apply -f sealed.yaml
sealedsecret.bitnami.com/db-pass created
$ kubectl -n team-a get secret db-pass -o jsonpath='{.data.password}' | base64 -d
hunter2hunter2. Now delete the Secret only and watch the controller recreate it from the SealedSecret; that reconcile is why the sealed form is the source of truth you commit.External Secrets ships a fake provider made for exactly this practice: create a SecretStore of provider fake holding key pg/password, an ExternalSecret targeting it, and verify the materialized Secret appears with the value, refreshed on interval.
kubectl get externalsecret shows SecretSynced True. The provider is fake; the CRD mechanics, which are what the exam could touch, are entirely real.FAULT=rbac make break. Everything looks Running; only the can-i matrix shows the hole.
auth can-i output.kubectl auth whoami answers the question every impersonation task starts with. Run it under --as and you can see exactly what the API server will evaluate rules against, including the groups you may have forgotten to pass.
kubectl auth whoami
kubectl auth whoami --as dev-a
kubectl auth whoami --as dev-a --as-group platform --as-group system:authenticated
kubectl auth can-i --list --as dev-a --as-group platform -n team-a | head -15outputcaptured 2026-09-12
$ kubectl auth whoami
ATTRIBUTE VALUE
Username kubernetes-admin
Groups [kubeadm:cluster-admins system:authenticated]
Extra: authentication.kubernetes.io/credential-id [X509SHA256=5e1514c2eb0eb304bb87c1a2eb03282a5074648ec504dd0ed919195f9d9017d5]
$ kubectl auth whoami --as dev-a
ATTRIBUTE VALUE
Username dev-a
Groups [system:authenticated]
$ kubectl auth whoami --as dev-a --as-group platform --as-group system:authenticated
ATTRIBUTE VALUE
Username dev-a
Groups [platform system:authenticated]
$ kubectl auth can-i --list --as dev-a --as-group platform -n team-a | head -15
Resources Non-Resource URLs Resource Names Verbs
selfsubjectreviews.authentication.k8s.io [] [] [create]
selfsubjectaccessreviews.authorization.k8s.io [] [] [create]
selfsubjectrulesreviews.authorization.k8s.io [] [] [create]
configmaps [] [] [get list watch create update patch delete]
deployments [] [] [get list watch create update patch delete]
jobs [] [] [get list watch create update patch delete]
pods/log [] [] [get list watch create update patch delete]
pods [] [] [get list watch create update patch delete]
services [] [] [get list watch create update patch delete]
statefulsets [] [] [get list watch create update patch delete]
configmaps.apps [] [] [get list watch create update patch delete]
deployments.apps [] [] [get list watch create update patch delete]
jobs.apps [] [] [get list watch create update patch delete]
pods.apps/log [] [] [get list watch create update patch delete]--as-group drops you into only the default groups, which is why a binding to a group appears not to work.RBAC forbids privilege escalation: you cannot create a binding that grants permissions you do not hold. The escape is the bind verb on the specific role, and the error before it is one you should recognize instantly.
kubectl -n team-a create rolebinding dev-a-admin --clusterrole=admin --user=dev-a
kubectl create clusterrole node-reader --verb=get,list --resource=nodes
kubectl -n team-a create rolebinding node-reader-binding --clusterrole=node-reader --user=dev-b --as dev-a
kubectl create clusterrole binder --verb=bind --resource=clusterroles --resource-name=node-reader
kubectl -n team-a create rolebinding dev-a-binder --clusterrole=binder --user=dev-a
kubectl -n team-a create rolebinding node-reader-binding --clusterrole=node-reader --user=dev-b --as dev-a
kubectl -n team-a delete rolebinding node-reader-binding dev-a-binder dev-a-admin --ignore-not-found
kubectl delete clusterrole node-reader binderoutputcaptured 2026-09-12
$ kubectl -n team-a create rolebinding dev-a-admin --clusterrole=admin --user=dev-a
rolebinding.rbac.authorization.k8s.io/dev-a-admin created
$ kubectl create clusterrole node-reader --verb=get,list --resource=nodes
clusterrole.rbac.authorization.k8s.io/node-reader created
$ kubectl -n team-a create rolebinding node-reader-binding --clusterrole=node-reader --user=dev-b --as dev-a
error: failed to create rolebinding: rolebindings.rbac.authorization.k8s.io "node-reader-binding" is forbidden: user "dev-a" (groups=["system:authenticated"]) is attempting to grant RBAC permissions not currently held:
{APIGroups:[""], Resources:["nodes"], Verbs:["get" "list"]}
$ kubectl create clusterrole binder --verb=bind --resource=clusterroles --resource-name=node-reader
clusterrole.rbac.authorization.k8s.io/binder created
$ kubectl -n team-a create rolebinding dev-a-binder --clusterrole=binder --user=dev-a
rolebinding.rbac.authorization.k8s.io/dev-a-binder created
$ kubectl -n team-a create rolebinding node-reader-binding --clusterrole=node-reader --user=dev-b --as dev-a
rolebinding.rbac.authorization.k8s.io/node-reader-binding created
$ kubectl -n team-a delete rolebinding node-reader-binding dev-a-binder dev-a-admin --ignore-not-found
rolebinding.rbac.authorization.k8s.io "node-reader-binding" deleted from team-a namespace
rolebinding.rbac.authorization.k8s.io "dev-a-binder" deleted from team-a namespace
rolebinding.rbac.authorization.k8s.io "dev-a-admin" deleted from team-a namespace
$ kubectl delete clusterrole node-reader binder
clusterrole.rbac.authorization.k8s.io "node-reader" deleted
clusterrole.rbac.authorization.k8s.io "binder" deletedbind on that exact ClusterRole is granted. Say why resourceNames on the bind rule matters more than the verb.A ServiceAccount token is a JWT with an expiry and an audience, and since it stopped being a Secret by default, knowing how to mint and inspect one is basic hygiene. The bound claims are what make it safe.
kubectl -n team-a create serviceaccount reporter --dry-run=client -o yaml | kubectl apply -f -
TOKEN=$(kubectl -n team-a create token reporter --duration=10m)
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq
kubectl -n team-a run tokenholder --image=nginx:1.27-alpine --restart=Never --overrides='{"spec":{"serviceAccountName":"reporter","containers":[{"name":"tokenholder","image":"nginx:1.27-alpine","resources":{"requests":{"cpu":"25m","memory":"32Mi"},"limits":{"cpu":"100m","memory":"64Mi"}}}]}}'
kubectl -n team-a wait --for=condition=Ready pod/tokenholder --timeout=120s
kubectl -n team-a exec tokenholder -- cat /var/run/secrets/kubernetes.io/serviceaccount/token | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq '{aud, exp, "kubernetes.io": ."kubernetes.io"}'
kubectl -n team-a delete pod tokenholder
kubectl -n team-a delete serviceaccount reporteroutputcaptured 2026-09-12
$ kubectl -n team-a create serviceaccount reporter --dry-run=client -o yaml | kubectl apply -f -
serviceaccount/reporter created
$ TOKEN=$(kubectl -n team-a create token reporter --duration=10m)
$ echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq
{
"aud": [
"https://kubernetes.default.svc.cluster.local"
],
"exp": 1789239113,
"iat": 1789238513,
"iss": "https://kubernetes.default.svc.cluster.local",
"jti": "4a9851ec-3d17-4c59-aaa3-c5395ace326f",
"kubernetes.io": {
"namespace": "team-a",
"serviceaccount": {
"name": "reporter",
"uid": "dcf3e81b-8822-4a69-81a1-dd5be70d6c44"
}
},
"nbf": 1789238513,
"sub": "system:serviceaccount:team-a:reporter"
}
$ kubectl -n team-a run tokenholder --image=nginx:1.27-alpine --restart=Never --overrides='{"spec":{"serviceAccountName":"reporter","containers":[{"name":"tokenholder","image":"nginx:1.27-alpine","resources":{"requests":{"cpu":"25m","memory":"32Mi"},"limits":{"cpu":"100m","memory":"64Mi"}}}]}}'
Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "tokenholder" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "tokenholder" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "tokenholder" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "tokenholder" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
pod/tokenholder created
$ kubectl -n team-a wait --for=condition=Ready pod/tokenholder --timeout=120s
pod/tokenholder condition met
$ kubectl -n team-a exec tokenholder -- cat /var/run/secrets/kubernetes.io/serviceaccount/token | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq '{aud, exp, "kubernetes.io": ."kubernetes.io"}'
{
"aud": [
"https://kubernetes.default.svc.cluster.local"
],
"exp": 1820774514,
"kubernetes.io": {
"namespace": "team-a",
"node": {
"name": "cnpe-worker2",
"uid": "b6be3349-5691-412e-9523-7ba669580823"
},
"pod": {
"name": "tokenholder",
"uid": "447d638e-ffcf-41da-a1d7-5bc0b7a33a23"
},
"serviceaccount": {
"name": "reporter",
"uid": "dcf3e81b-8822-4a69-81a1-dd5be70d6c44"
},
"warnafter": 1789242121
}
}
$ kubectl -n team-a delete pod tokenholder
pod "tokenholder" deleted from team-a namespace
$ kubectl -n team-a delete serviceaccount reporter
serviceaccount "reporter" deleted from team-a namespaceexp and an aud, and the one projected into the pod additionally names the pod under kubernetes.io.ESO's refresh policy decides whether the Kubernetes Secret tracks the external store or is a one-time copy. Combined with an immutable target, "CreatedOnce" is how you stop a rotation from breaking a running workload without a deploy.
# start from nothing: an immutable target from a previous run cannot be updated
kubectl delete externalsecret pg-once pg-periodic --ignore-not-found
kubectl delete secret pg-once pg-periodic --ignore-not-found
kubectl delete secretstore fake-store --ignore-not-found
kubectl apply -f - <<'EOF'
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata: { name: fake-store, namespace: default }
spec:
provider:
fake:
data:
- key: pg/password
value: first-value
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata: { name: pg-once, namespace: default }
spec:
refreshPolicy: CreatedOnce
secretStoreRef: { name: fake-store, kind: SecretStore }
target:
name: pg-once
immutable: true
data:
- secretKey: password
remoteRef: { key: pg/password }
EOF
sleep 20
kubectl get secret pg-once -o jsonpath='{.data.password}' | base64 -d; echo
kubectl patch secretstore fake-store --type merge -p '{"spec":{"provider":{"fake":{"data":[{"key":"pg/password","value":"second-value"}]}}}}'
sleep 60
kubectl get secret pg-once -o jsonpath='{.data.password}' | base64 -d; echo
kubectl apply -f - <<'EOF'
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata: { name: pg-periodic, namespace: default }
spec:
refreshPolicy: Periodic
refreshInterval: 30s
secretStoreRef: { name: fake-store, kind: SecretStore }
target: { name: pg-periodic }
data:
- secretKey: password
remoteRef: { key: pg/password }
EOF
sleep 20
kubectl get secret pg-periodic -o jsonpath='{.data.password}' | base64 -d; echo
kubectl patch secretstore fake-store --type merge -p '{"spec":{"provider":{"fake":{"data":[{"key":"pg/password","value":"third-value"}]}}}}'
sleep 60
kubectl get secret pg-periodic -o jsonpath='{.data.password}' | base64 -d; echo
kubectl delete externalsecret pg-once pg-periodic
kubectl delete secret pg-once pg-periodic --ignore-not-found
kubectl delete secretstore fake-store --ignore-not-foundoutputcaptured 2026-09-12
$ # start from nothing: an immutable target from a previous run cannot be updated
$ kubectl delete externalsecret pg-once pg-periodic --ignore-not-found
$ kubectl delete secret pg-once pg-periodic --ignore-not-found
$ kubectl delete secretstore fake-store --ignore-not-found
secretstore.external-secrets.io "fake-store" deleted from default namespace
$ kubectl apply -f - <<'EOF'
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata: { name: fake-store, namespace: default }
spec:
provider:
fake:
data:
- key: pg/password
value: first-value
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata: { name: pg-once, namespace: default }
spec:
refreshPolicy: CreatedOnce
secretStoreRef: { name: fake-store, kind: SecretStore }
target:
name: pg-once
immutable: true
data:
- secretKey: password
remoteRef: { key: pg/password }
EOF
Warning: store fake-store isn't currently maintained. Please plan and prepare accordingly.
secretstore.external-secrets.io/fake-store created
externalsecret.external-secrets.io/pg-once created
$ sleep 20
$ kubectl get secret pg-once -o jsonpath='{.data.password}' | base64 -d; echo
first-value
$ kubectl patch secretstore fake-store --type merge -p '{"spec":{"provider":{"fake":{"data":[{"key":"pg/password","value":"second-value"}]}}}}'
Warning: store fake-store isn't currently maintained. Please plan and prepare accordingly.
secretstore.external-secrets.io/fake-store patched
$ sleep 60
$ kubectl get secret pg-once -o jsonpath='{.data.password}' | base64 -d; echo
first-value
$ kubectl apply -f - <<'EOF'
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata: { name: pg-periodic, namespace: default }
spec:
refreshPolicy: Periodic
refreshInterval: 30s
secretStoreRef: { name: fake-store, kind: SecretStore }
target: { name: pg-periodic }
data:
- secretKey: password
remoteRef: { key: pg/password }
EOF
externalsecret.external-secrets.io/pg-periodic created
$ sleep 20
$ kubectl get secret pg-periodic -o jsonpath='{.data.password}' | base64 -d; echo
second-value
$ kubectl patch secretstore fake-store --type merge -p '{"spec":{"provider":{"fake":{"data":[{"key":"pg/password","value":"third-value"}]}}}}'
Warning: store fake-store isn't currently maintained. Please plan and prepare accordingly.
secretstore.external-secrets.io/fake-store patched
$ sleep 60
$ kubectl get secret pg-periodic -o jsonpath='{.data.password}' | base64 -d; echo
third-value
$ kubectl delete externalsecret pg-once pg-periodic
externalsecret.external-secrets.io "pg-once" deleted from default namespace
externalsecret.external-secrets.io "pg-periodic" deleted from default namespace
$ kubectl delete secret pg-once pg-periodic --ignore-not-found
$ kubectl delete secretstore fake-store --ignore-not-found
secretstore.external-secrets.io "fake-store" deleted from default namespaceReal secret stores hold JSON blobs, not tidy key-value pairs. dataFrom.extract unpacks one and a target template assembles exactly the string the application expects, so the app needs no knowledge of the store.
# start from nothing: an immutable target from a previous run cannot be updated
kubectl delete externalsecret pg-dsn --ignore-not-found
kubectl delete secret pg-dsn --ignore-not-found
kubectl delete secretstore json-store --ignore-not-found
kubectl apply -f - <<'EOF'
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata: { name: json-store, namespace: default }
spec:
provider:
fake:
data:
- key: pg/creds
value: '{"username":"app","password":"s3cr3t","host":"pg-rw.team-a.svc","port":"5432","db":"orders"}'
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata: { name: pg-dsn, namespace: default }
spec:
refreshInterval: 1m
secretStoreRef: { name: json-store, kind: SecretStore }
target:
name: pg-dsn
template:
engineVersion: v2
data:
DATABASE_URL: "postgres://{{ .username }}:{{ .password }}@{{ .host }}:{{ .port }}/{{ .db }}?sslmode=require"
dataFrom:
- extract: { key: pg/creds }
EOF
sleep 20
kubectl get externalsecret pg-dsn -o jsonpath='{.status.conditions}' | jq '.[] | {type, status, reason}'
kubectl get secret pg-dsn -o jsonpath='{.data.DATABASE_URL}' | base64 -d; echo
kubectl delete externalsecret pg-dsn
kubectl delete secretstore json-storeoutputcaptured 2026-09-13
$ # start from nothing: an immutable target from a previous run cannot be updated
$ kubectl delete externalsecret pg-dsn --ignore-not-found
$ kubectl delete secret pg-dsn --ignore-not-found
$ kubectl delete secretstore json-store --ignore-not-found
$ kubectl apply -f - <<'EOF'
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata: { name: json-store, namespace: default }
spec:
provider:
fake:
data:
- key: pg/creds
value: '{"username":"app","password":"s3cr3t","host":"pg-rw.team-a.svc","port":"5432","db":"orders"}'
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata: { name: pg-dsn, namespace: default }
spec:
refreshInterval: 1m
secretStoreRef: { name: json-store, kind: SecretStore }
target:
name: pg-dsn
template:
engineVersion: v2
data:
DATABASE_URL: "postgres://{{ .username }}:{{ .password }}@{{ .host }}:{{ .port }}/{{ .db }}?sslmode=require"
dataFrom:
- extract: { key: pg/creds }
EOF
Warning: store json-store isn't currently maintained. Please plan and prepare accordingly.
secretstore.external-secrets.io/json-store created
externalsecret.external-secrets.io/pg-dsn created
$ sleep 20
$ kubectl get externalsecret pg-dsn -o jsonpath='{.status.conditions}' | jq '.[] | {type, status, reason}'
{
"type": "Ready",
"status": "True",
"reason": "SecretSynced"
}
$ kubectl get secret pg-dsn -o jsonpath='{.data.DATABASE_URL}' | base64 -d; echo
postgres://app:s3cr3t@pg-rw.team-a.svc:5432/orders?sslmode=require
$ kubectl delete externalsecret pg-dsn
externalsecret.external-secrets.io "pg-dsn" deleted from default namespace
$ kubectl delete secretstore json-store
secretstore.external-secrets.io "json-store" deleted from default namespaceA SealedSecret is bound to its name and namespace by default, which is exactly right and occasionally inconvenient. The scope annotation trades some of that binding for portability, and the failure when you get it wrong is in the controller's log.
echo -n 'strict-value' | kubeseal --raw --namespace default --name strict-secret --controller-namespace kube-system --controller-name sealed-secrets > /tmp/strict.b64
cat > /tmp/strict.yaml <<EOF
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata: { name: strict-secret, namespace: default }
spec:
encryptedData:
value: $(cat /tmp/strict.b64)
EOF
kubectl apply -f /tmp/strict.yaml
sleep 10
kubectl get secret strict-secret -o jsonpath='{.data.value}' | base64 -d; echo
sed 's/name: strict-secret/name: renamed-secret/' /tmp/strict.yaml | kubectl apply -f -
sleep 10
kubectl get secret renamed-secret 2>/dev/null || echo 'not decrypted'
kubectl -n kube-system logs deploy/sealed-secrets --tail=10
echo -n 'wide-value' | kubeseal --raw --scope namespace-wide --namespace default --controller-namespace kube-system --controller-name sealed-secrets > /tmp/wide.b64
cat > /tmp/wide.yaml <<EOF
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: wide-secret
namespace: default
annotations: { sealedsecrets.bitnami.com/namespace-wide: "true" }
spec:
encryptedData:
value: $(cat /tmp/wide.b64)
EOF
kubectl apply -f /tmp/wide.yaml
sleep 10
sed 's/name: wide-secret/name: wide-renamed/' /tmp/wide.yaml | kubectl apply -f -
sleep 10
kubectl get secret wide-renamed -o jsonpath='{.data.value}' | base64 -d; echo
kubectl delete sealedsecret strict-secret renamed-secret wide-secret wide-renamed --ignore-not-foundoutputcaptured 2026-09-12
$ echo -n 'strict-value' | kubeseal --raw --namespace default --name strict-secret --controller-namespace kube-system --controller-name sealed-secrets > /tmp/strict.b64
$ cat > /tmp/strict.yaml <<EOF
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata: { name: strict-secret, namespace: default }
spec:
encryptedData:
value: $(cat /tmp/strict.b64)
EOF
$ kubectl apply -f /tmp/strict.yaml
sealedsecret.bitnami.com/strict-secret created
$ sleep 10
$ kubectl get secret strict-secret -o jsonpath='{.data.value}' | base64 -d; echo
strict-value
$ sed 's/name: strict-secret/name: renamed-secret/' /tmp/strict.yaml | kubectl apply -f -
sealedsecret.bitnami.com/renamed-secret created
$ sleep 10
$ kubectl get secret renamed-secret 2>/dev/null || echo 'not decrypted'
not decrypted
$ kubectl -n kube-system logs deploy/sealed-secrets --tail=10
time=2026-09-12T19:44:52.637Z level=INFO msg=Updating key=default/renamed-secret
time=2026-09-12T19:44:52.644Z level=ERROR msg="Error updating, will retry" key=default/renamed-secret error="no key could decrypt secret (value)"
time=2026-09-12T19:44:52.644Z level=INFO msg="Event(v1.ObjectReference{Kind:\"SealedSecret\", Namespace:\"default\", Name:\"renamed-secret\", UID:\"9f10a4d2-f72f-4f66-ad14-12352277718c\", APIVersion:\"bitnami.com/v1alpha1\", ResourceVersion:\"27307\", FieldPath:\"\"}): type: 'Warning' reason: 'ErrUnsealFailed' Failed to unseal: no key could decrypt secret (value)"
time=2026-09-12T19:44:52.685Z level=INFO msg=Updating key=default/renamed-secret
time=2026-09-12T19:44:52.692Z level=ERROR msg="Error updating, will retry" key=default/renamed-secret error="no key could decrypt secret (value)"
time=2026-09-12T19:44:52.692Z level=INFO msg="Event(v1.ObjectReference{Kind:\"SealedSecret\", Namespace:\"default\", Name:\"renamed-secret\", UID:\"9f10a4d2-f72f-4f66-ad14-12352277718c\", APIVersion:\"bitnami.com/v1alpha1\", ResourceVersion:\"27307\", FieldPath:\"\"}): type: 'Warning' reason: 'ErrUnsealFailed' Failed to unseal: no key could decrypt secret (value)"
time=2026-09-12T19:44:52.773Z level=INFO msg=Updating key=default/renamed-secret
time=2026-09-12T19:44:52.780Z level=ERROR msg="Error updating, giving up" key=default/renamed-secret error="no key could decrypt secret (value)"
E0912 19:44:52.780710 1 controller.go:320] "Unhandled Error" err="no key could decrypt secret (value)" logger="UnhandledError"
time=2026-09-12T19:44:52.780Z level=INFO msg="Event(v1.ObjectReference{Kind:\"SealedSecret\", Namespace:\"default\", Name:\"renamed-secret\", UID:\"9f10a4d2-f72f-4f66-ad14-12352277718c\", APIVersion:\"bitnami.com/v1alpha1\", ResourceVersion:\"27307\", FieldPath:\"\"}): type: 'Warning' reason: 'ErrUnsealFailed' Failed to unseal: no key could decrypt secret (value)"
$ echo -n 'wide-value' | kubeseal --raw --scope namespace-wide --namespace default --controller-namespace kube-system --controller-name sealed-secrets > /tmp/wide.b64
$ cat > /tmp/wide.yaml <<EOF
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: wide-secret
namespace: default
annotations: { sealedsecrets.bitnami.com/namespace-wide: "true" }
spec:
encryptedData:
value: $(cat /tmp/wide.b64)
EOF
$ kubectl apply -f /tmp/wide.yaml
sealedsecret.bitnami.com/wide-secret created
$ sleep 10
$ sed 's/name: wide-secret/name: wide-renamed/' /tmp/wide.yaml | kubectl apply -f -
sealedsecret.bitnami.com/wide-renamed created
$ sleep 10
$ kubectl get secret wide-renamed -o jsonpath='{.data.value}' | base64 -d; echo
wide-value
$ kubectl delete sealedsecret strict-secret renamed-secret wide-secret wide-renamed --ignore-not-found
sealedsecret.bitnami.com "strict-secret" deleted from default namespace
sealedsecret.bitnami.com "renamed-secret" deleted from default namespace
sealedsecret.bitnami.com "wide-secret" deleted from default namespace
sealedsecret.bitnami.com "wide-renamed" deleted from default namespacecluster-wide would give up, and why you would rarely want it.SOPS is the other answer to secrets in git: the file stays readable except for the values, and Flux decrypts it at apply time with a key that only the cluster has. The whole loop is five commands.
START=$PWD # the cd below would otherwise follow you into every later command
mkdir -p "$HOME/.local/bin"; export PATH="$HOME/.local/bin:$PATH"
curl -sL "$(curl -s https://api.github.com/repos/FiloSottile/age/releases/latest | jq -r '.assets[] | select(.name | endswith("linux-amd64.tar.gz")) | .browser_download_url')" | tar xz -C /tmp && install /tmp/age/age /tmp/age/age-keygen "$HOME/.local/bin/"
curl -sLo "$HOME/.local/bin/sops" "$(curl -s https://api.github.com/repos/getsops/sops/releases/latest | jq -r '.assets[] | select(.name | endswith("linux.amd64")) | .browser_download_url')" && chmod +x "$HOME/.local/bin/sops"
age-keygen --version; sops --version | head -1
rm -f /tmp/sops.agekey && age-keygen -o /tmp/sops.agekey
PUB=$(grep 'public key' /tmp/sops.agekey | awk '{print $NF}'); echo "recipient: $PUB"
kubectl -n flux-system create secret generic sops-age --from-file=age.agekey=/tmp/sops.agekey --dry-run=client -o yaml | kubectl apply -f -
rm -rf /tmp/platform-sops && git clone "http://lab:${GITEA_PASS}@gitea.lab:3000/lab/platform.git" /tmp/platform-sops && cd /tmp/platform-sops
mkdir -p secrets
cat > .sops.yaml <<EOF
creation_rules:
- path_regex: secrets/.*\.yaml$
encrypted_regex: '^(data|stringData)$'
age: $PUB
EOF
kubectl -n flux-demo create secret generic app-config --from-literal=api-key=super-secret --dry-run=client -o yaml > secrets/app-config.yaml
sops --encrypt --in-place secrets/app-config.yaml
head -12 secrets/app-config.yaml
git add .sops.yaml secrets && git commit -m 'sops-encrypted secret' && git push
kubectl create ns flux-demo --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f - <<'EOF'
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata: { name: secrets, namespace: flux-system }
spec:
interval: 1m
prune: true
sourceRef: { kind: GitRepository, name: platform }
path: ./secrets
decryption:
provider: sops
secretRef: { name: sops-age }
EOF
sleep 60
kubectl -n flux-demo get secret app-config -o jsonpath='{.data.api-key}' | base64 -d; echo
# put the lab back: this repo is shared with every other GitOps exercise on the site
flux delete kustomization secrets -s
sleep 10
kubectl -n flux-demo get secret app-config --ignore-not-found
git rm -r --quiet secrets .sops.yaml && git commit -m 'remove the sops demo' && git push
kubectl -n flux-system delete secret sops-age
cd "$START"outputcaptured 2026-09-13
$ START=$PWD # the cd below would otherwise follow you into every later command
$ mkdir -p "$HOME/.local/bin"; export PATH="$HOME/.local/bin:$PATH"
$ curl -sL "$(curl -s https://api.github.com/repos/FiloSottile/age/releases/latest | jq -r '.assets[] | select(.name | endswith("linux-amd64.tar.gz")) | .browser_download_url')" | tar xz -C /tmp && install /tmp/age/age /tmp/age/age-keygen "$HOME/.local/bin/"
$ curl -sLo "$HOME/.local/bin/sops" "$(curl -s https://api.github.com/repos/getsops/sops/releases/latest | jq -r '.assets[] | select(.name | endswith("linux.amd64")) | .browser_download_url')" && chmod +x "$HOME/.local/bin/sops"
$ age-keygen --version; sops --version | head -1
v1.3.2
sops 3.13.3 (latest)
$ rm -f /tmp/sops.agekey && age-keygen -o /tmp/sops.agekey
Public key: age13mwr72a08y8qhvusarepklye8h8j9ffw3pxuppgutdqmkj9c3drqqlumxf
$ PUB=$(grep 'public key' /tmp/sops.agekey | awk '{print $NF}'); echo "recipient: $PUB"
recipient: age13mwr72a08y8qhvusarepklye8h8j9ffw3pxuppgutdqmkj9c3drqqlumxf
$ kubectl -n flux-system create secret generic sops-age --from-file=age.agekey=/tmp/sops.agekey --dry-run=client -o yaml | kubectl apply -f -
secret/sops-age created
$ rm -rf /tmp/platform-sops && git clone "http://lab:${GITEA_PASS}@gitea.lab:3000/lab/platform.git" /tmp/platform-sops && cd /tmp/platform-sops
Cloning into '/tmp/platform-sops'...
$ mkdir -p secrets
$ cat > .sops.yaml <<EOF
creation_rules:
- path_regex: secrets/.*\.yaml$
encrypted_regex: '^(data|stringData)$'
age: $PUB
EOF
$ kubectl -n flux-demo create secret generic app-config --from-literal=api-key=super-secret --dry-run=client -o yaml > secrets/app-config.yaml
$ sops --encrypt --in-place secrets/app-config.yaml
$ head -12 secrets/app-config.yaml
apiVersion: v1
data:
api-key: ENC[AES256_GCM,data:Quby+vT4+2SjztVEfihNsg==,iv:toiP/Jmsl8B7MqbLg9I4gXytnuQX6RS9vSy4T1gb/Zw=,tag:TlhhcNVt0Gt5S7pkgRHQYQ==,type:str]
kind: Secret
metadata:
name: app-config
namespace: flux-demo
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvTmFIZE1mWjgxUGNoTUFp
$ git add .sops.yaml secrets && git commit -m 'sops-encrypted secret' && git push
[main fa86adb] sops-encrypted secret
2 files changed, 26 insertions(+)
create mode 100644 .sops.yaml
create mode 100644 secrets/app-config.yaml
To http://gitea.lab:3000/lab/platform.git
a816a73..fa86adb main -> main
$ kubectl create ns flux-demo --dry-run=client -o yaml | kubectl apply -f -
namespace/flux-demo created
$ kubectl apply -f - <<'EOF'
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata: { name: secrets, namespace: flux-system }
spec:
interval: 1m
prune: true
sourceRef: { kind: GitRepository, name: platform }
path: ./secrets
decryption:
provider: sops
secretRef: { name: sops-age }
EOF
kustomization.kustomize.toolkit.fluxcd.io/secrets created
$ sleep 60
$ kubectl -n flux-demo get secret app-config -o jsonpath='{.data.api-key}' | base64 -d; echo
super-secret
$ # put the lab back: this repo is shared with every other GitOps exercise on the site
$ flux delete kustomization secrets -s
► deleting kustomization secrets in flux-system namespace
✔ kustomization deleted
$ sleep 10
$ kubectl -n flux-demo get secret app-config --ignore-not-found
$ git rm -r --quiet secrets .sops.yaml && git commit -m 'remove the sops demo' && git push
[main 4a5f1ae] remove the sops demo
2 files changed, 26 deletions(-)
delete mode 100644 .sops.yaml
delete mode 100644 secrets/app-config.yaml
To http://gitea.lab:3000/lab/platform.git
fa86adb..4a5f1ae main -> main
$ kubectl -n flux-system delete secret sops-age
secret "sops-age" deleted from flux-system namespace
$ cd "$START"Self-check
Define "developer" once and grant it in twelve namespaces. What objects, and how many?
One ClusterRole plus twelve RoleBindings (each referencing that ClusterRole, each in its namespace). Not a ClusterRoleBinding: that would grant everywhere and fail the "and not in the other namespace" check.
A user can get a pod by name but kubectl get pods fails. Why?
list is a separate verb from get, and a rule with resourceNames cannot grant a plain list. Add list (and watch if anything streams), accepting that list exposes every object of that kind in the namespace.
How do you let a tenant read pod logs but never exec into a pod?
Grant get on pods/log and do not grant create on pods/exec. Subresources are distinct resource strings, which is what makes this expressible at all.
You ship a new CRD. How do existing view users get read access without editing the built-in role?
Create a ClusterRole with read verbs on your kind and label it rbac.authorization.k8s.io/aggregate-to-view: "true". Aggregation folds it into the built-in role automatically, the standard platform-team move when adding an API.
Sealed Secrets versus ESO: which would you pick for a regulated environment with an existing vault, and why?
ESO: the vault stays the single source of truth with its own audit and rotation, and git holds only references; nothing secret is ever committed, encrypted or not. Sealed Secrets suits clusters with no external store, at the cost of managing (and backing up) the sealing key yourself.
Is a Kubernetes Secret encrypted?
Not by itself: base64 is encoding. At rest it is only encrypted if the API server is configured with an EncryptionConfiguration (optionally KMS-backed); in transit it is protected by TLS. Access control (who has get secrets) is doing most of the actual work.
A namespace admin with the built-in admin role cannot create a RoleBinding to a custom ClusterRole that grants nodes read. Why, and what is the least-privilege fix?
Escalation prevention: you can only bind permissions you hold, and admin does not include cluster-scoped nodes. Grant the bind verb on clusterroles with resourceNames naming that one ClusterRole. Granting the node permission itself or cluster-admin would also work and would be the wrong answer.
An OIDC user in group platform-team is bound via a ClusterRoleBinding to that group and still gets Forbidden. First command, likely cause?
kubectl auth whoami shows the groups as the API server sees them, prefixed by --oidc-groups-prefix (or the claimMappings.groups.prefix in the structured config), so the real group is something like oidc:platform-team. Bind to that string. The same check catches a username prefix and a wrong groups claim name.
You rotate the aescbc key in EncryptionConfiguration. Order of operations, and what happens if you simply replace the key?
Add the new key as the first provider while keeping the old key second, restart the API servers, rewrite every Secret so it is re-encrypted (kubectl get secrets -A -o json | kubectl replace -f -), then drop the old key. Replacing the key outright leaves every existing Secret unreadable, which surfaces as API server errors on get secrets, not at startup.
ESO: a database password rotates in Vault every hour, the ExternalSecret has refreshInterval: 0, and pods keep failing to log in. Two fixes and their trade-offs?
refreshInterval: 0 means create once and never update, so the Secret holds the first value forever. Set a refreshInterval shorter than the rotation period (Periodic policy), and make the workload reload (a restart trigger such as Reloader, or read the file mount rather than env vars). Alternatively, if the value must not change under a running pod, keep CreatedOnce plus immutable: true and roll the pods deliberately.
Docs to know your way around
- kubernetes.io: Using RBAC Authorization (the RoleBinding-to-ClusterRole pattern and aggregation are both spelled out there); Managing Service Accounts; Encrypting Secret Data at Rest.
- sealed-secrets and external-secrets docs: one page each on their CRDs; the fake provider is under ESO's provider list.
- Offline:
kubectl auth can-i --list,kubectl api-resources --verbs=list,kubectl create role --help(its examples are a rules cheat sheet). - kubernetes.io/docs/reference/access-authn-authz/rbac, sections "Privilege escalation prevention and bootstrapping" and "Default roles and role bindings": the two parts of the page a Forbidden puzzle sends you to.
- kubernetes.io/docs/tasks/administer-cluster/encrypt-data (providers table, rotation steps) and /docs/reference/access-authn-authz/authentication (OIDC flags,
AuthenticationConfiguration,SelfSubjectReview). - external-secrets.io/latest/api/externalsecret (refresh and creation policies) and /guides/templating; fluxcd.io/flux/guides/mozilla-sops for the Kustomization
decryptionblock.
make down-gitops