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
164 lines
5.3 KiB
Python
164 lines
5.3 KiB
Python
"""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()
|