NK-WP-0054-T05 phase 3a: founder-run bind-split helper with end-to-end test
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

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
This commit is contained in:
tegwick 2026-10-29 22:00:49 +01:00
parent 18dae2a0ac
commit e6f4e28164
6 changed files with 577 additions and 6 deletions

View file

@ -30,8 +30,8 @@ TARGETS = {
ROTATIONS = {
'postgresql': {'PI_DB_PASSWORD': 'password'},
'privacyidea': {'PI_SECRET_KEY': 'PI_SECRET_KEY', 'PI_DB_PASSWORD': 'PI_SQLALCHEMY_DATABASE_URI'},
'lldap': {'LLDAP_JWT_SECRET': 'LLDAP_JWT_SECRET'},
'authelia': {'AUTHELIA_SESSION_SECRET': 'session_secret'},
'lldap': {'LLDAP_JWT_SECRET': 'LLDAP_JWT_SECRET', 'LLDAP_LDAP_USER_PASS': 'LLDAP_LDAP_USER_PASS'},
'authelia': {'AUTHELIA_SESSION_SECRET': 'session_secret', 'LLDAP_SVC_AUTHELIA_PASS': 'ldap_password'},
}
ANNOTATION = 'kubectl.kubernetes.io/last-applied-configuration'

View file

@ -0,0 +1,388 @@
#!/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())

View file

@ -0,0 +1,164 @@
"""End-to-end test of bind-split.py against a throwaway LLDAP on the pinned image (NK-WP-0054-T05).
Synthetic only: generated passwords, 127.0.0.1, container and volume removed afterwards. Skipped when
docker or the pinned image is unavailable.
"""
import importlib.util
import json
import os
import re
import secrets
import subprocess
import uuid
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
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
split = load("bind_split", HERE / "bind-split.py")
available = (
subprocess.run(
["docker", "image", "inspect", split.IMAGE], capture_output=True, check=False
).returncode
== 0
)
pytestmark = pytest.mark.skipif(
not available, reason="docker or pinned LLDAP image unavailable"
)
@pytest.fixture
def lab(tmp_path):
pytest.importorskip("ldap3")
rehearsal = load("bind_split_rehearsal", HERE / "bind-split-rehearsal.py")
admin_password = secrets.token_hex(20)
root = tmp_path / "custody"
(root / "lldap").mkdir(parents=True, mode=0o700)
root.chmod(0o700)
env = root / "lldap/secrets.env"
env.write_text(
f"LLDAP_JWT_SECRET={secrets.token_hex(32)}\nLLDAP_LDAP_USER_PASS={admin_password}\n"
)
env.chmod(0o600)
directory = rehearsal.Directory(uuid.uuid4().hex[:10])
directory.start("admin", admin_password)
try:
yield root, directory.http, admin_password, directory
finally:
directory.remove()
def admin_fd(password):
read, write = os.pipe()
os.write(write, (password + "\n").encode())
os.close(write)
return read
def test_full_split_on_a_production_shaped_directory(lab, tmp_path):
root, endpoint, admin_password, _ = lab
assert split.prepare_custody(root)["changed_keys"][0] == "LLDAP_LDAP_USER_PASS"
text = (root / "lldap/secrets.env").read_text()
assert (
admin_password not in text
and (root / "lldap/secrets.env").stat().st_mode & 0o077 == 0
)
for key in ("LLDAP_LDAP_USER_PASS", *split.SERVICE_KEYS):
assert re.search(rf"^{key}=[A-Za-z0-9]{{40}}$", text, re.MULTILINE)
with pytest.raises(split.Refused):
split.prepare_custody(root)
assert (
split.main(
[
"plan",
"--endpoint",
endpoint,
"--admin-password-fd",
str(admin_fd(admin_password)),
"--receipt",
str(tmp_path / "plan.json"),
]
)
== 0
)
plan = json.loads((tmp_path / "plan.json").read_text())["result"]["accounts"]
assert (
all(plan[a]["action"] == "create" for a in split.TARGETS)
and plan["admin"]["in_lldap_admin"]
)
directory = split.Directory(endpoint)
token = directory.login("admin", admin_password)
passwords = split.custody_values(root)
dry = split.accounts(directory, token, passwords, apply=False)
assert not dry["applied"] and "ld-admin" not in directory.snapshot(token)[0]
done = split.accounts(directory, token, passwords, apply=True)
assert done["applied"] and all(done["signs_in"].values())
assert all(done["accounts"][a]["action"] == "ok" for a in split.TARGETS)
again = split.accounts(directory, token, passwords, apply=True)
assert all(again["accounts"][a]["action"] == "ok" for a in split.TARGETS)
result = split.verify(directory, token, passwords)
assert result["verdict"] == "pass"
# The human then changes their own password; the old shared one must stop working.
split.set_password(endpoint, token, "admin", secrets.token_hex(20))
retired = split.verify(
directory,
directory.login("ld-admin", passwords["ld-admin"]),
passwords,
admin_password,
)
assert retired["old_shared_password_refused"] and retired["verdict"] == "pass"
receipt = tmp_path / "plan.json"
assert admin_password not in receipt.read_text()
assert all(value not in receipt.read_text() for value in passwords.values())
def test_existing_account_outside_the_service_domain_is_refused(lab):
root, endpoint, admin_password, _ = lab
split.prepare_custody(root)
directory = split.Directory(endpoint)
token = directory.login("admin", admin_password)
directory.gql(
token,
"mutation($u: CreateUserInput!) { createUser(user: $u) { id } }",
{"u": {"id": "svc-keycape", "email": "someone@example.test"}},
)
result = split.accounts(directory, token, split.custody_values(root), apply=True)
assert (
not result["applied"]
and result["accounts"]["svc-keycape"]["action"] == "refuse"
)
assert "ld-admin" not in directory.snapshot(token)[0]
def test_wrong_admin_password_is_refused_before_any_change(lab, tmp_path):
root, endpoint, _, _ = lab
split.prepare_custody(root)
code = split.main(
[
"accounts",
"--endpoint",
endpoint,
"--admin-password-fd",
str(admin_fd("wrong")),
str(root),
"--apply",
"--receipt",
str(tmp_path / "r.json"),
]
)
assert code == 1 and not (tmp_path / "r.json").exists()