NK-WP-0054-T05 phase 2c: Authelia refuses svc-* and ld-admin sign-in; synthetic check
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

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 21:52:52 +01:00
parent 145ca8e08c
commit 18dae2a0ac
4 changed files with 186 additions and 5 deletions

View file

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

View file

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

View file

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

View file

@ -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 `<tenant>: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