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

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'"),
]