Founder-run under CCR-2026-0055: setup, verify and realm_bootstrap.py --rotate-bind --from-openbao with the admin MFA proof. The first apply's check got a code that was not admin's, so both MFA prompts now name the user. Custody re-sealed after setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 896183@bnt-lap001 Assistant-Session: 3a18c01a-3205-4d02-94db-9efff25649a2
247 lines
13 KiB
Python
247 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Repair missing realm/resolver configuration while preserving established policy."""
|
|
import argparse
|
|
import getpass
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
from mfa_state import QuietParser, endpoint
|
|
from pi_api import request
|
|
|
|
RESOLVER = 'lldap-coulomb'
|
|
REALM = 'coulomb'
|
|
CORE = {'LDAPURI': 'ldap://lldap.sso.svc.cluster.local:3890',
|
|
'LDAPBASE': 'dc=netkingdom,dc=local', 'BINDDN': 'uid=svc-privacyidea,ou=people,dc=netkingdom,dc=local'}
|
|
TUNING = {'TIMEOUT': '5', 'CACHE_TIMEOUT': '120', 'SIZELIMIT': '500'}
|
|
# Before NK-WP-0054-T05 the resolver bound as the human admin; only --switch-bind moves it.
|
|
LEGACY_BINDDN = 'uid=admin,ou=people,dc=netkingdom,dc=local'
|
|
# NK-WP-0056-T04 (CCR-2026-0055): the svc-privacyidea bind is held in platform custody.
|
|
# --rotate-bind rewrites only BINDPW on the service bind; --from-openbao reads the value in
|
|
# memory under the attended platform-admin session instead of a prompt or a custody copy.
|
|
BIND_KV = 'platform/data/workloads/net-kingdom/directory-binds/svc-privacyidea'
|
|
BIND_FIELD = 'LDAP_BIND_PASSWORD'
|
|
|
|
|
|
class Refused(Exception):
|
|
pass
|
|
|
|
|
|
def password(value):
|
|
if not isinstance(value, str) or not value or len(value.encode()) > 4096 or any(ord(c) < 32 for c in value):
|
|
raise Refused()
|
|
return value
|
|
|
|
|
|
def private_inputs(root):
|
|
path = Path(__file__).resolve().parents[2] / 'bootstrap/apply-service-secrets.py'
|
|
spec = importlib.util.spec_from_file_location('realm_custody', path)
|
|
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
|
with module.custody(root, ['privacyidea', 'lldap']) as dirs:
|
|
admin = password(module.read_env(dirs['privacyidea']).get('PI_ADMIN_PASSWORD'))
|
|
bind = password(module.read_env(dirs['lldap']).get('LLDAP_SVC_PRIVACYIDEA_PASS'))
|
|
return admin, bind
|
|
|
|
|
|
def openbao_bind(run=None):
|
|
"""The svc-privacyidea bind from OpenBao, only under an attended netkingdom platform-admin session."""
|
|
import subprocess
|
|
run = run or (lambda argv: subprocess.run(argv, capture_output=True, timeout=60))
|
|
|
|
def bao(*args):
|
|
result = run(['bao', *args])
|
|
if result.returncode != 0: raise Refused()
|
|
return json.loads(result.stdout)
|
|
ident = bao('token', 'lookup', '-format=json')['data']
|
|
if ('platform-admin' not in ident.get('policies', []) or 'root' in ident.get('policies', [])
|
|
or not str(ident.get('path', '')).startswith('auth/netkingdom/')):
|
|
raise Refused()
|
|
data = bao('read', '-format=json', BIND_KV)['data']['data']
|
|
if set(data) != {BIND_FIELD}: raise Refused()
|
|
return password(data[BIND_FIELD])
|
|
|
|
|
|
class Client:
|
|
def __init__(self, url, admin_password):
|
|
self.url = endpoint(url)
|
|
self.token = None
|
|
auth = self.call('/auth', {'username': 'pi-admin', 'password': password(admin_password)})
|
|
if not isinstance(auth, dict) or auth.get('role') != 'admin' or not isinstance(auth.get('token'), str) or not auth['token']:
|
|
raise Refused()
|
|
self.token = auth['token']
|
|
|
|
def call(self, path, payload=None):
|
|
status, body = request(self.url + path, payload, self.token)
|
|
result = body.get('result') if isinstance(body, dict) else None
|
|
if status != 200 or not isinstance(result, dict) or result.get('status') is not True:
|
|
raise Refused()
|
|
return result.get('value')
|
|
|
|
def snapshot(self):
|
|
resolvers = self.call('/resolver/' + RESOLVER)
|
|
realms = self.call('/realm/')
|
|
if not isinstance(resolvers, dict) or set(resolvers) - {RESOLVER} or not isinstance(realms, dict):
|
|
raise Refused()
|
|
if any(not isinstance(v, dict) for v in realms.values()): raise Refused()
|
|
return {'resolver': resolvers.get(RESOLVER), 'realms': realms}
|
|
|
|
|
|
def plan(snapshot, switch_bind=False, rotate_bind=False):
|
|
resolver, realms = snapshot['resolver'], snapshot['realms']
|
|
updates = {}
|
|
if rotate_bind:
|
|
# Only on the established service bind, with nothing else to repair.
|
|
if (switch_bind or not isinstance(resolver, dict) or resolver.get('type') != 'ldapresolver'
|
|
or not isinstance(resolver.get('data'), dict)):
|
|
raise Refused()
|
|
current = plan(snapshot)
|
|
if current['resolver_update'] or current['create_realm'] or current['set_initial_default']: raise Refused()
|
|
return dict(current, rotate_bind=True)
|
|
if resolver is None:
|
|
updates = dict(CORE, **TUNING, type='ldapresolver', LOGINNAMEATTRIBUTE='uid',
|
|
LDAPSEARCHFILTER='(objectClass=inetOrgPerson)', LDAPFILTER='(&(objectClass=inetOrgPerson)(uid=%s))',
|
|
USERINFO=json.dumps({'username':'uid','phone':'telephoneNumber','mobile':'mobile','email':'mail',
|
|
'surname':'sn','givenname':'givenName'}),
|
|
UIDTYPE='uid', NOREFERRALS=True, NOSCHEMAS=True)
|
|
else:
|
|
if not isinstance(resolver, dict) or resolver.get('type') != 'ldapresolver' or not isinstance(resolver.get('data'), dict):
|
|
raise Refused()
|
|
data = resolver['data']
|
|
if any(data.get(key) != value for key, value in CORE.items() if key != 'BINDDN'): raise Refused()
|
|
if data.get('BINDDN') != CORE['BINDDN']:
|
|
if not (switch_bind and data.get('BINDDN') == LEGACY_BINDDN): raise Refused()
|
|
switching = True
|
|
else:
|
|
switching = False
|
|
for key, default in TUNING.items():
|
|
value = data.get(key)
|
|
if value in (None, ''): updates[key] = default
|
|
elif type(value) not in (str, int) or not str(value).isascii() or not str(value).isdigit(): raise Refused()
|
|
if updates and switching: raise Refused() # one change at a time: tune first, then switch
|
|
if updates: updates['type'] = 'ldapresolver'
|
|
realm = realms.get(REALM)
|
|
if realm is not None:
|
|
bindings = realm.get('resolver')
|
|
if (not isinstance(bindings, list) or not any(isinstance(v, dict) and v.get('name') == RESOLVER for v in bindings)):
|
|
raise Refused()
|
|
return {'resolver_update': updates, 'create_resolver': resolver is None,
|
|
'switch_bind': resolver is not None and switching, 'rotate_bind': False,
|
|
'create_realm': realm is None, 'set_initial_default': not realms}
|
|
|
|
|
|
def repair(client, bind_input, *, apply=False, switch_bind=False, rotate_bind=False):
|
|
initial = client.snapshot()
|
|
changes = plan(initial, switch_bind, rotate_bind)
|
|
actions = []
|
|
if changes['switch_bind']: actions.append('switch-bind')
|
|
if changes['rotate_bind']: actions.append('rotate-bind')
|
|
if changes['resolver_update']: actions.append('create-resolver' if changes['create_resolver'] else 'fill-missing-tuning')
|
|
if changes['create_realm']: actions.append('create-realm')
|
|
if changes['set_initial_default']: actions.append('set-first-realm-default')
|
|
if not apply:
|
|
return {'result': 'planned', 'actions': actions, 'configuration_writes': 0, 'policies_changed': False, 'mfa_verified': False}
|
|
bind = password(bind_input()) if changes['create_resolver'] or changes['switch_bind'] or changes['rotate_bind'] else None
|
|
# Recheck the full observed target before each operation. API has no CAS;
|
|
# an exclusive owner window remains required, and ambiguous writes are not retried.
|
|
expected = initial
|
|
for action in actions:
|
|
if client.snapshot() != expected: raise Refused()
|
|
if action in ('switch-bind', 'rotate-bind'):
|
|
value = client.call('/resolver/' + RESOLVER, {'type': 'ldapresolver', 'BINDDN': CORE['BINDDN'], 'BINDPW': bind})
|
|
if type(value) is not int or value < 1: raise Refused()
|
|
after = client.snapshot()
|
|
old, data = expected['resolver']['data'], after['resolver'].get('data', {})
|
|
if data.get('BINDDN') != CORE['BINDDN']: raise Refused()
|
|
if any(data.get(k) != v for k, v in old.items() if k not in ('BINDDN', 'BINDPW')): raise Refused()
|
|
if after['realms'] != expected['realms']: raise Refused()
|
|
elif action in ('create-resolver', 'fill-missing-tuning'):
|
|
payload = dict(changes['resolver_update'])
|
|
if action == 'create-resolver': payload['BINDPW'] = bind
|
|
value = client.call('/resolver/' + RESOLVER, payload)
|
|
if type(value) is not int or value < 1: raise Refused()
|
|
after = client.snapshot()
|
|
current = after['resolver']
|
|
if not isinstance(current, dict) or current.get('type') != 'ldapresolver': raise Refused()
|
|
data = current.get('data', {})
|
|
for key, desired in payload.items():
|
|
if key in ('type', 'BINDPW'): continue
|
|
actual = data.get(key)
|
|
if isinstance(desired, bool):
|
|
if str(actual).lower() != str(desired).lower(): raise Refused()
|
|
elif str(actual) != str(desired): raise Refused()
|
|
if action == 'fill-missing-tuning':
|
|
old = expected['resolver']['data']
|
|
if any(data.get(k) != v for k, v in old.items() if k not in payload): raise Refused()
|
|
if after['realms'] != expected['realms']: raise Refused()
|
|
elif action == 'create-realm':
|
|
value = client.call('/realm/' + REALM, {'resolvers': [RESOLVER], 'priority.'+RESOLVER: 1})
|
|
if not isinstance(value, dict) or value.get('failed') != [] or value.get('added') != [RESOLVER]: raise Refused()
|
|
after = client.snapshot()
|
|
realm = after['realms'].get(REALM)
|
|
if not isinstance(realm, dict) or [r.get('name') for r in realm.get('resolver', [])] != [RESOLVER]: raise Refused()
|
|
if {k:v for k,v in after['realms'].items() if k != REALM} != expected['realms']: raise Refused()
|
|
if after['resolver'] != expected['resolver']: raise Refused()
|
|
else:
|
|
value = client.call('/defaultrealm/' + REALM, {})
|
|
if type(value) is not int or value < 1: raise Refused()
|
|
after = client.snapshot()
|
|
anticipated = json.loads(json.dumps(expected))
|
|
anticipated['realms'][REALM]['default'] = True
|
|
if after != anticipated: raise Refused()
|
|
expected = after
|
|
final = client.snapshot()
|
|
if final != expected or plan(final)['resolver_update'] or plan(final)['create_realm']: raise Refused()
|
|
return {'result': 'configured', 'actions': actions, 'configuration_writes': len(actions),
|
|
'policies_changed': False, 'mfa_verified': False, 'onboarding_policy': 'owner-managed'}
|
|
|
|
|
|
def main():
|
|
phase = 'input'
|
|
try:
|
|
parser = QuietParser(description=__doc__)
|
|
parser.add_argument('root', nargs='?')
|
|
parser.add_argument('url', nargs='?', default=os.environ.get('PI_URL', 'https://pink.coulomb.social'))
|
|
parser.add_argument('--pi-url')
|
|
parser.add_argument('--attended', action='store_true')
|
|
parser.add_argument('--apply', action='store_true')
|
|
parser.add_argument('--verify-user')
|
|
parser.add_argument('--switch-bind', action='store_true')
|
|
parser.add_argument('--rotate-bind', action='store_true')
|
|
parser.add_argument('--from-openbao', action='store_true')
|
|
args = parser.parse_args()
|
|
base = endpoint(args.pi_url or args.url)
|
|
if args.verify_user and not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,127}', args.verify_user): raise Refused()
|
|
# A rotation is proven by an MFA login through the resolver; OpenBao is read only attended.
|
|
if args.rotate_bind and args.apply and not args.verify_user: raise Refused()
|
|
if args.from_openbao and not args.attended: raise Refused()
|
|
if args.attended:
|
|
if args.root is not None or not sys.stdin.isatty(): raise Refused()
|
|
admin = password(getpass.getpass('privacyIDEA pi-admin password: '))
|
|
bind_input = (openbao_bind if args.from_openbao else
|
|
lambda: getpass.getpass('LLDAP bind password for the resolver (svc-privacyidea): '))
|
|
else:
|
|
if args.root is None or args.verify_user: raise Refused()
|
|
admin, bind = private_inputs(args.root)
|
|
bind_input = lambda: bind
|
|
phase = 'configuration'
|
|
client = Client(base, admin)
|
|
report = repair(client, bind_input, apply=args.apply, switch_bind=args.switch_bind, rotate_bind=args.rotate_bind)
|
|
if args.verify_user and args.apply:
|
|
phase = 'functional-verification'
|
|
from verify_mfa import verify
|
|
proof = verify(base, args.verify_user, REALM, RESOLVER, admin,
|
|
lambda:getpass.getpass('Fresh MFA code of %s (realm %s; token PIN first if set; not pi-admin): ' % (args.verify_user, REALM)))
|
|
if proof['result'] != 'PASS': raise Refused()
|
|
report['mfa_verified'] = True
|
|
print(json.dumps(report, sort_keys=True))
|
|
return 0
|
|
except (Exception, KeyboardInterrupt):
|
|
print(json.dumps({'result':'unverified','phase':phase,'partial_changes_possible':phase!='input'}), file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|