From 18dae2a0ac41db316417617c6931ac4075b3af8a Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 29 Oct 2026 21:52:52 +0100 Subject: [PATCH] NK-WP-0054-T05 phase 2c: Authelia refuses svc-* and ld-admin sign-in; synthetic check Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 308701@bnt-lap001 Assistant-Session: 5548e447-444c-4dcc-bed1-05c16eebfe56 --- .../synthetic-authelia-filter.json | 27 ++++ sso-mfa/k8s/authelia/configmap.yaml | 5 +- sso-mfa/k8s/lldap/bind-split-rehearsal.py | 150 +++++++++++++++++- .../NK-WP-0054-service-identity-policy.md | 9 ++ 4 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 docs/evidence/2026-10-28-nk54-bind-split-rehearsal/synthetic-authelia-filter.json diff --git a/docs/evidence/2026-10-28-nk54-bind-split-rehearsal/synthetic-authelia-filter.json b/docs/evidence/2026-10-28-nk54-bind-split-rehearsal/synthetic-authelia-filter.json new file mode 100644 index 0000000..55d7fca --- /dev/null +++ b/docs/evidence/2026-10-28-nk54-bind-split-rehearsal/synthetic-authelia-filter.json @@ -0,0 +1,27 @@ +{ + "data": "synthetic", + "findings": { + "bind_user": "uid=svc-authelia,ou=people,dc=netkingdom,dc=local", + "previous_filter": { + "human": 200, + "human_wrong_password": 401, + "ld-admin": 200, + "svc-keycape": 200, + "svc-privacyidea": 200 + }, + "repository_filter": { + "human": 200, + "human_wrong_password": 401, + "ld-admin": 401, + "svc-keycape": 401, + "svc-privacyidea": 401 + } + }, + "image": "docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d", + "observed_at": "2026-10-29T20:52:19.793834+00:00", + "production_contacted": false, + "schema": "netkingdom.lldap-bind-split-rehearsal.v1", + "stage": "authelia", + "task": "NK-WP-0054-T05", + "values_recorded": false +} diff --git a/sso-mfa/k8s/authelia/configmap.yaml b/sso-mfa/k8s/authelia/configmap.yaml index c4eb6a3..8f29245 100644 --- a/sso-mfa/k8s/authelia/configmap.yaml +++ b/sso-mfa/k8s/authelia/configmap.yaml @@ -58,8 +58,9 @@ data: additional_users_dn: ou=people # Accept either the stable uid or the user's mail address. Authelia's # official LLDAP integration recommends this OR filter; the resolved - # identity still uses uid as its stable username. - users_filter: "(&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=inetOrgPerson))" + # identity still uses uid as its stable username. Service binds (svc-*) and the sealed + # directory admin (ld-admin) never sign in through Authelia (SI-3, SI-5; NK-WP-0054-T05). + users_filter: "(&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=inetOrgPerson)(!({username_attribute}=svc-*))(!({username_attribute}=ld-admin)))" additional_groups_dn: ou=groups groups_filter: "(member={dn})" group_name_attribute: cn diff --git a/sso-mfa/k8s/lldap/bind-split-rehearsal.py b/sso-mfa/k8s/lldap/bind-split-rehearsal.py index 5bab891..df27ef6 100644 --- a/sso-mfa/k8s/lldap/bind-split-rehearsal.py +++ b/sso-mfa/k8s/lldap/bind-split-rehearsal.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # /// script -# dependencies = ["ldap3>=2.9,<3"] +# dependencies = ["ldap3>=2.9,<3", "PyYAML>=6,<7"] # /// """Synthetic rehearsal of the directory bind split on the pinned LLDAP image (NK-WP-0054-T05). @@ -13,7 +13,9 @@ It answers the questions the live migration depends on and writes a value-free J - does `admin` keep its rights through `lldap_admin` group membership after that switch; - 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). +- can a `svc-*` account sign in interactively to LLDAP (SI-3); +- `--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). """ from __future__ import annotations @@ -31,7 +33,11 @@ from datetime import UTC, datetime from pathlib import Path import ldap3 +import yaml +AUTHELIA_IMAGE = "docker.io/authelia/authelia@sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54" +CONFIGMAP = Path(__file__).resolve().parents[1] / "authelia/configmap.yaml" +PREVIOUS_FILTER = "(&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=inetOrgPerson))" IMAGE = "docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d" BASE_DN = "dc=netkingdom,dc=local" READ_ONLY = ("svc-authelia", "svc-keycape", "svc-privacyidea") @@ -371,20 +377,158 @@ def rehearse(tag): return result +def authelia_config(users_filter, password): + config = yaml.safe_load( + yaml.safe_load(CONFIGMAP.read_text())["data"]["configuration.yml"] + ) + ldap = config["authentication_backend"]["ldap"] + ldap.pop("url", None) + ldap.update(address="ldap://lldap:3890", password=password) + if users_filter is not None: + ldap["users_filter"] = users_filter + config.pop("identity_providers", None) + config["session"]["secret"] = secrets.token_hex(32) + config["storage"] = { + "encryption_key": secrets.token_hex(32), + "local": {"path": "/data/db.sqlite3"}, + } + config["identity_validation"] = { + "reset_password": {"jwt_secret": secrets.token_hex(32)} + } + config["notifier"] = {"filesystem": {"filename": "/data/notifications.txt"}} + config["ntp"] = {"disable_startup_check": True} + config["server"] = {"address": "tcp://0.0.0.0:9091", "disable_healthcheck": True} + return config, ldap["user"], config["session"]["domain"] + + +def first_factor(name, config, network, logins, workdir): + path = workdir / f"{name}.yml" + path.write_text(yaml.safe_dump(config)) + path.chmod(0o644) + docker( + "run", + "--detach", + "--pull=never", + "--name", + name, + "--network", + network, + "--publish", + "127.0.0.1::9091", + "--tmpfs", + "/data", + "--volume", + f"{path}:/config/configuration.yml:ro", + AUTHELIA_IMAGE, + ) + try: + ports = json.loads( + docker( + "inspect", "--format", "{{json .NetworkSettings.Ports}}", name + ).stdout + ) + base = f"http://127.0.0.1:{ports['9091/tcp'][0]['HostPort']}" + head = { + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "auth." + config["session"]["domain"], + "X-Forwarded-For": "127.0.0.1", + "Content-Type": "application/json", + } + for _ in range(120): + try: + with urllib.request.urlopen( + urllib.request.Request(base + "/api/health", headers=head), + timeout=2, + ): + break + except (urllib.error.URLError, ConnectionError): + time.sleep(0.5) + else: + raise RuntimeError("authelia did not become healthy") + result = {} + for label, (user, password) in logins.items(): + body = json.dumps( + {"username": user, "password": password, "keepMeLoggedIn": False} + ).encode() + request = urllib.request.Request( + base + "/api/firstfactor", data=body, headers=head, method="POST" + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + result[label] = response.status + except urllib.error.HTTPError as error: + result[label] = error.code + return result + finally: + docker("rm", "--force", name, check=False) + + +def rehearse_authelia(tag, workdir): + users = ("alice", "ld-admin", *READ_ONLY) + passwords = {name: secrets.token_urlsafe(24) for name in ("admin", *users)} + directory = Directory(tag) + network = f"nk54-authelia-{tag}" + result = {} + try: + directory.start("admin", passwords["admin"]) + admin = directory.login("admin", passwords["admin"]) + directory.ok( + admin, 'mutation { createGroup(name: "net-kingdom-users") { id } }' + ) + directory.create_user(admin, "alice", ["net-kingdom-users"]) + directory.create_user(admin, "ld-admin", ["lldap_admin"]) + for user in READ_ONLY: + directory.create_user(admin, user, ["lldap_strict_readonly"]) + for user in users: + directory.set_password(admin, user, passwords[user]) + docker("network", "create", network) + docker("network", "connect", "--alias", "lldap", network, directory.name) + logins = { + "human": ("alice", passwords["alice"]), + "human_wrong_password": ("alice", "wrong-" + passwords["alice"]), + "svc-keycape": ("svc-keycape", passwords["svc-keycape"]), + "svc-privacyidea": ("svc-privacyidea", passwords["svc-privacyidea"]), + "ld-admin": ("ld-admin", passwords["ld-admin"]), + } + config, bind_user, _ = authelia_config(None, passwords["svc-authelia"]) + result["bind_user"] = bind_user + result["repository_filter"] = first_factor( + f"nk54-authelia-new-{tag}", config, network, logins, workdir + ) + config, _, _ = authelia_config(PREVIOUS_FILTER, passwords["svc-authelia"]) + result["previous_filter"] = first_factor( + f"nk54-authelia-old-{tag}", config, network, logins, workdir + ) + finally: + directory.remove() + docker("network", "rm", network, check=False) + return result + + 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" + ) args = parser.parse_args(argv) if args.receipt.exists(): print("refused: receipt exists", file=sys.stderr) return 2 tag = uuid.uuid4().hex[:10] - findings = rehearse(tag) + if args.stage == "authelia": + import tempfile + + with tempfile.TemporaryDirectory(prefix="nk54-authelia-") as workdir: + findings = rehearse_authelia(tag, Path(workdir)) + else: + findings = rehearse(tag) receipt = { "schema": "netkingdom.lldap-bind-split-rehearsal.v1", "task": "NK-WP-0054-T05", "observed_at": datetime.now(UTC).isoformat(), "image": IMAGE, + "stage": args.stage, "data": "synthetic", "production_contacted": False, "values_recorded": False, diff --git a/workplans/NK-WP-0054-service-identity-policy.md b/workplans/NK-WP-0054-service-identity-policy.md index 3781fb0..9f5ebf5 100644 --- a/workplans/NK-WP-0054-service-identity-policy.md +++ b/workplans/NK-WP-0054-service-identity-policy.md @@ -303,6 +303,15 @@ runs `lldap/lldap:stable`, and pinning to v0.6.2 could downgrade the database; t running digest first. Service-account emails use `@service.netkingdom.invalid`. Left: 2c (Authelia users filter requiring `net-kingdom-users`, synthetic check), then phases 3 and 4. +**2026-10-29 phase 2c:** Authelia's `users_filter` now excludes `svc-*` and `ld-admin` instead of +requiring `net-kingdom-users`, because tenant users are provisioned only into `:users` +and an inclusion filter could lock them out. `bind-split-rehearsal.py --stage authelia` runs the +pinned Authelia 4.39.28 with the repository's own `configmap.yaml`, bound as `svc-authelia` +against a synthetic LLDAP ([receipt](../docs/evidence/2026-10-28-nk54-bind-split-rehearsal/synthetic-authelia-filter.json)): +human login 200, wrong password 401, `svc-keycape`/`svc-privacyidea`/`ld-admin` 401; under the previous +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. + ## Disposition of per-service admin accounts ```task