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
5
Makefile
5
Makefile
|
|
@ -203,6 +203,9 @@ service-identity-test: ## Run service-identity schema/validator tests (NK-WP-005
|
||||||
service-identity-check: ## Evaluate the checked-in service identity declarations (read-only, offline)
|
service-identity-check: ## Evaluate the checked-in service identity declarations (read-only, offline)
|
||||||
python3 tools/service-identity/validate.py tools/service-identity/declarations/*.yaml
|
python3 tools/service-identity/validate.py tools/service-identity/declarations/*.yaml
|
||||||
|
|
||||||
|
bind-split-test: ## LLDAP bind-split helper tests on a throwaway pinned LLDAP (NK-WP-0054-T05; needs docker)
|
||||||
|
uv run --with pytest --with ldap3 --with pyyaml python -m pytest sso-mfa/k8s/lldap/test_bind_split.py sso-mfa/k8s/lldap/test_identity_provisioner_reconcile.py
|
||||||
|
|
||||||
rotation-lanes-test: ## Run rotation-lane register tests (NK-WP-0055-T15)
|
rotation-lanes-test: ## Run rotation-lane register tests (NK-WP-0055-T15)
|
||||||
python3 -m pytest tools/rotation-lanes/tests
|
python3 -m pytest tools/rotation-lanes/tests
|
||||||
|
|
||||||
|
|
@ -374,7 +377,7 @@ security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody a
|
||||||
--host "$(SECURITY_BOOTSTRAP_HOST)" \
|
--host "$(SECURITY_BOOTSTRAP_HOST)" \
|
||||||
--port "$(SECURITY_BOOTSTRAP_PORT)"
|
--port "$(SECURITY_BOOTSTRAP_PORT)"
|
||||||
|
|
||||||
.PHONY: runbooks-validate rotation-lanes-test rotation-lanes-check help hooks hooks-test sops-setup sops-edit sops-encrypt sops-decrypt sops-rotate \
|
.PHONY: runbooks-validate bind-split-test rotation-lanes-test rotation-lanes-check help hooks hooks-test sops-setup sops-edit sops-encrypt sops-decrypt sops-rotate \
|
||||||
check-secrets creds-init creds-generate creds-bundle creds-apply creds-verify \
|
check-secrets creds-init creds-generate creds-bundle creds-apply creds-verify \
|
||||||
creds-status creds-rotate \
|
creds-status creds-rotate \
|
||||||
creds-agent-init creds-agent-status creds-emergency-reprint \
|
creds-agent-init creds-agent-status creds-emergency-reprint \
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ TARGETS = {
|
||||||
ROTATIONS = {
|
ROTATIONS = {
|
||||||
'postgresql': {'PI_DB_PASSWORD': 'password'},
|
'postgresql': {'PI_DB_PASSWORD': 'password'},
|
||||||
'privacyidea': {'PI_SECRET_KEY': 'PI_SECRET_KEY', 'PI_DB_PASSWORD': 'PI_SQLALCHEMY_DATABASE_URI'},
|
'privacyidea': {'PI_SECRET_KEY': 'PI_SECRET_KEY', 'PI_DB_PASSWORD': 'PI_SQLALCHEMY_DATABASE_URI'},
|
||||||
'lldap': {'LLDAP_JWT_SECRET': 'LLDAP_JWT_SECRET'},
|
'lldap': {'LLDAP_JWT_SECRET': 'LLDAP_JWT_SECRET', 'LLDAP_LDAP_USER_PASS': 'LLDAP_LDAP_USER_PASS'},
|
||||||
'authelia': {'AUTHELIA_SESSION_SECRET': 'session_secret'},
|
'authelia': {'AUTHELIA_SESSION_SECRET': 'session_secret', 'LLDAP_SVC_AUTHELIA_PASS': 'ldap_password'},
|
||||||
}
|
}
|
||||||
ANNOTATION = 'kubectl.kubernetes.io/last-applied-configuration'
|
ANNOTATION = 'kubectl.kubernetes.io/last-applied-configuration'
|
||||||
|
|
||||||
|
|
|
||||||
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())
|
||||||
164
sso-mfa/k8s/lldap/test_bind_split.py
Normal file
164
sso-mfa/k8s/lldap/test_bind_split.py
Normal 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()
|
||||||
|
|
@ -21,7 +21,8 @@ VALUES = {
|
||||||
COMPONENTS = ['postgresql', 'privacyidea', 'lldap', 'authelia', 'identity-provisioner']
|
COMPONENTS = ['postgresql', 'privacyidea', 'lldap', 'authelia', 'identity-provisioner']
|
||||||
ROTATIONS = [('postgresql','PI_DB_PASSWORD','password'), ('privacyidea','PI_SECRET_KEY','PI_SECRET_KEY'),
|
ROTATIONS = [('postgresql','PI_DB_PASSWORD','password'), ('privacyidea','PI_SECRET_KEY','PI_SECRET_KEY'),
|
||||||
('privacyidea','PI_DB_PASSWORD','PI_SQLALCHEMY_DATABASE_URI'),
|
('privacyidea','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'), ('authelia','AUTHELIA_SESSION_SECRET','session_secret'),
|
||||||
|
('lldap','LLDAP_LDAP_USER_PASS','LLDAP_LDAP_USER_PASS'), ('authelia','LLDAP_SVC_AUTHELIA_PASS','ldap_password')]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -91,6 +92,7 @@ def check_private_result(result):
|
||||||
assert 'BEGIN PRIVATE KEY' not in result.stdout+result.stderr
|
assert 'BEGIN PRIVATE KEY' not in result.stdout+result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def key_source(component,key):return 'lldap' if key.startswith('LLDAP_') else source(component)
|
||||||
def source(component):return {'postgresql':'privacyidea','identity-provisioner':'lldap'}.get(component,component)
|
def source(component):return {'postgresql':'privacyidea','identity-provisioner':'lldap'}.get(component,component)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -129,7 +131,7 @@ def test_repeated_apply_is_noop_and_preserves_other_data(case,component):
|
||||||
def test_explicit_rotation_is_conditional_and_changes_only_named_field(case,component,key,field):
|
def test_explicit_rotation_is_conditional_and_changes_only_named_field(case,component,key,field):
|
||||||
assert invoke(case,component).returncode==0
|
assert invoke(case,component).returncode==0
|
||||||
old=state(case);old['data']['owner-added']='c3ludGhldGlj';old['metadata']['annotations']={'owner':'retained','kubectl.kubernetes.io/last-applied-configuration':'synthetic-old-copy'};put_state(case,old)
|
old=state(case);old['data']['owner-added']='c3ludGhldGlj';old['metadata']['annotations']={'owner':'retained','kubectl.kubernetes.io/last-applied-configuration':'synthetic-old-copy'};put_state(case,old)
|
||||||
file=case[0]/source(component)/'secrets.env';file.write_text(file.read_text().replace(f'{key}={PASSWORD}',f'{key}={CHANGED}'))
|
file=case[0]/key_source(component,key)/'secrets.env';file.write_text(file.read_text().replace(f'{key}={PASSWORD}',f'{key}={CHANGED}'))
|
||||||
result=invoke(case,component,rotate=key);assert result.returncode==0,result.stderr;check_private_result(result)
|
result=invoke(case,component,rotate=key);assert result.returncode==0,result.stderr;check_private_result(result)
|
||||||
updated=state(case)
|
updated=state(case)
|
||||||
assert updated['metadata']['annotations']=={'owner':'retained'}
|
assert updated['metadata']['annotations']=={'owner':'retained'}
|
||||||
|
|
@ -191,7 +193,7 @@ def test_wrong_or_controller_owned_existing_secret_is_refused(case,defect):
|
||||||
@pytest.mark.parametrize('component,key,field',ROTATIONS)
|
@pytest.mark.parametrize('component,key,field',ROTATIONS)
|
||||||
def test_concurrent_change_stops_rotation_without_retry(case,component,key,field):
|
def test_concurrent_change_stops_rotation_without_retry(case,component,key,field):
|
||||||
assert invoke(case,component).returncode==0;old=state(case)
|
assert invoke(case,component).returncode==0;old=state(case)
|
||||||
file=case[0]/source(component)/'secrets.env';file.write_text(file.read_text().replace(f'{key}={PASSWORD}',f'{key}={CHANGED}'))
|
file=case[0]/key_source(component,key)/'secrets.env';file.write_text(file.read_text().replace(f'{key}={PASSWORD}',f'{key}={CHANGED}'))
|
||||||
count=len(calls(case));result=invoke(case,component,rotate=key,RACE='1')
|
count=len(calls(case));result=invoke(case,component,rotate=key,RACE='1')
|
||||||
assert result.returncode!=0 and state(case)==old;check_private_result(result)
|
assert result.returncode!=0 and state(case)==old;check_private_result(result)
|
||||||
assert sum(c['args'][0]=='patch' for c in calls(case)[count:])==1
|
assert sum(c['args'][0]=='patch' for c in calls(case)[count:])==1
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,20 @@ human login 200, wrong password 401, `svc-keycape`/`svc-privacyidea`/`ld-admin`
|
||||||
filter all three were 200. The read-only `svc-authelia` bind is sufficient for Authelia's lookups.
|
filter all three were 200. The read-only `svc-authelia` bind is sufficient for Authelia's lookups.
|
||||||
Phase 2 is complete; phases 3 and 4 remain.
|
Phase 2 is complete; phases 3 and 4 remain.
|
||||||
|
|
||||||
|
**2026-10-29 phase 3a (live helper):** `sso-mfa/k8s/lldap/bind-split.py`, founder-run, changes the
|
||||||
|
directory only. `prepare-custody` adds `LLDAP_SVC_*_PASS` and a new `ld-admin` `LLDAP_LDAP_USER_PASS`
|
||||||
|
to the private custody copy (40 alphanumerics, the shape `apply-service-secrets.literal()` accepts)
|
||||||
|
and refuses a second run; `plan` is read-only; `accounts [--apply]` creates `ld-admin` and the `svc-*`
|
||||||
|
accounts with `@service.netkingdom.invalid` emails, sets exactly their groups and their passwords
|
||||||
|
(pinned `lldap_set_password`, password in the environment only) and refuses an existing account with
|
||||||
|
a foreign email or extra groups; `verify [--old-admin-retired]` proves every account signs in with
|
||||||
|
its custody password, `admin` keeps `lldap_admin`, and the old shared password is refused. The
|
||||||
|
human `admin` signs in at a hidden prompt; receipts are value-free. `make bind-split-test` runs it
|
||||||
|
end to end on a throwaway pinned LLDAP (3 tests). `apply-service-secrets.py` gains the named
|
||||||
|
rotations `lldap LLDAP_LDAP_USER_PASS` and `authelia LLDAP_SVC_AUTHELIA_PASS -> ldap_password`, so the
|
||||||
|
existing Secrets are switched only by explicit, conditional rotation (97 tests). Left: 3b (backup
|
||||||
|
profile and the three restore launchers), then phase 4.
|
||||||
|
|
||||||
## Disposition of per-service admin accounts
|
## Disposition of per-service admin accounts
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue