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
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue