Surface redacted directory bind failures before native onboarding
All checks were successful
All checks were successful
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:
parent
d90e3b27f2
commit
c8e07615c3
9 changed files with 554 additions and 33 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue