Build a verifiable access matrix
In a fictional positions-closing service, support needs to inspect reconciliation status without changing the workload or reading other clients’ data. Translate the request into concrete attributes: identity, namespace, API group, resource, subresource, verb and, where applicable, name. One matrix row might permit get on ConfigMap closing-status in funds; another should reject get on ConfigMap other-status. Also record who approves the grant, when it ends and who collects withdrawal evidence. This matrix makes the discussion between APS, security and platform teams testable before a manifest exists. Follow the path from credential to outcome. A client may fail TLS before presenting an identity; it may present a token rejected by the API; or it may authenticate without authorization for the request. Even an authorized operation can fail at a later stage. Use the HTTP status, sanitized message and actual request attributes to locate the phase. Do not answer every failure with a broader Role. At RUN handover, provide permitted and prohibited examples with expected outcomes for the application identity.
Separate identity, scope and operation
A ServiceAccount belongs to a namespace but can receive access elsewhere. If ops/collector reads objects in funds, the destination RoleBinding exists in funds and its subject retains namespace: ops. The short name collector alone does not distinguish accounts. A ClusterRole can also be reused through several RoleBindings without automatically granting global access. For namespaced resources, the binding determines where that grant applies. Document this difference when a team wants to reuse an approved profile across applications. Do not collapse different operations into a generic read label. Reading a Pod does not necessarily authorize reading pods/log. Listing a collection also differs from fetching an object by name. In the exercise, the operator can retrieve closing-status, but an unfiltered list is denied; a list with the appropriate metadata.name field works. This helps explain why a dashboard that enumerates objects can fail even when manual named diagnosis succeeds. Decide whether the client should request only its required object or whether broader listing has an approved purpose. A label selector does not replace an RBAC name restriction.
Delegate while retaining the approved boundary
Granting create on roles does not permit every possible rule set. The API checks whether the author holds the permissions being included at the same scope, unless explicit escalate authorization applies. Similarly, creating a RoleBinding requires a grant compatible with the author’s rights or bind authorization for the referenced Role. In the lab, the operator creates a read Role whose rights it already holds but cannot create a Role granting Secret reads. It then delegates a Role specifically authorized through bind without receiving permission to bind every other Role. These exceptions require their own governance decision. A pipeline able to bind a powerful Role to any subject can also grant it to an identity it controls. Restricting the Role name is not the same as restricting recipients. If approved recipients are part of the requirement, define and validate the additional controls needed. During review, separate four questions: who creates the object, which Role they may select, whom they may grant it to and how that grant will be withdrawn. Retain an inventory of bindings produced by automation; removing pipeline rights does not delete objects it already created.
Treat credentials as a lifecycle
Select a dedicated ServiceAccount when an application needs its own identity. Creating the account does not change a Deployment template. Confirm serviceAccountName in both the template and the actual Pod, including the new instance after a change. For applications without an API access requirement, reduce exposure to automatic credentials. An explicit automountServiceAccountToken value on the Pod takes precedence over the account setting. This controls automatic mounting; it does not revoke all previously issued tokens or by itself prevent an explicitly configured projected volume. A token has intended recipients and validity conditions. In the lab, a token for a different audience receives 401 even after the operator has read rights. Adding a Role does not fix that problem. For an application using projected tokens, also verify its ability to reload credentials after rotation. A process retaining its initial copy indefinitely can fail while a new instance works. Acceptance testing should reproduce client behavior and retain results, not the token. Do not delete a shared ServiceAccount as the first response to one excessive grant; consider the impact on every workload using it.
Look for indirect access paths
A direct-request matrix is necessary, but it does not describe an identity’s entire power. Workload creation may allow mounting resources or selecting accounts in the same namespace, depending on existing controls. Issuing tokens for other ServiceAccounts may permit acting with those accounts’ rights. Include these paths before concluding that denial of get secrets establishes isolation. Separate what RBAC controls from fields and behavior requiring admission, namespace separation or another mechanism. Confirm the implemented control instead of assuming it from a profile’s name. Also review permissions that sound harmless because of their verb. Do not approve get on nodes/proxy as a simple Node object read: the kubelet path has different capabilities. Apply similar reasoning to impersonation. An --as test can help assess authorization for a represented identity, but it does not demonstrate acceptance of the application’s actual credential. Closing a 401 incident requires evidence about that credential and its recipient without exposing it. These examples show why the technical owner must review operational access effects as well as Role syntax.
Guided exercise: predict, execute and explain
Before running the code below, prepare a table with three columns: request, predicted result and reason. Use a disposable kind cluster named dr-cks-identity- followed by a unique identifier, a private kubeconfig and a loopback server. The script rejects other naming patterns and server versions differing from the observed version. It requires Python and an explicit path to kubectl 1.37.1. The recorded execution used kind 0.33.0, Kubernetes 1.37.0 and client 1.37.1; the inspected exam page states 1.35. The trial demonstrates behavior observed in that local version without certifying complete exam equivalence. Run python3 run.py --kubectl /path/kubectl --kubeconfig /private/path/config --cluster dr-cks-identity-IDENTIFIER --output /new/path/evidence.json. The script creates a random namespace, two accounts, two ConfigMaps and local Roles. Evaluated requests use real tokens over HTTPS with certificate verification and no impersonation. Administrative credentials only prepare and remove objects. First predict which creations are accepted, which reads fail and what happens when a grant is withdrawn. Compare each prediction with the report’s 19 observations. If they differ, explain the responsible request attribute or grant path before changing a rule.
Interpret results and close the exercise
In the recorded sequence, the first request receives 403; the named grant permits 200 for the approved object while retaining 403 for the other object. The filtered list works and the unfiltered list fails. The token with another audience receives 401. Creating a Role with rights already held receives 201; attempting to include Secret reads receives 403. The permitted binding grants access to the second account. When you withdraw rights from the delegator, the second account retains its existing grant; only removal of the corresponding binding withdraws that read. Finally, deleting the represented account makes its token unacceptable in the observed request. The report retains status codes, control names and the script hash. It retains no tokens or credential-bearing responses. The script removes its namespace and private directory; the trial owner must also delete the disposable cluster and administrative kubeconfig. Confirm cleanup before archiving the result. This practice does not execute a workload, admission policy, SSO, NetworkPolicy or an exam simulation. It does not demonstrate projected-token rotation inside a Pod. These boundaries guide the next exercise and prevent an authorization report being used as evidence for unobserved controls.
Prepare handover and change review
Give RUN the approved matrix, manifests, owners and withdrawal procedure. Include one permitted request and at least one prohibited request executed with the correct identity. Retain timestamp, version, namespace, resource name and sanitized outcome. For temporary supplier access, define the evidence required to close the ticket: removing future delegation is insufficient if supplier bindings remain. If the trial cannot be performed, record that absence and leave validation pending instead of declaring proven withdrawal. Include RBAC in upgrade and API-extension review. Wildcards can cover new resources; aggregated Roles can receive rules from objects selected through labels. A manual edit to the aggregate output may be restored by its controller. Reviewing only the old manifest leaves these dependencies unobserved. Apply the same care to replacing roleRef: the field is immutable, so the change must replace the binding and review its subjects. Summarize the decision in operationally useful terms: what task can be performed, by whom, for how long and which result proves access has ended. This lesson’s examples are fictional and do not represent internal BNP Paribas procedures.
"""Original disposable-cluster RBAC delegation lab; only synthetic namespaced data."""
import argparse,base64,datetime,hashlib,json,os,shutil,ssl,subprocess,tempfile,time,uuid,urllib.request,urllib.error
from pathlib import Path
p=argparse.ArgumentParser;p.add_argument('--kubectl',required=True);p.add_argument('--kubeconfig',required=True);p.add_argument('--cluster',required=True);p.add_argument('--output',required=True);a=p.parse_args
assert a.cluster.startswith('dr-cks-identity-');assert Path(a.kubeconfig).is_absolute;out=Path(a.output);assert not out.exists
tmp=Path(tempfile.mkdtemp(prefix='dr-cks-identity-private-'));ns='dr-identity-'+uuid.uuid4.hex[:8];created=[];records=[];tokens={}
base=[a.kubectl,'--kubeconfig',a.kubeconfig,'--context','kind-'+a.cluster,'--cache-dir',str(tmp/'cache'),'--request-timeout=15s']
def k(*args,obj=None):
r=subprocess.run(base+list(args),input=json.dumps(obj)if obj is not None else None,text=True,capture_output=True,timeout=30)
if r.returncode:raise RuntimeError('Admin operation failed: '+str(args[:3])+': '+r.stderr[:300])
return r.stdout
def obj(kind,name,**fields):return dict(apiVersion='rbac.authorization.k8s.io/v1'if kind in ['Role','RoleBinding']else'v1',kind=kind,metadata=dict(name=name),**fields)
def role(name,rules):return obj('Role',name,rules=rules)
def rule(resources,verbs,names=None,group=''):
d=dict(apiGroups=[group],resources=resources,verbs=verbs)
if names is not None:d['resourceNames']=names
return d
def binding(name,ref,subject='operator'):return obj('RoleBinding',name,roleRef=dict(apiGroup='rbac.authorization.k8s.io',kind='Role',name=ref),subjects=[dict(kind='ServiceAccount',name=subject,namespace=ns)])
def create(x):return k('-n',ns,'create','-f','-',obj=x)
def request(who,method,path,body=None):
req=urllib.request.Request(server+path,data=json.dumps(body).encodeif body is not None else None,headers={'Authorization':'Bearer '+tokens[who],'Content-Type':'application/json'},method=method)
try:
with urllib.request.urlopen(req,context=ctx,timeout=15)as res:return res.status,json.load(res)
except urllib.error.HTTPError as err:return err.code,json.load(err)
def check(label,who,method,path,expected,body=None):
status,result=request(who,method,path,body);assert status==expected,(label,status,result.get('reason'));records.append(dict(name=label,status=status,expected=expected,passed=True));print(label,status,flush=True);return result
def settle(who,path,expected):
end=time.monotonic+15
while time.monotonic<end:
if request(who,'GET',path)[0]==expected:return
time.sleep(.2)
raise AssertionError('Authorization propagation deadline exceeded')
try:
cfg=json.loads(k('config','view','--minify','--raw','-o','json'));cluster=cfg['clusters'][0]['cluster'];server=cluster['server'];assert server.startswith('https://127.0.0.1:');assert not cluster.get('insecure-skip-tls-verify')
ctx=ssl.create_default_context(cadata=base64.b64decode(cluster['certificate-authority-data']).decode);cfg=None
version=json.loads(k('version','-o','json'));assert version['serverVersion']['gitVersion']=='v1.37.0'assert version['clientVersion']['gitVersion']=='v1.37.1'
k('create','namespace',ns);created.append(ns)
for name in ['operator','recipient']:create(obj('ServiceAccount',name,automountServiceAccountToken=False));tokens[name]=k('-n',ns,'create','token',name,'--duration=10m').strip
tokens['wrong-audience']=k('-n',ns,'create','token','operator','--audience=dr-internal-evidence','--duration=10m').strip
for name in ['closing-status','other-status']:create(obj('ConfigMap',name,data=dict(state='synthetic')))
core='/api/v1/namespaces/'+ns;rbac='/apis/rbac.authorization.k8s.io/v1/namespaces/'+ns;target=core+'/configmaps/closing-status'
check('before-grant','operator','GET',target,403)
create(role('status-reader',[rule(['configmaps'],['get','list'],['closing-status'])]));create(binding('read-one','status-reader'));settle('operator',target,200)
check('named-get','operator','GET',target,200);check('other-name-denied','operator','GET',core+'/configmaps/other-status',403)
check('unfiltered-list-denied','operator','GET',core+'/configmaps',403)
listed=check('name-filtered-list','operator','GET',core+'/configmaps?fieldSelector=metadata.name%3Dclosing-status',200);assert [x['metadata']['name']for x in listed['items']]==['closing-status']
check('wrong-audience-rejected','wrong-audience','GET',target,401)
check('secret-list-denied','operator','GET',core+'/secrets',403)
create(role('author',[rule(['roles','rolebindings'],['create'],group='rbac.authorization.k8s.io')]));create(binding('author','author'))
check('role-create-with-held-rights','operator','POST',rbac+'/roles',201,role('derived-reader',[rule(['configmaps'],['get'],['closing-status'])]))
check('role-create-escalation-denied','operator','POST',rbac+'/roles',403,role('unheld-secrets',[rule(['secrets'],['get'])]))
create(role('secret-reader',[rule(['secrets'],['get'])]))
check('binding-unheld-role-denied','operator','POST',rbac+'/rolebindings',403,binding('secret-delegation','secret-reader','recipient'))
check('binding-held-role-allowed','operator','POST',rbac+'/rolebindings',201,binding('delegated','derived-reader','recipient'));settle('recipient',target,200)
check('recipient-get','recipient','GET',target,200)
create(role('status-updater',[rule(['configmaps'],['patch'],['closing-status'])]))
create(role('specific-delegator',[rule(['roles'],['bind'],['status-updater'],group='rbac.authorization.k8s.io')]));create(binding('specific-delegator','specific-delegator'))
check('specific-bind-allowed','operator','POST',rbac+'/rolebindings',201,binding('patch-delegation','status-updater','recipient'))
check('unlisted-bind-still-denied','operator','POST',rbac+'/rolebindings',403,binding('second-secret-delegation','secret-reader','recipient'))
create(binding('read-two','status-reader'));k('-n',ns,'delete','rolebinding','read-one');check('second-binding-retains-access','operator','GET',target,200)
k('-n',ns,'delete','rolebinding','read-two');settle('operator',target,403);check('last-read-binding-removed','operator','GET',target,403)
check('recipient-grant-survives-delegator-revocation','recipient','GET',target,200)
k('-n',ns,'delete','rolebinding','delegated');settle('recipient',target,403);check('recipient-read-revoked','recipient','GET',target,403)
k('-n',ns,'delete','serviceaccount','operator');settle('operator',target,401);check('deleted-serviceaccount-token-rejected','operator','GET',target,401)
assert len(records)==19,len(records)
finally:
tokens.clear
for space in created:k('delete','namespace',space,'--wait=true','--timeout=60s')
shutil.rmtree(tmp)
report=dict(executedAt=datetime.datetime.now(datetime.timezone.utc).isoformat,scriptSha256=hashlib.sha256(Path(__file__).read_bytes).hexdigest,version=version,examVersion='1.35',versionDifferenceExplicit=True,observations=records,cleanup=dict(namespacesRemoved=len(created),temporaryMaterialRemoved=not tmp.exists,tokensRecorded=False),scope='Actual HTTPS API requests with ServiceAccount tokens on one disposable Kubernetes1.37.0 kind node; no impersonation, production credentials, workload execution, SSO, admission-policy validation, NetworkPolicy or certification simulation.')
out.write_text(json.dumps(report,indent=2)+'\n');print('PASS',len(records))
The supplier retains reads after the pipeline loses create rolebindings: the existing grant needs its own withdrawal.
Common pitfalls
Treating an empty Role as denial; confusing account and binding namespaces; using --as to validate tokens; overlooking already delegated grants.
Related topics: Access auditing · Workload admission · Credential rotation
Correct authorization binds identity, operation and scope; withdrawal is proven only after grant paths and actual requests have been reviewed.
Reference: CKS certification and domains · Kubernetes v1.35; current six-domain CKS outline