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:
parent
6f9cfe3dfd
commit
6a0773bcfa
6 changed files with 369 additions and 3 deletions
144
sso-mfa/k8s/lldap/identity-provisioner-reconcile.py
Normal file
144
sso-mfa/k8s/lldap/identity-provisioner-reconcile.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue