diff --git a/docs/evidence/2026-10-28-nk54-bind-split-rehearsal/synthetic-startup.json b/docs/evidence/2026-10-28-nk54-bind-split-rehearsal/synthetic-startup.json new file mode 100644 index 0000000..18345d7 --- /dev/null +++ b/docs/evidence/2026-10-28-nk54-bind-split-rehearsal/synthetic-startup.json @@ -0,0 +1,15 @@ +{ + "data": "synthetic", + "findings": { + "admin_still_signs_in": true, + "mismatched_configured_password_ignored": true, + "missing_configured_admin_created": false + }, + "image": "docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d", + "observed_at": "2026-10-29T21:10:51.513396+00:00", + "production_contacted": false, + "schema": "netkingdom.lldap-bind-split-rehearsal.v1", + "stage": "startup", + "task": "NK-WP-0054-T05", + "values_recorded": false +} diff --git a/sso-mfa/k8s/lldap/bind-split-rehearsal.py b/sso-mfa/k8s/lldap/bind-split-rehearsal.py index df27ef6..ef3453c 100644 --- a/sso-mfa/k8s/lldap/bind-split-rehearsal.py +++ b/sso-mfa/k8s/lldap/bind-split-rehearsal.py @@ -14,6 +14,8 @@ It answers the questions the live migration depends on and writes a value-free J - does a `lldap_strict_readonly` bind read users and groups but fail to write; - what is the narrowest group with which the provisioner can create users and set membership; - can a `svc-*` account sign in interactively to LLDAP (SI-3); +- `--stage startup`: once `lldap_admin` has a member, does LLDAP ignore the configured admin password + and refuse to create a missing configured admin (so a backup from any point of the switch restores); - `--stage authelia`: does the repository's Authelia configuration, bound as `svc-authelia`, admit a human and refuse `svc-*` and `ld-admin` (and would the previous filter have admitted them). """ @@ -377,6 +379,31 @@ def rehearse(tag): return result +def rehearse_startup(tag): + admin, ld_admin = secrets.token_hex(16), secrets.token_hex(16) + directory = Directory(tag) + result = {} + try: + directory.start("admin", admin) + directory.stop() + directory.start("ld-admin", secrets.token_hex(16)) + result["missing_configured_admin_created"] = ( + directory.login("ld-admin", ld_admin) is not None + ) + result["admin_still_signs_in"] = directory.login("admin", admin) is not None + token = directory.login("admin", admin) + directory.create_user(token, "ld-admin", ["lldap_admin"]) + directory.set_password(token, "ld-admin", ld_admin) + directory.stop() + directory.start("ld-admin", secrets.token_hex(16)) + result["mismatched_configured_password_ignored"] = ( + directory.login("ld-admin", ld_admin) is not None + ) + finally: + directory.remove() + return result + + def authelia_config(users_filter, password): config = yaml.safe_load( yaml.safe_load(CONFIGMAP.read_text())["data"]["configuration.yml"] @@ -509,7 +536,7 @@ def main(argv=None): parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--receipt", type=Path, required=True) parser.add_argument( - "--stage", choices=["directory", "authelia"], default="directory" + "--stage", choices=["directory", "startup", "authelia"], default="directory" ) args = parser.parse_args(argv) if args.receipt.exists(): @@ -521,6 +548,8 @@ def main(argv=None): with tempfile.TemporaryDirectory(prefix="nk54-authelia-") as workdir: findings = rehearse_authelia(tag, Path(workdir)) + elif args.stage == "startup": + findings = rehearse_startup(tag) else: findings = rehearse(tag) receipt = { diff --git a/tests/test_isolated_lldap_recovery.py b/tests/test_isolated_lldap_recovery.py index bde7962..c59afdf 100644 --- a/tests/test_isolated_lldap_recovery.py +++ b/tests/test_isolated_lldap_recovery.py @@ -56,6 +56,26 @@ def test_configuration_preserves_original_seed_and_disables_reset_sending(tmp_pa assert PASSWORD.encode() not in value and jwt not in value + +@pytest.mark.parametrize('configured', [None, 'ld-admin']) +def test_configured_admin_follows_the_archived_deployment(tmp_path, configured): + # NK-WP-0054-T05: before and after the bind split the restore takes the configured + # super-admin from the archived deployment, never a hard-coded name. + resources(tmp_path) + if configured: + path = tmp_path / 'resources/sso/deployment/lldap.json' + deployment = json.loads(path.read_text()) + deployment['spec']['template']['spec']['containers'][0]['env'].append( + {'name': 'LLDAP_LDAP_USER_DN', 'value': configured}) + path.write_text(json.dumps(deployment)) + data = tmp_path / 'data/lldap' + data.mkdir(parents=True) + (data / 'lldap_config.toml').write_text('key_seed = "synthetic-original-seed"\n') + import tomllib + value, _, password, _ = m.configuration(tmp_path) + assert tomllib.loads(value.decode())['ldap_user_dn'] == (configured or 'admin') + assert password == PASSWORD.encode() + def test_tar_transport_refuses_path_escape(): with pytest.raises(m.bundle.Refused): m.archive_files({'../escape': b'synthetic'}) diff --git a/workplans/NK-WP-0054-service-identity-policy.md b/workplans/NK-WP-0054-service-identity-policy.md index c751ba6..bc1a16a 100644 --- a/workplans/NK-WP-0054-service-identity-policy.md +++ b/workplans/NK-WP-0054-service-identity-policy.md @@ -326,6 +326,22 @@ rotations `lldap LLDAP_LDAP_USER_PASS` and `authelia LLDAP_SVC_AUTHELIA_PASS -> existing Secrets are switched only by explicit, conditional rotation (97 tests). Left: 3b (backup profile and the three restore launchers), then phase 4. +**2026-10-29 phase 3b (restore path):** no launcher change is needed, which corrects this task's +earlier assumption. `isolated-lldap.py` takes the configured super-admin name from the archived +deployment's `LLDAP_LDAP_USER_DN` and its password from the archived `lldap-secrets`, and prompts for +the password of the user under test; `isolated-authelia.py` binds with the archived configmap user and +`authelia-secrets`; `isolated-privacyidea-mfa.py` rewrites only the resolver `LDAPURI` and keeps the +archived bind DN and password. After the split all three therefore follow `ld-admin`, `svc-authelia` +and `svc-privacyidea` from the backup itself. Regression test: the configured admin follows the archived +deployment (`tests/test_isolated_lldap_recovery.py`). `bind-split-rehearsal.py --stage startup` +([receipt](../docs/evidence/2026-10-28-nk54-bind-split-rehearsal/synthetic-startup.json)) proves on +v0.6.2 that once `lldap_admin` has a member, LLDAP ignores the configured admin password and does not +create a missing configured admin: a backup taken at any point of the switch restores, and +**`bind-split.py accounts` must run before the LLDAP deployment switch**. The backup profile is +unchanged: its components carry their own Secrets with the new values, and identity-provisioner is not +an identity-restore component (its password is re-derivable from custody). Phase 3 is complete; +phase 4 (runbook, founder rehearsal on a restored backup, live run) remains. + ## Disposition of per-service admin accounts ```task