NK-WP-0054-T05 phase 2c: Authelia refuses svc-* and ld-admin sign-in; synthetic check
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
145ca8e08c
commit
18dae2a0ac
4 changed files with 186 additions and 5 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue