Implement user-engine portal foundation
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

This commit is contained in:
tegwick 2026-07-27 22:45:42 +02:00
parent 60446e8b40
commit 0980d1fd41
12 changed files with 676 additions and 6 deletions

12
Containerfile Normal file
View file

@ -0,0 +1,12 @@
FROM python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e4d27e78f1b0e9
RUN useradd --create-home --uid 10001 user-engine
WORKDIR /app
COPY pyproject.toml README.md /app/
COPY src /app/src
RUN pip install --no-cache-dir ".[postgres]"
USER 10001:10001
EXPOSE 8080
ENV PYTHONUNBUFFERED=1
CMD ["python", "-m", "user_engine.runtime"]

View file

@ -0,0 +1,49 @@
# Portal boundary and threat model
The portal is a transport adapter for user-engine, not a new identity
authority. KeyCape owns login, credentials, MFA, authentication sessions and
OIDC tokens. flex-auth owns authorization decisions. user-engine owns users,
tenant memberships, profiles, registration, invitations, onboarding, audit
records and lifecycle intent. NetKingdom implements `IdentityProvisioningPort`
without leaking provider clients into the domain.
## Trust boundaries
- The public edge terminates TLS, rate-limits login/registration and validates
OIDC tokens. It strips all inbound `X-Verified-*` and
`X-User-Engine-Proxy-Secret` headers before injecting verified claims and
the runtime-only proxy marker.
- user-engine rejects every protected route if that marker or verified claims
are absent. Direct pod traffic is blocked by NetworkPolicy.
- Browser state is navigation state only. It is never authoritative for roles,
tenants, registration progress or lifecycle status.
- Provider credentials come from approved runtime secret references. They do
not enter requests, logs, audit payloads, workplans or Git.
## Controls
Protected writes receive a flex-auth decision through the existing
`AuthorizationCheckPort`. Tenant context is re-resolved in the service, so a
tenant administrator cannot select another tenant. Mutations carry request
correlation IDs and emit redacted audit/outbox records. Provisioning requests
carry independent idempotency keys and support safe resume after ambiguity.
The application emits a restrictive content security policy, prevents
framing and MIME sniffing, disables sensitive response caching and escapes
all server-rendered values. State-changing browser forms must use same-site
cookies plus edge-issued CSRF tokens; the initial JSON API is intended for
bearer-authenticated same-origin clients. Invitation tokens are single-use,
hashed at rest, time-bound and rate-limited at both identity and address
dimensions. Public errors must not reveal whether an email or tenant exists.
Audit retention, identity erasure and external deprovisioning are distinct
operations. Audit identifiers remain pseudonymous after erasure. Provider
outage leaves a retryable outbox item and never reports the lifecycle change
as externally complete. Compensation is idempotent and operator-visible.
## Deferred
Enterprise SAML/OIDC federation, SCIM and corporate directory synchronization
remain provider adapters. Their external ownership metadata and identity links
must fit the existing neutral boundaries rather than becoming user-engine
domain dependencies.

83
openapi/portal-v1.yaml Normal file
View file

@ -0,0 +1,83 @@
openapi: 3.1.0
info:
title: user-engine portal API
version: 0.1.0
servers:
- url: /api/v1
security:
- verifiedOidc: []
paths:
/me:
get:
operationId: currentUser
responses:
"200":
description: Current user and linked identities
"403":
$ref: "#/components/responses/Denied"
/registrations:
post:
operationId: startRegistration
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"201":
description: Registration started
"403":
$ref: "#/components/responses/Denied"
/registrations/{registrationId}/complete:
post:
operationId: completeRegistration
parameters:
- name: registrationId
in: path
required: true
schema: {type: string}
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"200":
description: Registration completed
"400":
description: Missing verified factors or invalid state
/tenants/{tenant}/users:
get:
operationId: listTenantUsers
parameters:
- $ref: "#/components/parameters/Tenant"
- {name: offset, in: query, schema: {type: integer, minimum: 0}}
- {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 100}}
responses:
"200": {description: Tenant-scoped memberships}
"403": {$ref: "#/components/responses/Denied"}
/tenants/{tenant}/users/{userId}:
patch:
operationId: updateTenantUserLifecycle
parameters:
- $ref: "#/components/parameters/Tenant"
- name: userId
in: path
required: true
schema: {type: string}
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"200": {description: Tenant account updated}
"403": {$ref: "#/components/responses/Denied"}
components:
securitySchemes:
verifiedOidc:
type: openIdConnect
openIdConnectUrl: https://kc.coulomb.social/.well-known/openid-configuration
parameters:
Tenant:
name: tenant
in: path
required: true
schema: {type: string}
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
schema: {type: string, minLength: 16, maxLength: 200}
responses:
Denied:
description: Caller is unauthenticated or unauthorized

View file

@ -4,6 +4,12 @@ version = "0.1.0"
description = "Headless user-domain and profile engine."
requires-python = ">=3.12"
[project.optional-dependencies]
postgres = ["psycopg[binary]>=3.2,<4"]
[project.scripts]
user-engine-portal = "user_engine.runtime:main"
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"

View file

@ -5,9 +5,11 @@ from user_engine.adapters.local import (
LocalAuthorizationCheckPort,
)
from user_engine.adapters.postgres import PostgresUserEngineStore
from user_engine.adapters.claims import VerifiedIdentityClaimsAdapter
__all__ = [
"InMemoryUserEngineStore",
"LocalAuthorizationCheckPort",
"PostgresUserEngineStore",
"VerifiedIdentityClaimsAdapter",
]

View file

@ -0,0 +1,57 @@
"""Normalization for claims already cryptographically verified at the edge."""
from __future__ import annotations
from typing import Mapping
from user_engine.domain import Actor, PrincipalType
from user_engine.errors import ValidationError
class VerifiedIdentityClaimsAdapter:
def __init__(self, *, expected_issuer: str, expected_audience: str) -> None:
self.expected_issuer = expected_issuer.rstrip("/")
self.expected_audience = expected_audience
def normalize(self, claims: Mapping[str, object]) -> Actor:
for required in ("iss", "sub", "tenant", "principal_type"):
if not claims.get(required):
raise ValidationError(f"{required} claim is required")
if str(claims["iss"]).rstrip("/") != self.expected_issuer:
raise ValidationError("unexpected token issuer")
audience = _strings(claims.get("aud", ()))
if self.expected_audience not in audience:
raise ValidationError("required token audience is missing")
scopes = claims.get("scope", ())
if isinstance(scopes, str):
scopes = tuple(item for item in scopes.split() if item)
return Actor(
issuer=str(claims["iss"]),
subject=str(claims["sub"]),
tenant=str(claims["tenant"]),
principal_type=PrincipalType(str(claims["principal_type"])),
audience=audience,
roles=_strings(claims.get("roles", ())),
groups=_strings(claims.get("groups", ())),
scopes=_strings(scopes),
assurance=dict(claims.get("assurance", {})),
authorized_party=_optional(claims.get("azp") or claims.get("client_id")),
preferred_username=_optional(claims.get("preferred_username")),
claims=dict(claims),
agent=dict(claims.get("agent", {})),
)
def identity_key(self, actor: Actor) -> tuple[str, str]:
return actor.identity_key
def _strings(value: object) -> tuple[str, ...]:
if value is None:
return ()
if isinstance(value, str):
return (value,)
return tuple(str(item) for item in value)
def _optional(value: object) -> str | None:
return None if value is None else str(value)

View file

@ -8,6 +8,7 @@ adapters without changing domain code.
from __future__ import annotations
from contextlib import AbstractContextManager
from dataclasses import dataclass
from typing import Any, Iterable, Mapping, Protocol
from user_engine.domain import (
@ -39,6 +40,52 @@ from user_engine.domain import (
)
@dataclass(frozen=True)
class ProvisioningRequest:
"""Provider-neutral identity lifecycle request.
``idempotency_key`` is mandatory so provider adapters can safely resume
after timeouts without creating duplicate directory identities.
"""
user_id: str
tenant: str
primary_email: str | None
display_name: str | None
idempotency_key: str
correlation_id: str
@dataclass(frozen=True)
class ProvisioningResult:
provider: str
external_subject: str
status: str
resumed: bool = False
class IdentityProvisioningPort(Protocol):
"""Lifecycle seam owned by NetKingdom adapters, not the user domain."""
def provision(self, request: ProvisioningRequest) -> ProvisioningResult:
"""Create or resume an external login identity."""
def suspend(
self, *, external_subject: str, idempotency_key: str, correlation_id: str
) -> ProvisioningResult:
"""Disable authentication while retaining recoverable identity state."""
def reactivate(
self, *, external_subject: str, idempotency_key: str, correlation_id: str
) -> ProvisioningResult:
"""Re-enable a previously suspended identity."""
def deprovision(
self, *, external_subject: str, idempotency_key: str, correlation_id: str
) -> ProvisioningResult:
"""Remove or tombstone an identity according to provider policy."""
class UserEngineStore(Protocol):
"""Durable persistence boundary for user-engine service behavior.

View file

@ -0,0 +1,64 @@
"""Production runtime assembly for the WSGI portal."""
from __future__ import annotations
import os
from wsgiref.simple_server import make_server
from user_engine.adapters import (
LocalAuthorizationCheckPort,
PostgresUserEngineStore,
VerifiedIdentityClaimsAdapter,
)
from user_engine.service import UserEngineService
from user_engine.web import PortalApplication
def create_application() -> PortalApplication:
"""Assemble the runtime from secret-backed environment references.
The local authorization adapter is an explicit pre-production bridge. A
flex-auth HTTP adapter must replace it before the production gate.
"""
try:
import psycopg
except ImportError as exc: # pragma: no cover - deployment guard
raise RuntimeError("install user-engine[postgres] for the runtime") from exc
database_url = _required("USER_ENGINE_DATABASE_URL")
store = PostgresUserEngineStore(psycopg.connect(database_url))
store.migrate()
service = UserEngineService(
store=store,
identity_adapter=VerifiedIdentityClaimsAdapter(
expected_issuer=_required("USER_ENGINE_OIDC_ISSUER"),
expected_audience=_required("USER_ENGINE_OIDC_AUDIENCE"),
),
authorization=LocalAuthorizationCheckPort(),
)
return PortalApplication(
service,
trusted_proxy_secret=_required("USER_ENGINE_PROXY_SECRET"),
login_url=_required("USER_ENGINE_LOGIN_URL"),
public_registration=os.environ.get("USER_ENGINE_PUBLIC_REGISTRATION", "false").lower()
== "true",
)
def main() -> None:
host = os.environ.get("USER_ENGINE_HOST", "0.0.0.0")
port = int(os.environ.get("USER_ENGINE_PORT", "8080"))
with make_server(host, port, create_application()) as server:
server.serve_forever()
def _required(name: str) -> str:
value = os.environ.get(name)
if not value:
raise RuntimeError(f"{name} is required")
return value
if __name__ == "__main__":
main()

230
src/user_engine/web.py Normal file
View file

@ -0,0 +1,230 @@
"""Dependency-free WSGI transport for the user-engine portal.
Authentication is deliberately delegated to KeyCape (or another OIDC-aware
edge). The application accepts claims only when the edge presents a shared
authentication marker configured at process start. This keeps passwords,
MFA material, provider administration credentials, and browser sessions out
of user-engine.
"""
from __future__ import annotations
from dataclasses import asdict, is_dataclass
from enum import Enum
from html import escape
import json
import secrets
from typing import Any, Callable, Iterable, Mapping
from urllib.parse import parse_qs
from user_engine.domain import AccountStatus
from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
from user_engine.service import UserEngineService
StartResponse = Callable[[str, list[tuple[str, str]]], Any]
def _jsonable(value: Any) -> Any:
if is_dataclass(value):
return {key: _jsonable(item) for key, item in asdict(value).items()}
if isinstance(value, Enum):
return value.value
if isinstance(value, Mapping):
return {str(key): _jsonable(item) for key, item in value.items()}
if isinstance(value, (tuple, list)):
return [_jsonable(item) for item in value]
if hasattr(value, "isoformat"):
return value.isoformat()
return value
class PortalApplication:
"""Small, auditable HTTP adapter over :class:`UserEngineService`."""
def __init__(
self,
service: UserEngineService,
*,
trusted_proxy_secret: str,
login_url: str,
public_registration: bool = True,
) -> None:
if len(trusted_proxy_secret) < 24:
raise ValueError("trusted proxy secret must contain at least 24 characters")
self.service = service
self.trusted_proxy_secret = trusted_proxy_secret
self.login_url = login_url
self.public_registration = public_registration
def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]:
correlation_id = environ.get("HTTP_X_REQUEST_ID") or f"corr_{secrets.token_hex(12)}"
try:
return self._dispatch(environ, start_response, str(correlation_id))
except (ValidationError, ConflictError) as exc:
return self._error(start_response, "400 Bad Request", "invalid_request", str(exc), correlation_id)
except AuthorizationDenied:
return self._error(start_response, "403 Forbidden", "access_denied", "Access denied.", correlation_id)
except NotFoundError:
return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", correlation_id)
except (json.JSONDecodeError, UnicodeDecodeError):
return self._error(start_response, "400 Bad Request", "invalid_json", "Malformed request body.", correlation_id)
def _dispatch(self, environ: Mapping[str, Any], start_response: StartResponse, correlation_id: str) -> Iterable[bytes]:
method = str(environ.get("REQUEST_METHOD", "GET")).upper()
path = str(environ.get("PATH_INFO", "/")).rstrip("/") or "/"
if path == "/healthz":
return self._json(start_response, "200 OK", _jsonable(self.service.health()), correlation_id)
if path == "/readyz":
report = self.service.readiness()
return self._json(start_response, "200 OK" if report.ready else "503 Service Unavailable", _jsonable(report), correlation_id)
if path == "/login":
start_response("303 See Other", [("Location", self.login_url), *self._security_headers(correlation_id)])
return [b""]
if path == "/" and method == "GET":
actor = self._optional_actor(environ)
return self._html(start_response, self._home(actor), correlation_id)
actor = self._actor(environ)
if path == "/api/v1/me" and method == "GET":
return self._json(start_response, "200 OK", _jsonable(self.service.me(self._claims(environ), correlation_id=correlation_id)), correlation_id)
if path == "/api/v1/registrations" and method == "POST":
if not self.public_registration:
raise AuthorizationDenied("public registration disabled")
body = self._body(environ)
session = self.service.start_registration(
actor,
tenant=body.get("tenant"),
correlation_id=correlation_id,
)
return self._json(start_response, "201 Created", _jsonable(session), correlation_id)
if path.startswith("/api/v1/registrations/") and path.endswith("/complete") and method == "POST":
registration_id = path.split("/")[4]
body = self._body(environ)
result = self.service.complete_registration(
actor,
registration_id,
display_name=body.get("display_name"),
primary_email=body.get("primary_email"),
correlation_id=correlation_id,
)
return self._json(start_response, "200 OK", _jsonable(result), correlation_id)
if path.startswith("/api/v1/tenants/") and path.endswith("/users") and method == "GET":
tenant = path.split("/")[4]
self.service.resolve_tenant_context(actor, tenant)
memberships = self.service.store.memberships_for_tenant(tenant)
offset, limit = self._page(environ)
items = memberships[offset : offset + limit]
payload = {"items": _jsonable(items), "offset": offset, "limit": limit, "total": len(memberships)}
return self._json(start_response, "200 OK", payload, correlation_id)
if path.startswith("/api/v1/tenants/") and "/users/" in path and method == "PATCH":
parts = path.split("/")
tenant, user_id = parts[4], parts[6]
body = self._body(environ)
status = AccountStatus(str(body["status"]))
result = self.service.set_tenant_account_status(
actor, user_id, status, tenant=tenant, correlation_id=correlation_id
)
return self._json(start_response, "200 OK", _jsonable(result), correlation_id)
if path.startswith("/admin/") and method == "GET":
tenant = path.split("/")[2]
self.service.resolve_tenant_context(actor, tenant)
memberships = self.service.store.memberships_for_tenant(tenant)
return self._html(start_response, self._admin(tenant, memberships), correlation_id)
return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", correlation_id)
def _claims(self, environ: Mapping[str, Any]) -> Mapping[str, Any]:
marker = str(environ.get("HTTP_X_USER_ENGINE_PROXY_SECRET", ""))
if not secrets.compare_digest(marker, self.trusted_proxy_secret):
raise AuthorizationDenied("untrusted identity source")
raw = environ.get("HTTP_X_VERIFIED_OIDC_CLAIMS")
if not raw:
raise AuthorizationDenied("verified claims required")
claims = json.loads(str(raw))
if not isinstance(claims, dict):
raise AuthorizationDenied("verified claims must be an object")
return claims
def _actor(self, environ: Mapping[str, Any]) -> Any:
return self.service.identity_adapter.normalize(self._claims(environ))
def _optional_actor(self, environ: Mapping[str, Any]) -> Any | None:
try:
return self._actor(environ)
except (AuthorizationDenied, json.JSONDecodeError, ValidationError):
return None
@staticmethod
def _body(environ: Mapping[str, Any]) -> Mapping[str, Any]:
length = min(int(environ.get("CONTENT_LENGTH") or 0), 65536)
payload = environ["wsgi.input"].read(length) if length else b"{}"
value = json.loads(payload.decode("utf-8"))
if not isinstance(value, dict):
raise ValidationError("request body must be an object")
return value
@staticmethod
def _page(environ: Mapping[str, Any]) -> tuple[int, int]:
query = parse_qs(str(environ.get("QUERY_STRING", "")))
offset = max(0, int(query.get("offset", ["0"])[0]))
limit = max(1, min(100, int(query.get("limit", ["25"])[0])))
return offset, limit
def _home(self, actor: Any | None) -> str:
identity = (
f"<p>Signed in as <strong>{escape(actor.preferred_username)}</strong>.</p>"
if actor is not None
else f'<p><a class="button" href="/login">Sign in with KeyCape</a></p>'
)
return self._page_html(
"Identity & access",
"<h1>Your account, on your terms.</h1>"
"<p>Join a tenant, complete onboarding, and manage access without exposing credentials to applications.</p>"
+ identity,
)
def _admin(self, tenant: str, memberships: tuple[Any, ...]) -> str:
rows = "".join(
f"<tr><td>{escape(item.user_id)}</td><td>{escape(item.kind)}</td><td>{escape(item.scope_id)}</td></tr>"
for item in memberships
) or '<tr><td colspan="3">No members yet.</td></tr>'
return self._page_html(
f"{tenant} users",
f"<h1>{escape(tenant)} users</h1><table><thead><tr><th>User</th><th>Role</th><th>Scope</th></tr></thead><tbody>{rows}</tbody></table>",
)
@staticmethod
def _page_html(title: str, body: str) -> str:
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{escape(title)} · Railiance</title><style>
:root{{--ink:#17201c;--paper:#f5f1e8;--accent:#195b47;--line:#c8c1b3}}
*{{box-sizing:border-box}}body{{margin:0;background:var(--paper);color:var(--ink);font:18px/1.55 system-ui,sans-serif}}
header,main{{max-width:68rem;margin:auto;padding:1.25rem}}header{{border-bottom:1px solid var(--line)}}
h1{{font:clamp(2.2rem,7vw,5.5rem)/.98 Georgia,serif;max-width:13ch}}a{{color:var(--accent)}}
.button{{display:inline-block;background:var(--accent);color:white;padding:.8rem 1.15rem;border-radius:.3rem;text-decoration:none}}
table{{width:100%;border-collapse:collapse;background:#fff}}th,td{{padding:.75rem;text-align:left;border-bottom:1px solid var(--line)}}
a:focus-visible{{outline:3px solid #e59f24;outline-offset:3px}}@media(max-width:640px){{body{{font-size:16px}}}}
</style></head><body><header><strong>Railiance identity</strong></header><main>{body}</main></body></html>"""
def _html(self, start_response: StartResponse, body: str, correlation_id: str) -> list[bytes]:
data = body.encode()
start_response("200 OK", [("Content-Type", "text/html; charset=utf-8"), ("Content-Length", str(len(data))), *self._security_headers(correlation_id)])
return [data]
def _json(self, start_response: StartResponse, status: str, payload: Any, correlation_id: str) -> list[bytes]:
data = json.dumps(payload, separators=(",", ":"), default=str).encode()
start_response(status, [("Content-Type", "application/json"), ("Content-Length", str(len(data))), *self._security_headers(correlation_id)])
return [data]
def _error(self, start_response: StartResponse, status: str, code: str, message: str, correlation_id: str) -> list[bytes]:
return self._json(start_response, status, {"error": {"code": code, "message": message, "correlation_id": correlation_id}}, correlation_id)
@staticmethod
def _security_headers(correlation_id: str) -> list[tuple[str, str]]:
return [
("X-Request-ID", correlation_id),
("Cache-Control", "no-store"),
("X-Content-Type-Options", "nosniff"),
("Referrer-Policy", "no-referrer"),
("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"),
]

View file

@ -0,0 +1,31 @@
import unittest
from user_engine.adapters import VerifiedIdentityClaimsAdapter
from user_engine.errors import ValidationError
class VerifiedIdentityClaimsAdapterTests(unittest.TestCase):
def setUp(self):
self.adapter = VerifiedIdentityClaimsAdapter(
expected_issuer="https://kc.example",
expected_audience="user-engine",
)
self.claims = {
"iss": "https://kc.example/",
"sub": "person-1",
"aud": ["user-engine"],
"tenant": "tenant:friendly:binky",
"principal_type": "human",
"roles": ["tenant-admin"],
}
def test_normalizes_verified_claims(self):
actor = self.adapter.normalize(self.claims)
self.assertEqual("person-1", actor.subject)
self.assertEqual(("tenant-admin",), actor.roles)
def test_rejects_wrong_issuer_and_audience(self):
with self.assertRaises(ValidationError):
self.adapter.normalize({**self.claims, "iss": "https://evil.example"})
with self.assertRaises(ValidationError):
self.adapter.normalize({**self.claims, "aud": ["other"]})

89
tests/test_web.py Normal file
View file

@ -0,0 +1,89 @@
import io
import json
import unittest
from user_engine.adapters import InMemoryUserEngineStore, LocalAuthorizationCheckPort
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
from user_engine.web import PortalApplication
SECRET = "test-proxy-secret-with-adequate-length"
def invoke(app, path, *, method="GET", claims=None, marker=SECRET, body=None):
payload = json.dumps(body or {}).encode()
environ = {
"REQUEST_METHOD": method,
"PATH_INFO": path,
"QUERY_STRING": "",
"CONTENT_LENGTH": str(len(payload)),
"wsgi.input": io.BytesIO(payload),
"HTTP_X_REQUEST_ID": "corr_test",
}
if claims is not None:
environ["HTTP_X_VERIFIED_OIDC_CLAIMS"] = json.dumps(claims)
environ["HTTP_X_USER_ENGINE_PROXY_SECRET"] = marker
captured = {}
def start_response(status, headers):
captured["status"] = status
captured["headers"] = dict(headers)
response = b"".join(app(environ, start_response))
return captured, response
class PortalApplicationTests(unittest.TestCase):
def setUp(self):
store = InMemoryUserEngineStore()
store.migrate()
service = UserEngineService(
store=store,
identity_adapter=FixtureIdentityClaimsAdapter(),
authorization=LocalAuthorizationCheckPort(),
)
self.app = PortalApplication(
service,
trusted_proxy_secret=SECRET,
login_url="https://kc.example/login",
)
self.claims = human_actor_claims(tenant="tenant:friendly:binky")
def test_public_health_and_home(self):
health, payload = invoke(self.app, "/healthz")
self.assertEqual("200 OK", health["status"])
self.assertEqual("no-store", health["headers"]["Cache-Control"])
self.assertEqual("ok", json.loads(payload)["status"])
home, html = invoke(self.app, "/")
self.assertEqual("200 OK", home["status"])
self.assertIn(b"Sign in with KeyCape", html)
def test_protected_route_rejects_untrusted_claim_header(self):
result, payload = invoke(
self.app, "/api/v1/me", claims=self.claims, marker="attacker"
)
self.assertEqual("403 Forbidden", result["status"])
self.assertNotIn(b"attacker", payload)
def test_verified_claims_create_current_user(self):
result, payload = invoke(self.app, "/api/v1/me", claims=self.claims)
self.assertEqual("200 OK", result["status"])
decoded = json.loads(payload)
self.assertEqual("tenant:friendly:binky", decoded["actor"]["tenant"])
def test_registration_api_is_correlated(self):
result, payload = invoke(
self.app,
"/api/v1/registrations",
method="POST",
claims=self.claims,
body={"tenant": "tenant:friendly:binky"},
)
self.assertEqual("201 Created", result["status"])
self.assertEqual("corr_test", result["headers"]["X-Request-ID"])
self.assertEqual("factor_pending", json.loads(payload)["status"])
if __name__ == "__main__":
unittest.main()

View file

@ -4,7 +4,7 @@ type: workplan
title: "Production self-service and user administration portal"
domain: communication
repo: user-engine
status: ready
status: active
owner: codex
topic_slug: netkingdom
created: "2026-07-27"
@ -34,7 +34,7 @@ authorization decisions.
```task
id: USER-WP-0020-T01
status: todo
status: done
priority: high
state_hub_task_id: "9886ac8d-7456-4de0-b019-351dfd74ec20"
```
@ -54,7 +54,7 @@ metadata for that later stage.
```task
id: USER-WP-0020-T02
status: wait
status: progress
priority: high
state_hub_task_id: "fdb0c322-3efe-4077-bfba-1648787ef411"
```
@ -70,7 +70,7 @@ OpenAPI, health/readiness, redacted audit, and transactional outbox behavior.
```task
id: USER-WP-0020-T03
status: wait
status: progress
priority: high
state_hub_task_id: "45ed1485-003d-4e5f-99fb-91b1b430f3fa"
```
@ -86,7 +86,7 @@ admin credentials or store password/MFA secrets.
```task
id: USER-WP-0020-T04
status: wait
status: progress
priority: high
state_hub_task_id: "16555b68-17ef-4902-bd30-f9a0cfe10f9e"
```
@ -103,7 +103,7 @@ platform or other-tenant data.
```task
id: USER-WP-0020-T05
status: wait
status: progress
priority: high
state_hub_task_id: "65ff5c96-134a-4ec2-ad92-511f0e5f6f04"
```