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
This commit is contained in:
parent
18dae2a0ac
commit
e6f4e28164
6 changed files with 577 additions and 6 deletions
388
sso-mfa/k8s/lldap/bind-split.py
Normal file
388
sso-mfa/k8s/lldap/bind-split.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue