net-kingdom/sso-mfa/k8s/lldap/bind-split.py
tegwick e6f4e28164
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
NK-WP-0054-T05 phase 3a: founder-run bind-split helper with end-to-end test
prepare-custody, plan, accounts and verify for ld-admin and the svc-* accounts;
named rotations for the Authelia bind and LLDAP_LDAP_USER_PASS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 308701@bnt-lap001
Assistant-Session: 5548e447-444c-4dcc-bed1-05c16eebfe56
2026-10-29 22:00:49 +01:00

388 lines
14 KiB
Python

#!/usr/bin/env python3
"""Founder-run directory side of the LLDAP bind split (NK-WP-0054-T05).
Subcommands, in runbook order:
prepare-custody ROOT add LLDAP_SVC_*_PASS and a new ld-admin LLDAP_LDAP_USER_PASS to the
private custody copy; refuses if already prepared. Prints names only.
plan --endpoint URL read-only: which target accounts exist, with which groups.
accounts --endpoint URL ROOT [--apply]
create ld-admin and the svc-* accounts, set their groups and their
passwords from custody. Without --apply it only reports what it would do.
verify --endpoint URL ROOT [--old-admin-retired]
every target account signs in with its custody password and holds exactly
its groups; `admin` keeps lldap_admin; optionally the old shared password
no longer signs in as `admin`.
The human `admin` signs in at a hidden prompt (or `--admin-password-fd` for an attended pipe); the
helper never takes a password on argv and never prints one. Each run writes a value-free receipt.
Passwords are set with the pinned image's own `lldap_set_password` (OPAQUE), run locally, with the
password in its environment only. This tool changes the directory, not any Kubernetes object.
"""
from __future__ import annotations
import argparse
import getpass
import hashlib
import importlib.util
import json
import os
import secrets
import string
import subprocess
import sys
import urllib.request
from datetime import UTC, datetime
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[2]
IMAGE = "docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d"
EMAIL_DOMAIN = "service.netkingdom.invalid"
# account -> (custody key, groups it must hold exactly)
TARGETS = {
"ld-admin": ("LLDAP_LDAP_USER_PASS", ["lldap_admin"]),
"svc-authelia": ("LLDAP_SVC_AUTHELIA_PASS", ["lldap_strict_readonly"]),
"svc-keycape": ("LLDAP_SVC_KEYCAPE_PASS", ["lldap_strict_readonly"]),
"svc-privacyidea": ("LLDAP_SVC_PRIVACYIDEA_PASS", ["lldap_strict_readonly"]),
"svc-provisioner": (
"LLDAP_SVC_PROVISIONER_PASS",
["lldap_admin"],
), # exception SI-X6
}
SERVICE_KEYS = [key for key, _ in TARGETS.values() if key != "LLDAP_LDAP_USER_PASS"]
# apply-service-secrets.literal() accepts only [A-Za-z0-9]{32,128}.
ALPHABET = string.ascii_letters + string.digits
class Refused(Exception):
pass
def _load(name, path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
custody_module = _load(
"bind_split_custody", ROOT / "sso-mfa/bootstrap/apply-service-secrets.py"
)
lldap_admin = _load("bind_split_lldap_admin", HERE / "lldap_admin.py")
def new_value():
return "".join(secrets.choice(ALPHABET) for _ in range(40))
def custody_values(root):
with custody_module.custody(Path(root), ["lldap"]) as directories:
values = custody_module.read_env(directories["lldap"])
missing = [key for key, _ in TARGETS.values() if not values.get(key)]
if missing:
raise Refused(
"custody lacks " + ", ".join(missing) + "; run prepare-custody first"
)
return {account: values[key] for account, (key, _) in TARGETS.items()}
def prepare_custody(root):
with custody_module.custody(Path(root), ["lldap"]) as directories:
parent = directories["lldap"]
values = custody_module.read_env(parent)
if any(key in values for key in SERVICE_KEYS):
raise Refused("custody is already prepared; nothing changed")
if not values.get("LLDAP_LDAP_USER_PASS"):
raise Refused("custody lacks LLDAP_LDAP_USER_PASS")
lines = [
line
for line in custody_module.read_private(parent, "secrets.env")
.decode()
.splitlines()
if not line.startswith("LLDAP_LDAP_USER_PASS=")
]
lines += [
"# NK-WP-0054-T05 bind split: ld-admin (configured super-admin) and per-consumer accounts.",
"# The human admin password is personal and no longer kept here.",
f"LLDAP_LDAP_USER_PASS={new_value()}",
*(f"{key}={new_value()}" for key in SERVICE_KEYS),
]
fd = os.open(
".secrets.env.next",
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o600,
dir_fd=parent,
)
with os.fdopen(fd, "w") as out:
out.write("\n".join(lines) + "\n")
out.flush()
os.fsync(out.fileno())
os.replace(
".secrets.env.next", "secrets.env", src_dir_fd=parent, dst_dir_fd=parent
)
return {"changed_keys": ["LLDAP_LDAP_USER_PASS", *SERVICE_KEYS]}
class Directory:
def __init__(self, endpoint, opener=None):
self.base = lldap_admin.endpoint(endpoint)
self.opener = opener or lldap_admin.Transport(self.base)
def _post(self, path, payload, token=None):
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(
self.base + path,
data=json.dumps(payload).encode(),
headers=headers,
method="POST",
)
return json.load(self.opener(request, timeout=15))
def login(self, user, password):
try:
return self._post(
"/auth/simple/login", {"username": user, "password": password}
)["token"]
except lldap_admin.Refused:
return None
def gql(self, token, query, variables=None):
body = self._post(
"/api/graphql", {"query": query, "variables": variables or {}}, token
)
if body.get("errors"):
raise Refused("directory refused a request")
return body["data"]
def snapshot(self, token):
users = self.gql(token, "{ users { id email groups { displayName } } }")[
"users"
]
groups = self.gql(token, "{ groups { id displayName } }")["groups"]
return (
{
u["id"]: {
"email": u["email"],
"groups": sorted(g["displayName"] for g in u["groups"]),
}
for u in users
},
{g["displayName"]: g["id"] for g in groups},
)
def set_password(endpoint, token, user, password, runner=subprocess.run):
done = runner(
[
"docker",
"run",
"--rm",
"--pull=never",
"--network",
"host",
"--env",
"LLDAP_USER_PASSWORD",
"--entrypoint",
"/app/lldap_set_password",
IMAGE,
"--base-url",
endpoint,
"--token",
token,
"--username",
user,
],
env={
"LLDAP_USER_PASSWORD": password,
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
},
capture_output=True,
check=False,
)
if done.returncode:
raise Refused(f"setting the password of {user} failed")
def admin_token(directory, fd):
if fd is not None:
with os.fdopen(fd, "r", closefd=True) as stream:
password = stream.readline().rstrip("\n")
else:
password = getpass.getpass("Password of the human admin account: ")
token = directory.login("admin", password)
if token is None:
raise Refused("admin sign-in refused")
return token
def assess(users):
report = {}
for account, (_, groups) in TARGETS.items():
found = users.get(account)
if found is None:
report[account] = {"exists": False, "action": "create"}
elif not found["email"].endswith("@" + EMAIL_DOMAIN):
report[account] = {
"exists": True,
"action": "refuse",
"reason": "email outside " + EMAIL_DOMAIN,
}
else:
extra = sorted(set(found["groups"]) - set(groups))
report[account] = {
"exists": True,
"groups": found["groups"],
"action": "refuse"
if extra
else ("ok" if found["groups"] == groups else "add-groups"),
**(
{"reason": "unexpected groups " + ", ".join(extra)} if extra else {}
),
}
report["admin"] = {
"exists": "admin" in users,
"in_lldap_admin": "lldap_admin" in users.get("admin", {}).get("groups", []),
}
return report
def accounts(directory, token, passwords, apply, runner=subprocess.run):
users, group_ids = directory.snapshot(token)
report = assess(users)
if any(entry.get("action") == "refuse" for entry in report.values()):
return {"applied": False, "accounts": report}
if not apply:
return {"applied": False, "accounts": report}
for account, (_, groups) in TARGETS.items():
if not report[account]["exists"]:
directory.gql(
token,
"mutation($u: CreateUserInput!) { createUser(user: $u) { id } }",
{
"u": {
"id": account,
"email": f"{account}@{EMAIL_DOMAIN}",
"displayName": account,
}
},
)
have = set(users.get(account, {}).get("groups", []))
for group in groups:
if group not in have:
directory.gql(
token,
"mutation($u: String!, $g: Int!) { addUserToGroup(userId: $u, groupId: $g) { ok } }",
{"u": account, "g": group_ids[group]},
)
set_password(directory.base, token, account, passwords[account], runner)
return {
"applied": True,
"accounts": assess(directory.snapshot(token)[0]),
"signs_in": {a: directory.login(a, passwords[a]) is not None for a in TARGETS},
}
def verify(directory, token, passwords, old_admin_password=None):
users, _ = directory.snapshot(token)
result = {
"accounts": assess(users),
"signs_in": {a: directory.login(a, passwords[a]) is not None for a in TARGETS},
}
if old_admin_password is not None:
result["old_shared_password_refused"] = (
directory.login("admin", old_admin_password) is None
)
checks = [
result["signs_in"][a] and result["accounts"][a].get("action") == "ok"
for a in TARGETS
]
checks.append(result["accounts"]["admin"]["in_lldap_admin"])
if old_admin_password is not None:
checks.append(result["old_shared_password_refused"])
result["verdict"] = "pass" if all(checks) else "fail"
return result
def write_receipt(path, command, endpoint, result):
record = {
"schema": "netkingdom.lldap-bind-split.v1",
"task": "NK-WP-0054-T05",
"command": command,
"observed_at": datetime.now(UTC).isoformat(),
"endpoint_sha256": hashlib.sha256(endpoint.encode()).hexdigest()
if endpoint
else None,
"values_recorded": False,
"result": result,
}
fd = os.open(
Path(path).expanduser(),
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o600,
)
with os.fdopen(fd, "w") as out:
out.write(json.dumps(record, indent=2, sort_keys=True) + "\n")
return record
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
sub = parser.add_subparsers(dest="command", required=True)
prep = sub.add_parser("prepare-custody")
prep.add_argument("root")
for name in ("plan", "accounts", "verify"):
p = sub.add_parser(name)
p.add_argument("--endpoint", required=True)
p.add_argument("--admin-password-fd", type=int)
if name != "plan":
p.add_argument("root")
if name == "accounts":
p.add_argument("--apply", action="store_true")
if name == "verify":
p.add_argument(
"--old-admin-retired",
action="store_true",
help="also prompt for the old shared password and require that it is refused",
)
for p in sub.choices.values():
p.add_argument("--receipt", required=True)
args = parser.parse_args(argv)
try:
if Path(args.receipt).expanduser().exists():
raise Refused("receipt exists")
endpoint = getattr(args, "endpoint", None)
if args.command == "prepare-custody":
result = prepare_custody(args.root)
else:
directory = Directory(endpoint)
passwords = custody_values(args.root) if args.command != "plan" else None
token = admin_token(directory, args.admin_password_fd)
if args.command == "plan":
result = {"accounts": assess(directory.snapshot(token)[0])}
elif args.command == "accounts":
result = accounts(directory, token, passwords, args.apply)
else:
old = (
getpass.getpass("Old shared admin password (must now be refused): ")
if args.old_admin_retired
else None
)
result = verify(directory, token, passwords, old)
write_receipt(args.receipt, args.command, endpoint, result)
except (Refused, lldap_admin.Refused, custody_module.Refused) as error:
print(f"refused: {error}", file=sys.stderr)
return 1
print(json.dumps(result, indent=2, sort_keys=True))
if result.get("verdict") == "fail" or any(
entry.get("action") == "refuse" for entry in result.get("accounts", {}).values()
):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())