NK-WP-0054-T05: synthetic bind-split rehearsal on pinned LLDAP (phase 1 of 4)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

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-28 18:59:49 +01:00
parent dcb5bc2540
commit 84cc7bf13c
2 changed files with 304 additions and 0 deletions

View file

@ -0,0 +1,62 @@
{
"data": "synthetic",
"findings": {
"admin_after_switch": {
"can_create_user": true,
"groups": [
"lldap_admin"
],
"login_with_unchanged_password": true
},
"cleanup": true,
"ld_admin_introduced": {
"can_create_group": true,
"groups": [
"lldap_admin"
],
"login": true
},
"provisioner_password_manager": {
"can_add_group_member": false,
"can_create_user": false,
"can_set_user_password": true,
"login": true,
"refused_admin_password": true,
"users_group_id_known": true
},
"read_only_binds": {
"svc-authelia": {
"bind": true,
"groups_visible": 6,
"people_visible": 8,
"sees_alice": true,
"write_refused": true
},
"svc-keycape": {
"bind": true,
"groups_visible": 6,
"people_visible": 8,
"sees_alice": true,
"write_refused": true
},
"svc-privacyidea": {
"bind": true,
"groups_visible": 6,
"people_visible": 8,
"sees_alice": true,
"write_refused": true
}
},
"si3_interactive_login": {
"svc-authelia": true,
"svc-keycape": true,
"svc-privacyidea": true
}
},
"image": "docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d",
"observed_at": "2026-10-28T17:59:22.643767+00:00",
"production_contacted": false,
"schema": "netkingdom.lldap-bind-split-rehearsal.v1",
"task": "NK-WP-0054-T05",
"values_recorded": false
}

View file

@ -0,0 +1,242 @@
#!/usr/bin/env python3
# /// script
# dependencies = ["ldap3>=2.9,<3"]
# ///
"""Synthetic rehearsal of the directory bind split on the pinned LLDAP image (NK-WP-0054-T05).
Agent-runnable: a throwaway LLDAP with generated passwords and a production-shaped directory,
published on 127.0.0.1 only and removed afterwards. No backup, no production data, no Secret.
It answers the questions the live migration depends on and writes a value-free JSON receipt:
- can `ld-admin` be introduced into an existing directory whose super-admin is `admin`, and does
LLDAP start with `LLDAP_LDAP_USER_DN=ld-admin` without touching `admin`;
- does `admin` keep its rights through `lldap_admin` group membership after that switch;
- does a `lldap_strict_readonly` bind read users and groups but fail to write;
- what is the narrowest group with which the provisioner can create users and set membership;
- can a `svc-*` account sign in interactively to LLDAP (SI-3).
"""
from __future__ import annotations
import argparse
import json
import secrets
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
from datetime import datetime, timezone
from pathlib import Path
import ldap3
IMAGE = "docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d"
BASE_DN = "dc=netkingdom,dc=local"
READ_ONLY = ("svc-authelia", "svc-keycape", "svc-privacyidea")
PRODUCTION_GROUPS = ("net-kingdom-users", "net-kingdom-admins")
def docker(*args, env=None, check=True):
done = subprocess.run(["docker", *args], capture_output=True, text=True, env=env)
if check and done.returncode:
raise RuntimeError(f"docker {args[0]} failed")
return done
class Directory:
def __init__(self, tag):
self.name = f"nk54-bind-split-{tag}"
self.volume = self.name
self.jwt = secrets.token_hex(32)
self.http = self.ldap = None
def start(self, user_dn, password):
docker("rm", "--force", self.name, check=False)
docker("run", "--detach", "--pull=never", "--name", self.name,
"--volume", f"{self.volume}:/data",
"--publish", "127.0.0.1::17170", "--publish", "127.0.0.1::3890",
"--env", f"LLDAP_JWT_SECRET={self.jwt}", "--env", f"LLDAP_LDAP_BASE_DN={BASE_DN}",
"--env", f"LLDAP_LDAP_USER_DN={user_dn}", "--env", "LLDAP_LDAP_USER_PASS",
IMAGE, env={"LLDAP_LDAP_USER_PASS": password, "PATH": "/usr/bin:/bin"})
ports = json.loads(docker("inspect", "--format", "{{json .NetworkSettings.Ports}}", self.name).stdout)
self.http = f"http://127.0.0.1:{ports['17170/tcp'][0]['HostPort']}"
self.ldap = int(ports["3890/tcp"][0]["HostPort"])
for _ in range(60):
try:
with urllib.request.urlopen(self.http + "/health", timeout=2):
return
except (urllib.error.URLError, ConnectionError):
time.sleep(0.5)
raise RuntimeError("lldap did not become healthy")
def stop(self):
docker("rm", "--force", self.name, check=False)
def remove(self):
self.stop()
docker("volume", "rm", "--force", self.volume, check=False)
def login(self, user, password):
request = urllib.request.Request(self.http + "/auth/simple/login", method="POST",
data=json.dumps({"username": user, "password": password}).encode(),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(request, timeout=10) as response:
return json.load(response)["token"]
except urllib.error.HTTPError:
return None
def graphql(self, token, query, variables=None):
request = urllib.request.Request(self.http + "/api/graphql", method="POST",
data=json.dumps({"query": query, "variables": variables or {}}).encode(),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"})
try:
with urllib.request.urlopen(request, timeout=10) as response:
body = json.load(response)
except urllib.error.HTTPError as error:
return None, f"http {error.code}"
return body.get("data"), (body.get("errors") or [{}])[0].get("message")
def ok(self, token, query, variables=None):
data, error = self.graphql(token, query, variables)
if error:
raise RuntimeError(f"graphql refused: {error}")
return data
def group_id(self, token, name):
groups = self.ok(token, "{ groups { id displayName } }")["groups"]
return next(g["id"] for g in groups if g["displayName"] == name)
def create_user(self, token, user, groups=()):
self.ok(token, "mutation($u: CreateUserInput!) { createUser(user: $u) { id } }",
{"u": {"id": user, "email": f"{user}@rehearsal.invalid", "displayName": user}})
for group in groups:
self.ok(token, "mutation($u: String!, $g: Int!) { addUserToGroup(userId: $u, groupId: $g) { ok } }",
{"u": user, "g": self.group_id(token, group)})
def set_password(self, token, user, password):
docker("exec", "--env", "LLDAP_USER_PASSWORD", self.name, "/app/lldap_set_password",
"--base-url", "http://127.0.0.1:17170", "--token", token, "--username", user,
env={"LLDAP_USER_PASSWORD": password, "PATH": "/usr/bin:/bin"})
def groups_of(self, token, user):
data = self.ok(token, "query($u: String!) { user(userId: $u) { groups { displayName } } }", {"u": user})
return sorted(g["displayName"] for g in data["user"]["groups"])
def bind(self, user, password):
server = ldap3.Server("127.0.0.1", port=self.ldap, get_info=ldap3.NONE)
connection = ldap3.Connection(server, f"uid={user},ou=people,{BASE_DN}", password)
return connection if connection.bind() else None
def rehearse(tag):
passwords = {name: secrets.token_urlsafe(24) for name in
("admin", "ld-admin", "alice", *READ_ONLY, "svc-provisioner")}
directory = Directory(tag)
result = {}
try:
# Production shape: `admin` is the configured super-admin, one human user, the baseline groups.
directory.start("admin", passwords["admin"])
admin = directory.login("admin", passwords["admin"])
for group in PRODUCTION_GROUPS:
directory.ok(admin, "mutation($n: String!) { createGroup(name: $n) { id } }", {"n": group})
directory.create_user(admin, "alice", ["net-kingdom-users"])
directory.set_password(admin, "alice", passwords["alice"])
# Migration step 1: introduce ld-admin and the service accounts while admin is still configured.
directory.create_user(admin, "ld-admin", ["lldap_admin"])
directory.set_password(admin, "ld-admin", passwords["ld-admin"])
for user in READ_ONLY:
directory.create_user(admin, user, ["lldap_strict_readonly"])
directory.set_password(admin, user, passwords[user])
directory.create_user(admin, "svc-provisioner", ["lldap_password_manager"])
directory.set_password(admin, "svc-provisioner", passwords["svc-provisioner"])
# Migration step 2: switch the configured super-admin to ld-admin and restart on the same data.
directory.stop()
directory.start("ld-admin", passwords["ld-admin"])
ld_admin = directory.login("ld-admin", passwords["ld-admin"])
admin = directory.login("admin", passwords["admin"])
result["ld_admin_introduced"] = {
"login": ld_admin is not None,
"groups": directory.groups_of(ld_admin, "ld-admin") if ld_admin else None,
"can_create_group": ld_admin is not None and directory.graphql(
ld_admin, 'mutation { createGroup(name: "rehearsal-probe") { id } }')[1] is None,
}
result["admin_after_switch"] = {
"login_with_unchanged_password": admin is not None,
"groups": directory.groups_of(ld_admin, "admin") if ld_admin else None,
"can_create_user": admin is not None and directory.graphql(
admin, "mutation($u: CreateUserInput!) { createUser(user: $u) { id } }",
{"u": {"id": "probe-by-admin", "email": "probe-by-admin@rehearsal.invalid"}})[1] is None,
}
# Read-only binds: read everything needed by Authelia, KeyCape and the privacyIDEA resolver; write nothing.
binds = {}
for user in READ_ONLY:
connection = directory.bind(user, passwords[user])
if connection is None:
binds[user] = {"bind": False}
continue
connection.search(f"ou=people,{BASE_DN}", "(objectClass=person)", attributes=["uid", "mail", "memberOf"])
people = sorted(str(e.uid) for e in connection.entries)
connection.search(f"ou=groups,{BASE_DN}", "(objectClass=groupOfUniqueNames)", attributes=["cn"])
groups = len(connection.entries)
write = connection.modify(f"uid=alice,ou=people,{BASE_DN}", {"mail": [(ldap3.MODIFY_REPLACE, ["x@rehearsal.invalid"])]})
connection.unbind()
binds[user] = {"bind": True, "people_visible": len(people), "sees_alice": "alice" in people,
"groups_visible": groups, "write_refused": not write}
result["read_only_binds"] = binds
# Provisioner: what can lldap_password_manager do, and what does lldap_admin add?
provisioner = directory.login("svc-provisioner", passwords["svc-provisioner"])
probe = f"probe-{uuid.uuid4().hex[:8]}"
create_error = directory.graphql(provisioner, "mutation($u: CreateUserInput!) { createUser(user: $u) { id } }",
{"u": {"id": probe, "email": f"{probe}@rehearsal.invalid"}})[1]
users_group = directory.group_id(ld_admin, "net-kingdom-users")
member_error = directory.graphql(provisioner, "mutation($u: String!, $g: Int!) { addUserToGroup(userId: $u, groupId: $g) { ok } }",
{"u": "alice", "g": directory.group_id(ld_admin, "net-kingdom-admins")})[1]
password_ok = subprocess.run(
["docker", "exec", "--env", "LLDAP_USER_PASSWORD", directory.name, "/app/lldap_set_password",
"--base-url", "http://127.0.0.1:17170", "--token", provisioner, "--username", "alice"],
capture_output=True, env={"LLDAP_USER_PASSWORD": secrets.token_urlsafe(24), "PATH": "/usr/bin:/bin"}).returncode == 0
admin_password_error = subprocess.run(
["docker", "exec", "--env", "LLDAP_USER_PASSWORD", directory.name, "/app/lldap_set_password",
"--base-url", "http://127.0.0.1:17170", "--token", provisioner, "--username", "ld-admin"],
capture_output=True, env={"LLDAP_USER_PASSWORD": secrets.token_urlsafe(24), "PATH": "/usr/bin:/bin"}).returncode != 0
result["provisioner_password_manager"] = {
"login": provisioner is not None, "can_create_user": create_error is None,
"can_add_group_member": member_error is None, "can_set_user_password": password_ok,
"refused_admin_password": admin_password_error, "users_group_id_known": users_group > 0,
}
# SI-3: interactive sign-in of a service account to the LLDAP web login.
result["si3_interactive_login"] = {user: directory.login(user, passwords[user]) is not None for user in READ_ONLY}
finally:
directory.remove()
result["cleanup"] = docker("volume", "inspect", directory.volume, check=False).returncode != 0
return result
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--receipt", type=Path, required=True)
args = parser.parse_args(argv)
if args.receipt.exists():
print("refused: receipt exists", file=sys.stderr)
return 2
tag = uuid.uuid4().hex[:10]
findings = rehearse(tag)
receipt = {"schema": "netkingdom.lldap-bind-split-rehearsal.v1", "task": "NK-WP-0054-T05",
"observed_at": datetime.now(timezone.utc).isoformat(), "image": IMAGE,
"data": "synthetic", "production_contacted": False, "values_recorded": False,
"findings": findings}
args.receipt.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n")
print(json.dumps(findings, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main())