Implement scoped P06 authentication policy and guarded optional onboarding
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a09cbb-87c6-7900-a145-4ce53ba9f1a6
This commit is contained in:
parent
d1a169dedd
commit
a5496170cf
7 changed files with 258 additions and 0 deletions
83
sso-mfa/k8s/keycape/authentication-policy-rollout.py
Normal file
83
sso-mfa/k8s/keycape/authentication-policy-rollout.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Migrate only the two reviewed baseline registrations; never export Secret values.
|
||||
|
||||
Runtime policy overrides are issuer-owned in its policy PVC. This migration is
|
||||
for initial activation; subsequent changes use the authenticated policy journey.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
spec = importlib.util.spec_from_file_location('config_pin', ROOT/'openbao-client-config.py')
|
||||
pin = importlib.util.module_from_spec(spec); spec.loader.exec_module(pin)
|
||||
CLIENTS = {'vergabe-demo-company', 'user-engine-portal'}
|
||||
CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786'
|
||||
|
||||
class Refused(Exception): pass
|
||||
|
||||
def require(condition, reason):
|
||||
if not condition: raise Refused(reason)
|
||||
|
||||
def replacement(secret):
|
||||
try: raw,config,_ = pin.issuer_document(secret)
|
||||
except Exception: raise Refused("issuer_configuration_unreadable") from None
|
||||
clients = config.get('clients',[])
|
||||
ids = [c.get('clientId') for c in clients]
|
||||
require(len(ids)==len(set(ids)) and CLIENTS.issubset(ids), 'reviewed_clients_required')
|
||||
document = yaml.compose(raw)
|
||||
nodes = next(v for k,v in document.value if k.value=='clients')
|
||||
edits=[]; expected=copy.deepcopy(config)
|
||||
for index,client in enumerate(clients):
|
||||
if client['clientId'] not in CLIENTS: continue
|
||||
require(client.get('clientType')=='public' and client.get('grantTypes')==['authorization_code'], 'client_shape_changed')
|
||||
require('mfaRequired' not in client, 'explicit_policy_requires_review')
|
||||
if client.get('mfaOptional') is True: continue
|
||||
require('mfaOptional' not in client, 'explicit_policy_requires_review')
|
||||
node=nodes.value[index]
|
||||
require(not node.flow_style, 'block_client_required')
|
||||
column=node.value[0][0].start_mark.column
|
||||
position=node.end_mark.index
|
||||
line_start=raw.rfind('\n',0,position)+1
|
||||
if not raw[line_start:position].strip(): position=line_start
|
||||
addition=('' if position==0 or raw[position-1]=='\n' else '\n')+' '*column+'mfaOptional: true\n'
|
||||
edits.append((position,addition));expected['clients'][index]['mfaOptional']=True
|
||||
updated=raw
|
||||
for position,addition in sorted(edits,reverse=True):updated=updated[:position]+addition+updated[position:]
|
||||
require(yaml.load(updated,Loader=pin.UniqueLoader)==expected, 'unrelated_configuration_changed')
|
||||
return base64.b64encode(updated.encode()).decode(),bool(edits)
|
||||
|
||||
def kube(args,payload=None):
|
||||
result=subprocess.run(['kubectl','--request-timeout=20s',*args],input=json.dumps(payload) if payload is not None else None,capture_output=True,text=True,timeout=30)
|
||||
require(result.returncode==0,'kubernetes_operation_failed')
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def run(args):
|
||||
require(kube(['get','namespace','kube-system','-o','json'])['metadata']['uid']==CLUSTER_UID,'cluster_changed')
|
||||
secret=kube(['-n','sso','get','secret','keycape-config','-o','json'])
|
||||
require(not secret['metadata'].get('ownerReferences'),'controller_owned_configuration')
|
||||
value,changed=replacement(secret);metadata=pin.safe_metadata(secret)
|
||||
receipt={'clients':sorted(CLIENTS),'before':metadata,'change_needed':changed,'other_clients_unchanged':True,'secret_values_emitted':False,'mode':args.mode}
|
||||
if args.mode=='inspect':return receipt
|
||||
require(metadata=={'uid':args.expected_uid,'resource_version':args.expected_resource_version},'observed_revision_changed')
|
||||
if changed:
|
||||
patch=[{'op':'test','path':'/metadata/uid','value':metadata['uid']},{'op':'test','path':'/metadata/resourceVersion','value':metadata['resource_version']},{'op':'replace','path':'/data/config.yaml','value':value}]
|
||||
command=['-n','sso','patch','secret','keycape-config','--type=json','--patch-file=/dev/stdin','-o','json']
|
||||
if args.mode=='dry-run':command.append('--dry-run=server')
|
||||
response=kube(command,patch)
|
||||
require(response['data']==dict(secret['data'],**{'config.yaml':value}),'patch_readback_mismatch')
|
||||
if args.mode=='apply':
|
||||
response=kube(['-n','sso','get','secret','keycape-config','-o','json'])
|
||||
require(response['metadata']['uid']==metadata['uid'] and response['data']==dict(secret['data'],**{'config.yaml':value}),'readback_changed')
|
||||
receipt['after']=pin.safe_metadata(response)
|
||||
return receipt
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser();parser.add_argument('mode',choices=['inspect','dry-run','apply']);parser.add_argument('--expected-uid');parser.add_argument('--expected-resource-version')
|
||||
try: print(json.dumps(run(parser.parse_args())))
|
||||
except Refused as error:print(json.dumps({'success':False,'failure':str(error)}));raise SystemExit(1)
|
||||
11
sso-mfa/k8s/keycape/policy-pvc.yaml
Normal file
11
sso-mfa/k8s/keycape/policy-pvc.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: keycape-authentication-policy
|
||||
namespace: sso
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: local-path
|
||||
resources:
|
||||
requests:
|
||||
storage: 64Mi
|
||||
35
sso-mfa/k8s/keycape/test_authentication_policy_rollout.py
Normal file
35
sso-mfa/k8s/keycape/test_authentication_policy_rollout.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import base64,importlib.util,json,unittest
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
spec=importlib.util.spec_from_file_location('rollout',Path(__file__).with_name('authentication-policy-rollout.py'))
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
|
||||
class PolicyMigration(unittest.TestCase):
|
||||
def secret(self):
|
||||
raw='''# exact bytes outside reviewed fields must survive
|
||||
issuer: https://fixture.test
|
||||
authelia:
|
||||
issuer: https://primary.test
|
||||
clients:
|
||||
- clientId: user-engine-portal
|
||||
clientType: public
|
||||
grantTypes: [authorization_code]
|
||||
- clientId: untouched
|
||||
mfaRequired: true
|
||||
clientSecret: fixture-secret
|
||||
- clientId: vergabe-demo-company
|
||||
clientType: public
|
||||
grantTypes: [authorization_code]
|
||||
privacyidea:
|
||||
requireForAll: true
|
||||
'''
|
||||
return {'data':{'config.yaml':base64.b64encode(raw.encode()).decode(),'key.pem':'fixture-key'}}
|
||||
def test_exact_migration_preserves_siblings_and_retries(self):
|
||||
secret=self.secret();value,changed=module.replacement(secret);self.assertTrue(changed)
|
||||
raw=base64.b64decode(value).decode();self.assertIn('# exact bytes',raw);self.assertIn(' clientSecret: fixture-secret\n',raw)
|
||||
config=yaml.safe_load(raw);self.assertTrue(config['privacyidea']['requireForAll'])
|
||||
self.assertTrue(config['clients'][0]['mfaOptional']);self.assertTrue(config['clients'][2]['mfaOptional'])
|
||||
secret['data']['config.yaml']=value;again,changed=module.replacement(secret);self.assertFalse(changed);self.assertEqual(value,again)
|
||||
def test_explicit_or_duplicate_registration_refused(self):
|
||||
for edit in [lambda s:s.replace(' clientType: public',' mfaRequired: false\n clientType: public',1),lambda s:s.replace('clientId: untouched','clientId: user-engine-portal')]:
|
||||
secret=self.secret();secret['data']['config.yaml']=base64.b64encode(edit(base64.b64decode(secret['data']['config.yaml']).decode()).encode()).decode()
|
||||
with self.assertRaises(module.Refused):module.replacement(secret)
|
||||
Loading…
Add table
Add a link
Reference in a new issue