Surface redacted directory bind failures before native onboarding
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s
Identity provider journey acceptance / provider (push) Successful in 6s
Build and Publish identity-provisioner / build-and-push (push) Successful in 10s

Map uncaught HTTPError from LLDAP login to a structured
dependency_unavailable response, add /readyz as the provisioner-to-directory
preflight, keep /healthz as process liveness, and run the contract in CI.
Auth rejection is not retried during cooldown.

NK-WP-0036-T05 remains in progress until the immutable image is published,
pinned with /readyz, and one native login/create/password-setup journey is
verified.

Assistant: grok
Assistant-Session: 01a09dc6-3f0e-78f1-a884-c8c703c24ddf
This commit is contained in:
tegwick 2026-09-14 04:46:29 +02:00
parent d90e3b27f2
commit c8e07615c3
9 changed files with 554 additions and 33 deletions

View file

@ -4,15 +4,16 @@ from __future__ import annotations
from dataclasses import dataclass
import hashlib
import json
import os
import secrets
import subprocess
import threading
import time
from typing import Callable
from urllib.request import Request, urlopen
from urllib.parse import urlencode, urlsplit
from urllib.request import urlopen
from provisioner import directory_login
@dataclass(frozen=True)
@ -116,16 +117,12 @@ class LLDAPPasswordSetter:
self.runner = runner
def __call__(self, subject: str, password: str) -> None:
request = Request(
self.base_url + "/auth/simple/login",
data=json.dumps(
{"username": "admin", "password": self.admin_password}
).encode(),
headers={"Content-Type": "application/json"},
method="POST",
token = directory_login(
base_url=self.base_url,
admin_password=self.admin_password,
opener=self.opener,
timeout=10,
)
with self.opener(request, timeout=10) as response:
token = str(json.loads(response.read())["token"])
env = dict(os.environ)
env["LLDAP_USER_PASSWORD"] = password
result = self.runner(

View file

@ -5,8 +5,9 @@ from __future__ import annotations
from dataclasses import dataclass
import json
import re
import secrets
import time
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
@ -27,11 +28,38 @@ class DriftResult:
changed: tuple[str, ...] = ()
class DependencyFailure(RuntimeError):
"""Directory dependency failed. Messages never carry credentials or upstream bodies."""
def __init__(self, code: str, *, dependency: str = "directory") -> None:
self.code = code
self.dependency = dependency
super().__init__("dependency_unavailable")
def payload(self) -> dict[str, str]:
return {
"error": "dependency_unavailable",
"dependency": self.dependency,
"reason": self.code,
}
class LLDAPProvisioner:
def __init__(self, *, base_url: str, admin_password: str, opener: Callable = urlopen) -> None:
def __init__(
self,
*,
base_url: str,
admin_password: str,
opener: Callable = urlopen,
auth_rejected_cooldown: float = 30.0,
clock: Callable[[], float] = time.monotonic,
) -> None:
self.base_url = base_url.rstrip("/")
self.admin_password = admin_password
self.opener = opener
self.auth_rejected_cooldown = auth_rejected_cooldown
self.clock = clock
self._auth_rejected_until = 0.0
def provision(self, payload: dict[str, Any]) -> Result:
_required(payload, "user_id", "tenant", "primary_email", "idempotency_key", "correlation_id")
@ -177,15 +205,27 @@ mutation Remove($userId: String!, $groupId: Int!) {
status = "reconciled" if not remaining else "drifted"
return DriftResult("netkingdom-lldap", subject, status, tuple(remaining), tuple(changed))
def _login(self) -> str:
request = Request(
self.base_url + "/auth/simple/login",
data=json.dumps({"username": "admin", "password": self.admin_password}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
def preflight(self) -> dict[str, str]:
"""One login plus one directory read. Auth rejection is not retried during cooldown."""
now = self.clock()
if now < self._auth_rejected_until:
raise DependencyFailure("auth_rejected")
try:
token = self._login(timeout=3)
self._gql(token, "query { groups { id } }", {}, timeout=3)
except DependencyFailure as exc:
if exc.code == "auth_rejected":
self._auth_rejected_until = now + self.auth_rejected_cooldown
raise
return {"status": "ready", "dependency": "directory"}
def _login(self, timeout: float = 10) -> str:
return directory_login(
base_url=self.base_url,
admin_password=self.admin_password,
opener=self.opener,
timeout=timeout,
)
with self.opener(request, timeout=10) as response:
return str(json.loads(response.read())["token"])
def _directory(self, token: str) -> tuple[list[dict], list[dict]]:
value = self._gql(token, "query { users { id email displayName } groups { id displayName } }", {})
@ -264,20 +304,74 @@ mutation Remove($userId: String!, $groupId: Int!) {
drift.append("status:mismatch")
return drift
def _gql(self, token: str, query: str, variables: dict[str, Any]) -> dict:
def _gql(self, token: str, query: str, variables: dict[str, Any], timeout: float = 15) -> dict:
request = Request(
self.base_url + "/api/graphql",
data=json.dumps({"query": query, "variables": variables}).encode(),
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
method="POST",
)
with self.opener(request, timeout=15) as response:
payload = json.loads(response.read())
try:
with self.opener(request, timeout=timeout) as response:
payload = json.loads(response.read())
except HTTPError as exc:
status = int(getattr(exc, "code", 0) or 0)
_discard(exc)
if status in {401, 403}:
raise DependencyFailure("auth_rejected") from None
raise DependencyFailure("protocol_error") from None
except (TimeoutError, URLError, OSError):
raise DependencyFailure("unreachable") from None
except (json.JSONDecodeError, TypeError, ValueError, UnicodeDecodeError):
raise DependencyFailure("protocol_error") from None
if payload.get("errors"):
raise ValueError(str(payload["errors"][0].get("message", "LLDAP GraphQL error")))
return dict(payload.get("data") or {})
def directory_login(
*,
base_url: str,
admin_password: str,
opener: Callable = urlopen,
timeout: float = 10,
) -> str:
request = Request(
base_url.rstrip("/") + "/auth/simple/login",
data=json.dumps({"username": "admin", "password": admin_password}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with opener(request, timeout=timeout) as response:
payload = json.loads(response.read())
except HTTPError as exc:
status = int(getattr(exc, "code", 0) or 0)
_discard(exc)
if status in {401, 403}:
raise DependencyFailure("auth_rejected") from None
raise DependencyFailure("protocol_error") from None
except (TimeoutError, URLError, OSError):
raise DependencyFailure("unreachable") from None
except (json.JSONDecodeError, TypeError, ValueError, UnicodeDecodeError):
raise DependencyFailure("protocol_error") from None
if not isinstance(payload, dict):
raise DependencyFailure("protocol_error")
token = str(payload.get("token") or "")
if not token:
raise DependencyFailure("protocol_error")
return token
def _discard(exc: BaseException) -> None:
read = getattr(exc, "read", None)
if callable(read):
try:
read(65536)
except Exception:
pass
def dispatch(
provisioner: LLDAPProvisioner, path: str, payload: dict[str, Any]
) -> Result | DriftResult:

View file

@ -6,7 +6,7 @@ import os
from urllib.parse import parse_qs, urlsplit
import secrets
from provisioner import LLDAPProvisioner, _directory_username, dispatch
from provisioner import DependencyFailure, LLDAPProvisioner, _directory_username, dispatch
from password_setup import LLDAPPasswordSetter, PasswordSetupGrants
@ -19,6 +19,17 @@ class Handler(BaseHTTPRequestHandler):
path = urlsplit(self.path)
if path.path == "/healthz":
return self._send(200, {"status": "ok"})
if path.path == "/readyz":
try:
return self._send(200, self.provisioner.preflight())
except DependencyFailure as exc:
return self._send(503, exc.payload())
except RuntimeError:
return self._send(503, {
"error": "dependency_unavailable",
"dependency": "directory",
"reason": "unreachable",
})
if path.path == "/setup/password":
token = parse_qs(path.query).get("token", [""])[0]
if not self.password_setups.valid(token):
@ -41,6 +52,8 @@ class Handler(BaseHTTPRequestHandler):
return self._send(404, {"error": "not_found"})
except (ValueError, json.JSONDecodeError) as exc:
return self._send(400, {"error": "invalid_request", "message": str(exc)})
except DependencyFailure as exc:
return self._send(503, exc.payload())
except RuntimeError:
return self._send(503, {"error": "dependency_unavailable"})
response = asdict(result)
@ -64,7 +77,7 @@ class Handler(BaseHTTPRequestHandler):
return_to = self.password_setups.consume(token, password)
except ValueError as exc:
return self._html(400, _setup_page(token, str(exc)))
except RuntimeError:
except (DependencyFailure, RuntimeError):
return self._html(503, _failed_page())
return self._html(200, _complete_page(return_to))

View file

@ -0,0 +1,336 @@
"""Contained provisioner-to-directory contract: login, preflight, redacted failure."""
from __future__ import annotations
import io
import json
import pathlib
import sys
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
sys.path.insert(0, str(pathlib.Path(__file__).parents[1]))
from password_setup import LLDAPPasswordSetter, PasswordSetupGrants
from provisioner import DependencyFailure, LLDAPProvisioner, directory_login
from server import Handler
SECRET = "synthetic-only-directory-password"
TOKEN = "synthetic-directory-token"
class DirectoryState:
def __init__(self) -> None:
self.password = SECRET
self.logins = 0
self.graphql = 0
self.users: dict[str, dict] = {}
self.groups: dict[str, int] = {}
self.next_group = 1
self.login_status = 200
self.login_body: dict | bytes = {"token": TOKEN}
class FakeDirectory(BaseHTTPRequestHandler):
state: DirectoryState
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("Content-Length", "0")))
if self.path == "/auth/simple/login":
self.state.logins += 1
payload = json.loads(raw)
if self.state.login_status != 200:
body = self.state.login_body
if isinstance(body, dict):
body = json.dumps(body).encode()
self._raw(self.state.login_status, body)
return
if payload.get("username") != "admin" or payload.get("password") != self.state.password:
leaked = json.dumps({"message": "invalid", "password": payload.get("password")}).encode()
self._raw(401, leaked)
return
self._send(200, {"token": TOKEN})
return
if self.path == "/api/graphql":
self.state.graphql += 1
if self.headers.get("Authorization") != f"Bearer {TOKEN}":
self._send(401, {"message": "unauthorized"})
return
self._send(200, self._graphql(json.loads(raw)))
return
self._send(404, {"error": "not_found"})
def _graphql(self, body: dict) -> dict:
query = str(body.get("query") or "")
variables = body.get("variables") or {}
if "createUser" in query:
user_id = str(variables["id"])
self.state.users[user_id] = {
"id": user_id,
"email": variables["email"],
"displayName": variables["display"],
"groups": [],
}
return {"data": {"createUser": {"id": user_id}}}
if "createGroup" in query:
name = str(variables["name"])
group_id = self.state.next_group
self.state.next_group += 1
self.state.groups[name] = group_id
return {"data": {"createGroup": {"id": group_id, "displayName": name}}}
if "addUserToGroup" in query:
user = self.state.users[str(variables["userId"])]
group_id = int(variables["groupId"])
name = next(n for n, i in self.state.groups.items() if i == group_id)
user["groups"].append({"id": group_id, "displayName": name})
return {"data": {"addUserToGroup": {"ok": True}}}
if "users {" in query and "groups {" in query:
return {
"data": {
"users": [
{"id": user["id"], "email": user["email"], "displayName": user["displayName"]}
for user in self.state.users.values()
],
"groups": [
{"id": group_id, "displayName": name}
for name, group_id in self.state.groups.items()
],
}
}
if "groups { id }" in query:
return {"data": {"groups": [{"id": group_id} for group_id in self.state.groups.values()]}}
return {"data": {}}
def _send(self, status: int, payload: dict) -> None:
self._raw(status, json.dumps(payload).encode())
def _raw(self, status: int, body: bytes) -> None:
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
return
def serve(handler):
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, thread, f"http://127.0.0.1:{server.server_port}"
class DirectoryContractTests(unittest.TestCase):
def setUp(self):
self.state = DirectoryState()
class Bound(FakeDirectory):
state = self.state
self.directory, self.directory_thread, self.directory_url = serve(Bound)
self.provisioner = LLDAPProvisioner(
base_url=self.directory_url,
admin_password=SECRET,
auth_rejected_cooldown=60,
clock=lambda: self.now,
)
self.now = 0.0
class App(Handler):
service_token = "fixture-only-service-token"
provisioner = self.provisioner
password_setups = PasswordSetupGrants(
public_url="https://kc.example/setup/password",
setter=LLDAPPasswordSetter(
base_url=self.directory_url,
admin_password=SECRET,
helper="/bin/true",
runner=lambda *args, **kwargs: type("R", (), {"returncode": 0})(),
),
)
self.app, self.app_thread, self.app_url = serve(App)
def tearDown(self):
for server, thread in (
(self.app, self.app_thread),
(self.directory, self.directory_thread),
):
server.shutdown()
server.server_close()
thread.join()
def get(self, path: str):
try:
response = urlopen(self.app_url + path)
except HTTPError as error:
response = error
with response:
body = response.read().decode()
return response.status, json.loads(body) if body.startswith("{") else body
def provision(self):
payload = {
"user_id": "person-1",
"tenant": "tenant:trial:demo",
"primary_email": "person@example.test",
"display_name": "Person",
"idempotency_key": "fixture-long-idempotency",
"correlation_id": "corr-fixture",
"roles": ["tenant-admin"],
}
request = Request(
self.app_url + "/v1/identities/provision",
data=json.dumps(payload).encode(),
headers={"Authorization": "Bearer fixture-only-service-token"},
)
try:
response = urlopen(request)
except HTTPError as error:
response = error
with response:
return response.status, json.loads(response.read())
def test_preflight_covers_login_and_directory_read(self):
self.assertEqual(
{"status": "ready", "dependency": "directory"},
self.provisioner.preflight(),
)
self.assertEqual(1, self.state.logins)
self.assertEqual(1, self.state.graphql)
def test_healthz_stays_ok_when_directory_rejects_bind(self):
self.state.login_status = 401
self.state.login_body = {"message": "invalid", "password": SECRET}
live_status, live = self.get("/healthz")
ready_status, ready = self.get("/readyz")
self.assertEqual(200, live_status)
self.assertEqual({"status": "ok"}, live)
self.assertEqual(503, ready_status)
self.assertEqual(
{
"error": "dependency_unavailable",
"dependency": "directory",
"reason": "auth_rejected",
},
ready,
)
self.assertNotIn(SECRET, json.dumps(ready))
def test_rejected_bind_is_not_retried_during_cooldown(self):
self.state.login_status = 401
with self.assertRaises(DependencyFailure) as failure:
self.provisioner.preflight()
self.assertEqual("auth_rejected", failure.exception.code)
self.assertEqual(1, self.state.logins)
self.now = 30.0
with self.assertRaises(DependencyFailure):
self.provisioner.preflight()
self.assertEqual(1, self.state.logins)
self.now = 60.0
with self.assertRaises(DependencyFailure):
self.provisioner.preflight()
self.assertEqual(2, self.state.logins)
def test_provision_returns_redacted_structured_failure_not_http_error(self):
self.state.login_status = 401
self.state.login_body = {"message": "invalid", "password": SECRET}
status, payload = self.provision()
self.assertEqual(503, status)
self.assertEqual("dependency_unavailable", payload["error"])
self.assertEqual("directory", payload["dependency"])
self.assertEqual("auth_rejected", payload["reason"])
serialized = json.dumps(payload)
self.assertNotIn(SECRET, serialized)
self.assertNotIn("HTTPError", serialized)
self.assertNotIn("password", serialized)
def test_native_provision_and_password_setup_journey(self):
status, payload = self.provision()
self.assertEqual(200, status)
self.assertEqual("password_setup_required", payload["status"])
self.assertIn("uid=person,ou=people,dc=netkingdom,dc=local", payload["external_subject"])
self.assertIn("person", self.state.users)
self.assertIn("tenant:trial:demo:users", self.state.groups)
self.assertIn("tenant:trial:demo:admins", self.state.groups)
token = payload["password_setup_url"].partition("token=")[2]
request = Request(
self.app_url + "/setup/password",
data=urlencode({
"token": token,
"password": "recipient-long-password",
"confirmation": "recipient-long-password",
}).encode(),
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urlopen(request) as response:
body = response.read().decode()
self.assertEqual(200, response.status)
self.assertIn("Password set", body)
self.assertNotIn(SECRET, body)
self.assertNotIn("recipient-long-password", body)
self.assertNotIn(token, body)
class DirectoryLoginMappingTests(unittest.TestCase):
def test_http_401_becomes_auth_rejected_without_body_or_cause(self):
def opener(request, timeout=10):
raise HTTPError(
request.full_url,
401,
"Unauthorized",
hdrs=None,
fp=io.BytesIO(json.dumps({"password": SECRET, "token": "leaked"}).encode()),
)
with self.assertRaises(DependencyFailure) as failure:
directory_login(base_url="http://directory", admin_password=SECRET, opener=opener)
self.assertEqual("auth_rejected", failure.exception.code)
self.assertIsNone(failure.exception.__cause__)
self.assertNotIn(SECRET, str(failure.exception))
self.assertNotIn("leaked", str(failure.exception.payload()))
self.assertEqual(
{
"error": "dependency_unavailable",
"dependency": "directory",
"reason": "auth_rejected",
},
failure.exception.payload(),
)
def test_unreachable_directory_is_structured_and_not_retried(self):
calls = []
def opener(request, timeout=10):
calls.append(timeout)
raise URLError("connection refused")
with self.assertRaises(DependencyFailure) as failure:
directory_login(base_url="http://directory", admin_password=SECRET, opener=opener)
self.assertEqual("unreachable", failure.exception.code)
self.assertEqual([10], calls)
def test_password_setter_rejects_bind_once(self):
calls = []
def opener(request, timeout=10):
calls.append(json.loads(request.data)["password"])
raise HTTPError(request.full_url, 401, "Unauthorized", hdrs=None, fp=io.BytesIO(b"{}"))
setter = LLDAPPasswordSetter(
base_url="http://directory",
admin_password=SECRET,
opener=opener,
runner=lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("helper must not run")),
)
with self.assertRaises(DependencyFailure) as failure:
setter("person", "recipient-long-password")
self.assertEqual("auth_rejected", failure.exception.code)
self.assertEqual([SECRET], calls)
self.assertNotIn(SECRET, str(failure.exception))