NK-WP-0054-T05 phase 3a: founder-run bind-split helper with end-to-end test
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

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:
tegwick 2026-10-29 22:00:49 +01:00
parent 18dae2a0ac
commit e6f4e28164
6 changed files with 577 additions and 6 deletions

View file

@ -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 \

View file

@ -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'

View 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())

View 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()

View file

@ -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

View file

@ -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