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)
|
||||
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)
|
||||
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)" \
|
||||
--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 \
|
||||
creds-status creds-rotate \
|
||||
creds-agent-init creds-agent-status creds-emergency-reprint \
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
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']
|
||||
ROTATIONS = [('postgresql','PI_DB_PASSWORD','password'), ('privacyidea','PI_SECRET_KEY','PI_SECRET_KEY'),
|
||||
('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
|
||||
|
|
@ -91,6 +92,7 @@ def check_private_result(result):
|
|||
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)
|
||||
|
||||
|
||||
|
|
@ -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):
|
||||
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)
|
||||
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)
|
||||
updated=state(case)
|
||||
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)
|
||||
def test_concurrent_change_stops_rotation_without_retry(case,component,key,field):
|
||||
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')
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue