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

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

View file

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

View file

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

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

View file

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

View file

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