diff --git a/.forgejo/workflows/identity-journeys.yaml b/.forgejo/workflows/identity-journeys.yaml index 81e7a91..0e7a4eb 100644 --- a/.forgejo/workflows/identity-journeys.yaml +++ b/.forgejo/workflows/identity-journeys.yaml @@ -10,7 +10,7 @@ jobs: container: image: python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e4d27e78f1b0e9 steps: - - name: Test the exact commit + - name: Run provider and directory-contract tests run: | set -eu mkdir -p identity-source diff --git a/.forgejo/workflows/identity-provisioner-image.yaml b/.forgejo/workflows/identity-provisioner-image.yaml new file mode 100644 index 0000000..0038095 --- /dev/null +++ b/.forgejo/workflows/identity-provisioner-image.yaml @@ -0,0 +1,54 @@ +name: Build and Publish identity-provisioner + +# Fleet image-publish pattern: build from a tarball of the pushed commit, +# never from a workstation tree. Contract tests run in identity-journeys.yaml +# on the same paths; do not promote a digest if that job failed. + +on: + push: + branches: [main] + paths: + - "identity-provisioner/**" + - ".forgejo/workflows/identity-provisioner-image.yaml" + workflow_dispatch: + +env: + REGISTRY: forgejo.coulomb.social + IMAGE_NAME: coulomb/identity-provisioner + DOCKER_HOST: tcp://127.0.0.1:2375 + +jobs: + build-and-push: + runs-on: container-build + steps: + - name: Build and push image + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -eu + REF="${GITHUB_SHA:-main}" + SHORT="${REF:0:7}" + mkdir -p buildctx "${HOME}/bin" + wget -qO /tmp/repo.tar.gz \ + "https://forgejo.coulomb.social/${GITHUB_REPOSITORY}/archive/${SHORT}.tar.gz" + tar xzf /tmp/repo.tar.gz -C buildctx --strip-components=1 + wget -qO- https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \ + | tar xz --strip-components=1 -C "${HOME}/bin" docker/docker + export PATH="${HOME}/bin:${PATH}" + echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin + IMAGE="${REGISTRY}/${IMAGE_NAME}" + docker build -f buildctx/identity-provisioner/Containerfile \ + -t "${IMAGE}:latest" -t "${IMAGE}:main-${SHORT}" \ + buildctx/identity-provisioner + docker push "${IMAGE}:latest" + docker push "${IMAGE}:main-${SHORT}" + echo "pushed ${IMAGE}:latest and ${IMAGE}:main-${SHORT}" + + - name: Report immutable digest + run: | + set -eu + export PATH="${HOME}/bin:${PATH}" + IMAGE="${REGISTRY}/${IMAGE_NAME}" + SHORT="${GITHUB_SHA:0:7}" + docker inspect --format='{{index .RepoDigests 0}}' "${IMAGE}:main-${SHORT}" diff --git a/docs/identity-provisioner-bind-repair.md b/docs/identity-provisioner-bind-repair.md index 143c6b6..1c8731b 100644 --- a/docs/identity-provisioner-bind-repair.md +++ b/docs/identity-provisioner-bind-repair.md @@ -70,7 +70,10 @@ User Engine's display name is not necessarily its directory username: the current provider derives a name from email unless preferred_username is sent. Keep the requested demo login-name mapping explicit before provisioning. -Follow-up remains necessary for credential custody/publication and a functional -provisioner preflight: the current /healthz confirms process health while the -directory connection is broken. The HTTP handler also fails to catch the -upstream HTTPError. These are tracked in NK-WP-0036-T05. +NK-WP-0036-T05 adds `/readyz` as a contained provisioner-to-directory preflight +(one login plus one directory read), maps bind failures to a redacted +`dependency_unavailable` JSON body, and keeps `/healthz` as process liveness. +Auth rejection is not retried during a cooldown so kube probes cannot hammer a +rejected password. Synthetic contract tests run in identity-journeys CI. +Promotion still requires the immutable image digest, switching readiness to +`/readyz`, and a native onboarding journey; see the T05 workplan note. diff --git a/identity-provisioner/password_setup.py b/identity-provisioner/password_setup.py index d5f68cf..81a255a 100644 --- a/identity-provisioner/password_setup.py +++ b/identity-provisioner/password_setup.py @@ -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( diff --git a/identity-provisioner/provisioner.py b/identity-provisioner/provisioner.py index 02f31e2..10a742a 100644 --- a/identity-provisioner/provisioner.py +++ b/identity-provisioner/provisioner.py @@ -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: diff --git a/identity-provisioner/server.py b/identity-provisioner/server.py index 7a85fad..a86a949 100644 --- a/identity-provisioner/server.py +++ b/identity-provisioner/server.py @@ -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)) diff --git a/identity-provisioner/tests/test_directory_contract.py b/identity-provisioner/tests/test_directory_contract.py new file mode 100644 index 0000000..5f5dfb8 --- /dev/null +++ b/identity-provisioner/tests/test_directory_contract.py @@ -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)) diff --git a/sso-mfa/k8s/identity-provisioner/deployment.yaml b/sso-mfa/k8s/identity-provisioner/deployment.yaml index baa404d..e7e6dc0 100644 --- a/sso-mfa/k8s/identity-provisioner/deployment.yaml +++ b/sso-mfa/k8s/identity-provisioner/deployment.yaml @@ -45,6 +45,9 @@ spec: resources: requests: {cpu: 25m, memory: 32Mi} limits: {cpu: 250m, memory: 128Mi} + # /healthz is process liveness only. After the T05 image is pinned, + # switch readiness to /readyz (timeoutSeconds >= 5) so a rejected + # directory bind takes the pod out of Service endpoints. readinessProbe: httpGet: {path: /healthz, port: http} periodSeconds: 10 diff --git a/workplans/NK-WP-0036-restore-user-portal-client-registration.md b/workplans/NK-WP-0036-restore-user-portal-client-registration.md index 8c2492f..769049f 100644 --- a/workplans/NK-WP-0036-restore-user-portal-client-registration.md +++ b/workplans/NK-WP-0036-restore-user-portal-client-registration.md @@ -8,7 +8,7 @@ status: active owner: the-custodian topic_slug: netkingdom created: "2026-09-11" -updated: "2026-09-12" +updated: "2026-09-14" related: [KEY-WP-0007, RAPPS-WP-0014, VERGABE-WP-0019] state_hub_workstream_id: "6e1358d6-87e4-52e7-b3dd-09abdc48cefc" --- @@ -142,7 +142,7 @@ to export another live Secret. Retain NK-WP-0033's separate incident residuals. ```task id: NK-WP-0036-T05 -status: todo +status: progress priority: high state_hub_task_id: "46d33ab6-d76b-537f-8d60-32c4451675b5" ``` @@ -156,6 +156,27 @@ single native onboarding journey. Preserve credential secrecy and avoid unbounded password-check retries. Actual demo users and application admission remain RAPPS-WP-0014 and VERGABE-WP-0019. +2026-09-14 agent implementation (not yet done): source now maps directory +HTTPError/URLError to redacted `{"error":"dependency_unavailable", +"dependency":"directory","reason":"auth_rejected|unreachable|protocol_error"}`. +`GET /healthz` remains process liveness; `GET /readyz` runs one login plus one +directory read. Auth rejection is cached for 30s so probes do not retry a +rejected password unbounded. 28 provider tests pass locally, including the new +directory-contract suite. identity-journeys CI discovers those tests; +identity-provisioner-image.yaml is the immutable publish lane. + +Remaining operator steps before T05 can be marked done: +1. Push this commit to `main` so identity-journeys and identity-provisioner-image + run. Confirm journeys green, then record the image digest from + `coulomb/identity-provisioner`. +2. Pin that digest in `sso-mfa/k8s/identity-provisioner/deployment.yaml`, switch + readiness to `/readyz` with `timeoutSeconds: 5` or higher, keep liveness on + `/healthz`, apply, and confirm Ready 1/1 with `/readyz` returning + `{"status":"ready","dependency":"directory"}`. +3. Verify one native onboarding journey: provider login, user create/linkage, + password setup. Do not retry a rejected password in a loop. Demo users and + application admission stay RAPPS-WP-0014 and VERGABE-WP-0019. + ## Admit the canonical users hostname and preserve callback validation