ops: record native tenant success and prepare attended identity repair

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-11 21:41:26 +02:00
parent 6f9cfe3dfd
commit 6a0773bcfa
6 changed files with 369 additions and 3 deletions

View file

@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Attended repair of one consumer field using an operator-held current password.
Never reads a Secret payload, changes a provider password, or copies a bundle.
"""
from __future__ import annotations
import argparse
import base64
import getpass
import json
import re
import subprocess
import sys
CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786'
KUBE = ['kubectl', '--kubeconfig', '/home/worsch/.kube/config-hosteurope',
'--server', 'https://127.0.0.1:16444', '--request-timeout=20s']
NAMESPACE = 'sso'
SECRET = 'lldap-secrets'
KEY = 'LLDAP_LDAP_USER_PASS'
# The password and returned directory token remain in this child process only.
# Child stdin carries the operator input; no credential is a process argument.
VERIFY = '''import json,os,sys
from urllib.error import HTTPError
from provisioner import LLDAPProvisioner
try:
assert os.environ['LLDAP_URL']=='http://lldap.sso.svc.cluster.local:17170'
p=LLDAPProvisioner(base_url=os.environ['LLDAP_URL'],admin_password=json.load(sys.stdin)['password'])
token=p._login()
p._gql(token,'query { groups { id } }',{})
except Exception:
print('directory-check-failed');sys.exit(1)
print('directory-check-passed')
'''
VERIFY_RELOADED = '''import os,sys
from provisioner import LLDAPProvisioner
try:
p=LLDAPProvisioner(base_url=os.environ['LLDAP_URL'],admin_password=os.environ['LLDAP_ADMIN_PASSWORD'])
token=p._login()
p._gql(token,'query { groups { id } }',{})
except Exception:
print('reloaded-check-failed');sys.exit(1)
print('reloaded-check-passed')
'''
class Refused(Exception):
"""Fixed safe code only; never carry child output or an exception repr."""
def run(arguments, *, data=None, timeout=30):
try:
result = subprocess.run(KUBE + arguments, input=data, text=True,
capture_output=True, timeout=timeout)
except (OSError, subprocess.TimeoutExpired):
raise Refused('command_unavailable') from None
if result.returncode:
raise Refused('command_refused') from None
return result.stdout.strip()
def inspect():
uid = run(['get', 'namespace', 'kube-system', '-o', 'jsonpath={.metadata.uid}'])
if uid != CLUSTER_UID:
raise Refused('wrong_cluster')
# Do not include annotations: last-applied configuration can contain data.
fields = run(['-n', NAMESPACE, 'get', 'secret', SECRET, '-o',
'go-template={{.metadata.uid}} {{.metadata.resourceVersion}} {{if .metadata.ownerReferences}}{{len .metadata.ownerReferences}}{{else}}0{{end}}']).split()
if len(fields) != 3 or not re.fullmatch(r'[a-f0-9-]{36}', fields[0]) or not fields[1].isdigit():
raise Refused('invalid_metadata')
if fields[2] != '0':
raise Refused('controller_owned_secret')
return {'uid': fields[0], 'resource_version': fields[1]}
def patch(password, metadata):
return [
{'op': 'test', 'path': '/metadata/uid', 'value': metadata['uid']},
{'op': 'test', 'path': '/metadata/resourceVersion', 'value': metadata['resource_version']},
{'op': 'replace', 'path': '/data/' + KEY,
'value': base64.b64encode(password.encode()).decode()},
]
def reconcile(password, *, mode, expected_uid=None, expected_resource_version=None):
if not password or len(password) > 4096 or '\x00' in password:
raise Refused('invalid_protected_input')
metadata = inspect()
if mode == 'apply' and metadata != {'uid': expected_uid, 'resource_version': expected_resource_version}:
raise Refused('stale_metadata')
result = run(['-n', NAMESPACE, 'exec', '-i', 'deployment/identity-provisioner',
'--', 'python', '-c', VERIFY], data=json.dumps({'password': password}))
if result != 'directory-check-passed':
raise Refused('current_provider_credential_unverified')
if mode == 'check':
return {'result': 'checked', 'provider_login': True, 'secret_written': False, **metadata}
payload = json.dumps(patch(password, metadata))
command = ['-n', NAMESPACE, 'patch', 'secret', SECRET, '--type=json',
'--patch-file=/dev/stdin', '-o', 'name']
run(command + ['--dry-run=server'], data=payload)
run(command, data=payload)
# Do not restore the known nonworking field on failure. Report partial repair.
try:
run(['-n', NAMESPACE, 'rollout', 'restart', 'deployment/identity-provisioner'])
run(['-n', NAMESPACE, 'rollout', 'status', 'deployment/identity-provisioner',
'--timeout=120s'], timeout=150)
result = run(['-n', NAMESPACE, 'exec', 'deployment/identity-provisioner',
'--', 'python', '-c', VERIFY_RELOADED])
if result != 'reloaded-check-passed':
raise Refused('consumer_verification_failed')
except Refused:
raise Refused('field_updated_consumer_verification_incomplete') from None
return {'result': 'reconciled', 'provider_login': True, 'consumer_login': True,
'provider_password_changed': False, 'secret_field': KEY, **inspect()}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('mode', choices=['inspect', 'check', 'apply'])
parser.add_argument('--expected-uid')
parser.add_argument('--expected-resource-version')
args = parser.parse_args()
try:
if args.mode == 'inspect':
result = {'result': 'inspected', **inspect()}
else:
if args.mode == 'apply' and not (args.expected_uid and args.expected_resource_version):
raise Refused('exact_metadata_required')
if not sys.stdin.isatty():
raise Refused('attended_terminal_required')
password = getpass.getpass('Current working LLDAP admin password (hidden): ')
result = reconcile(password, mode=args.mode, expected_uid=args.expected_uid,
expected_resource_version=args.expected_resource_version)
del password
print(json.dumps(result, sort_keys=True))
return 0
except Refused as error:
print(json.dumps({'result': 'refused', 'code': str(error)}))
return 1
if __name__ == '__main__':
raise SystemExit(main())

View file

@ -0,0 +1,74 @@
import base64
import importlib.util
import io
import json
from pathlib import Path
import subprocess
import unittest
from unittest.mock import patch
spec=importlib.util.spec_from_file_location('repair',Path(__file__).with_name('identity-provisioner-reconcile.py'))
repair=importlib.util.module_from_spec(spec)
spec.loader.exec_module(repair)
META={'uid':'c6a9e6be-5bb5-47e6-9faa-06b8d72afec3','resource_version':'1234'}
SYNTHETIC='synthetic-only-current-password'
class ReconcileTests(unittest.TestCase):
def test_patch_has_exact_guards_and_changes_only_the_named_field(self):
changes=repair.patch(SYNTHETIC,META)
self.assertEqual(['test','test','replace'],[x['op'] for x in changes])
self.assertEqual('/metadata/uid',changes[0]['path'])
self.assertEqual('/metadata/resourceVersion',changes[1]['path'])
self.assertEqual('/data/LLDAP_LDAP_USER_PASS',changes[2]['path'])
self.assertEqual(SYNTHETIC,base64.b64decode(changes[2]['value']).decode())
def test_stale_metadata_stops_before_credential_use(self):
with patch.object(repair,'inspect',return_value=META), patch.object(repair,'run') as run:
with self.assertRaisesRegex(repair.Refused,'stale_metadata'):
repair.reconcile(SYNTHETIC,mode='apply',expected_uid=META['uid'],expected_resource_version='old')
run.assert_not_called()
def test_provider_refusal_stops_before_any_write(self):
with patch.object(repair,'inspect',return_value=META), patch.object(repair,'run',side_effect=repair.Refused('command_refused')) as run:
with self.assertRaises(repair.Refused):
repair.reconcile(SYNTHETIC,mode='apply',expected_uid=META['uid'],expected_resource_version=META['resource_version'])
self.assertEqual(1,run.call_count)
self.assertNotIn('patch',run.call_args.args[0])
def test_check_does_not_write_and_keeps_password_out_of_arguments(self):
with patch.object(repair,'inspect',return_value=META), patch.object(repair,'run',return_value='directory-check-passed') as run:
result=repair.reconcile(SYNTHETIC,mode='check')
self.assertFalse(result['secret_written'])
self.assertNotIn(SYNTHETIC,str(run.call_args.args))
self.assertNotIn(SYNTHETIC,json.dumps(result))
self.assertEqual(SYNTHETIC,json.loads(run.call_args.kwargs['data'])['password'])
def test_apply_dry_runs_then_writes_reloads_and_verifies(self):
results=['directory-check-passed','secret/lldap-secrets','secret/lldap-secrets','restarted','ready','reloaded-check-passed']
with patch.object(repair,'inspect',return_value=META), patch.object(repair,'run',side_effect=results) as run:
result=repair.reconcile(SYNTHETIC,mode='apply',expected_uid=META['uid'],expected_resource_version=META['resource_version'])
self.assertEqual('reconciled',result['result'])
self.assertFalse(result['provider_password_changed'])
calls=run.call_args_list
self.assertIn('--dry-run=server',calls[1].args[0])
self.assertNotIn('--dry-run=server',calls[2].args[0])
for call in calls:
self.assertNotIn(SYNTHETIC,str(call.args))
self.assertNotIn(base64.b64encode(SYNTHETIC.encode()).decode(),str(call.args))
def test_child_errors_never_reveal_output(self):
failed=subprocess.CompletedProcess([],1,stdout=SYNTHETIC,stderr=SYNTHETIC)
with patch.object(subprocess,'run',return_value=failed):
with self.assertRaises(repair.Refused) as result:
repair.run(['anything'])
self.assertEqual('command_refused',str(result.exception))
def test_wrong_cluster_and_controller_owner_refused(self):
with patch.object(repair,'run',return_value='foreign-cluster'):
with self.assertRaisesRegex(repair.Refused,'wrong_cluster'): repair.inspect()
with patch.object(repair,'run',side_effect=[repair.CLUSTER_UID,META['uid']+' 1234 1']):
with self.assertRaisesRegex(repair.Refused,'controller_owned_secret'): repair.inspect()
if __name__=='__main__': unittest.main()