2026-07-27 22:45:42 +02:00
|
|
|
"""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
|
2026-07-28 01:22:55 +02:00
|
|
|
from urllib.parse import parse_qs, urlencode
|
2026-07-27 22:45:42 +02:00
|
|
|
|
|
|
|
|
from user_engine.domain import AccountStatus
|
|
|
|
|
from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
|
2026-07-28 00:06:21 +02:00
|
|
|
from user_engine.oidc import OIDCClient, cookie_value
|
2026-07-28 00:57:04 +02:00
|
|
|
from user_engine.ports import IdentityProvisioningPort, ProvisioningRequest
|
2026-07-27 22:45:42 +02:00
|
|
|
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,
|
2026-07-28 00:06:21 +02:00
|
|
|
oidc_client: OIDCClient | None = None,
|
2026-07-28 00:57:04 +02:00
|
|
|
provisioning: IdentityProvisioningPort | None = None,
|
2026-07-27 22:45:42 +02:00
|
|
|
) -> 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
|
2026-07-28 00:06:21 +02:00
|
|
|
self.oidc_client = oidc_client
|
2026-07-28 00:57:04 +02:00
|
|
|
self.provisioning = provisioning
|
2026-07-27 22:45:42 +02:00
|
|
|
|
|
|
|
|
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))
|
2026-07-28 00:06:21 +02:00
|
|
|
except (ValidationError, ConflictError, ValueError) as exc:
|
2026-07-27 22:45:42 +02:00
|
|
|
return self._error(start_response, "400 Bad Request", "invalid_request", str(exc), correlation_id)
|
2026-07-28 01:22:55 +02:00
|
|
|
except RuntimeError:
|
|
|
|
|
return self._error(
|
|
|
|
|
start_response,
|
|
|
|
|
"502 Bad Gateway",
|
|
|
|
|
"provisioning_unavailable",
|
|
|
|
|
"Identity provisioning is temporarily unavailable.",
|
|
|
|
|
correlation_id,
|
|
|
|
|
)
|
2026-07-27 22:45:42 +02:00
|
|
|
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)
|
2026-07-29 23:52:06 +02:00
|
|
|
if path == "/metrics":
|
|
|
|
|
supplied = str(environ.get("HTTP_X_USER_ENGINE_PROXY_SECRET", ""))
|
|
|
|
|
if not secrets.compare_digest(supplied, self.trusted_proxy_secret):
|
|
|
|
|
raise AuthorizationDenied("metrics require the trusted workload marker")
|
|
|
|
|
return self._metrics(start_response, correlation_id)
|
2026-07-28 00:06:21 +02:00
|
|
|
if path in {"/login", "/oidc/start"}:
|
|
|
|
|
location = self.oidc_client.begin() if self.oidc_client else self.login_url
|
|
|
|
|
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
|
|
|
|
|
return [b""]
|
|
|
|
|
if path == "/oidc/callback":
|
|
|
|
|
if self.oidc_client is None:
|
|
|
|
|
raise NotFoundError("OIDC login is not configured")
|
|
|
|
|
query = parse_qs(str(environ.get("QUERY_STRING", "")))
|
|
|
|
|
if query.get("error"):
|
|
|
|
|
raise AuthorizationDenied("OIDC login failed")
|
|
|
|
|
session_id = self.oidc_client.complete(
|
|
|
|
|
code=query.get("code", [""])[0],
|
|
|
|
|
state=query.get("state", [""])[0],
|
|
|
|
|
)
|
|
|
|
|
headers = [
|
|
|
|
|
("Location", "/"),
|
|
|
|
|
("Set-Cookie", f"ue_session={session_id}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600"),
|
|
|
|
|
*self._security_headers(correlation_id),
|
|
|
|
|
]
|
|
|
|
|
start_response("303 See Other", headers)
|
|
|
|
|
return [b""]
|
|
|
|
|
if path == "/logout" and method == "POST":
|
|
|
|
|
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
|
|
|
|
|
if session_id and self.oidc_client:
|
|
|
|
|
self.oidc_client.logout(session_id)
|
|
|
|
|
start_response(
|
|
|
|
|
"303 See Other",
|
|
|
|
|
[("Location", "/"), ("Set-Cookie", "ue_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"), *self._security_headers(correlation_id)],
|
|
|
|
|
)
|
2026-07-27 22:45:42 +02:00
|
|
|
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)
|
2026-07-28 00:57:04 +02:00
|
|
|
if path.startswith("/api/v1/tenants/") and path.endswith("/users") and method == "POST":
|
|
|
|
|
tenant = path.split("/")[4]
|
|
|
|
|
self.service.resolve_tenant_context(actor, tenant)
|
|
|
|
|
body = self._body(environ)
|
|
|
|
|
user = self.service.create_user(
|
|
|
|
|
actor,
|
|
|
|
|
display_name=body.get("display_name"),
|
|
|
|
|
primary_email=body.get("primary_email"),
|
|
|
|
|
correlation_id=correlation_id,
|
|
|
|
|
)
|
|
|
|
|
# Platform operators may create an identity for a tenant other than
|
|
|
|
|
# their own. Ensure the lifecycle record follows the requested
|
|
|
|
|
# tenant instead of only retaining the actor tenant created by the
|
|
|
|
|
# generic domain operation.
|
|
|
|
|
tenant_account = self.service.set_tenant_account_status(
|
|
|
|
|
actor,
|
|
|
|
|
user.user_id,
|
|
|
|
|
AccountStatus.ACTIVE,
|
|
|
|
|
tenant=tenant,
|
|
|
|
|
correlation_id=correlation_id,
|
|
|
|
|
)
|
|
|
|
|
membership = self.service.add_membership(
|
|
|
|
|
actor,
|
|
|
|
|
user.user_id,
|
|
|
|
|
tenant=tenant,
|
|
|
|
|
scope_type="tenant",
|
|
|
|
|
scope_id=tenant,
|
|
|
|
|
kind=str(body.get("role", "user")),
|
|
|
|
|
correlation_id=correlation_id,
|
|
|
|
|
)
|
|
|
|
|
return self._json(start_response, "201 Created", {
|
|
|
|
|
"user": _jsonable(user),
|
|
|
|
|
"tenant_account": _jsonable(tenant_account),
|
|
|
|
|
"membership": _jsonable(membership),
|
|
|
|
|
"provisioning_status": "pending",
|
|
|
|
|
}, correlation_id)
|
|
|
|
|
if path.startswith("/api/v1/tenants/") and path.endswith("/provision") and method == "POST":
|
|
|
|
|
if self.provisioning is None:
|
|
|
|
|
raise ValidationError("identity provisioning is unavailable")
|
|
|
|
|
parts = path.split("/")
|
|
|
|
|
tenant, user_id = parts[4], parts[6]
|
|
|
|
|
self.service.resolve_tenant_context(actor, tenant)
|
|
|
|
|
user = self.service.store.user(user_id)
|
|
|
|
|
if user is None:
|
|
|
|
|
raise NotFoundError("user not found")
|
|
|
|
|
idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", ""))
|
|
|
|
|
if len(idempotency_key) < 16:
|
|
|
|
|
raise ValidationError("Idempotency-Key must contain at least 16 characters")
|
|
|
|
|
result = self.provisioning.provision(ProvisioningRequest(
|
|
|
|
|
user_id=user.user_id,
|
|
|
|
|
tenant=tenant,
|
|
|
|
|
primary_email=user.primary_email,
|
|
|
|
|
display_name=user.display_name,
|
|
|
|
|
idempotency_key=idempotency_key,
|
|
|
|
|
correlation_id=correlation_id,
|
|
|
|
|
roles=tuple(
|
|
|
|
|
membership.kind
|
|
|
|
|
for membership in self.service.store.memberships_for_user(
|
|
|
|
|
user.user_id, tenant=tenant
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
))
|
2026-07-28 01:22:55 +02:00
|
|
|
identity = self.service.link_identity(
|
|
|
|
|
actor,
|
|
|
|
|
user.user_id,
|
|
|
|
|
issuer="urn:netkingdom:directory",
|
|
|
|
|
subject=result.external_subject,
|
|
|
|
|
provider=result.provider,
|
|
|
|
|
correlation_id=correlation_id,
|
|
|
|
|
)
|
|
|
|
|
return self._json(start_response, "200 OK", {
|
|
|
|
|
"provisioning": _jsonable(result),
|
|
|
|
|
"identity": _jsonable(identity),
|
|
|
|
|
}, correlation_id)
|
2026-07-27 22:45:42 +02:00
|
|
|
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"]))
|
2026-07-28 01:22:55 +02:00
|
|
|
idempotency_key = str(environ.get("HTTP_IDEMPOTENCY_KEY", ""))
|
|
|
|
|
if len(idempotency_key) < 16:
|
|
|
|
|
raise ValidationError("Idempotency-Key must contain at least 16 characters")
|
|
|
|
|
result = self._change_status(
|
|
|
|
|
actor, tenant, user_id, status,
|
|
|
|
|
idempotency_key=idempotency_key,
|
|
|
|
|
correlation_id=correlation_id,
|
2026-07-27 22:45:42 +02:00
|
|
|
)
|
|
|
|
|
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)
|
2026-07-28 01:22:55 +02:00
|
|
|
return self._html(
|
|
|
|
|
start_response,
|
|
|
|
|
self._admin(tenant, memberships, self._csrf_token(environ)),
|
|
|
|
|
correlation_id,
|
|
|
|
|
)
|
|
|
|
|
if path.startswith("/admin/") and method == "POST":
|
|
|
|
|
parts = path.split("/")
|
|
|
|
|
tenant = parts[2]
|
|
|
|
|
self.service.resolve_tenant_context(actor, tenant)
|
|
|
|
|
body = self._form_body(environ)
|
|
|
|
|
self._require_csrf(environ, str(body.get("csrf_token", "")))
|
|
|
|
|
if len(parts) == 4 and parts[3] == "users":
|
|
|
|
|
user = self.service.create_user(
|
|
|
|
|
actor,
|
|
|
|
|
display_name=body.get("display_name"),
|
|
|
|
|
primary_email=body.get("primary_email"),
|
|
|
|
|
correlation_id=correlation_id,
|
|
|
|
|
)
|
|
|
|
|
self.service.set_tenant_account_status(
|
|
|
|
|
actor, user.user_id, AccountStatus.ACTIVE,
|
|
|
|
|
tenant=tenant, correlation_id=correlation_id,
|
|
|
|
|
)
|
|
|
|
|
self.service.add_membership(
|
|
|
|
|
actor, user.user_id, tenant=tenant, scope_type="tenant",
|
|
|
|
|
scope_id=tenant, kind=str(body.get("role", "user")),
|
|
|
|
|
correlation_id=correlation_id,
|
|
|
|
|
)
|
|
|
|
|
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
|
|
|
|
|
if len(parts) == 6 and parts[3] == "users" and parts[5] == "provision":
|
|
|
|
|
if self.provisioning is None:
|
|
|
|
|
raise ValidationError("identity provisioning is unavailable")
|
|
|
|
|
user_id = parts[4]
|
|
|
|
|
user = self.service.store.user(user_id)
|
|
|
|
|
if user is None:
|
|
|
|
|
raise NotFoundError("user not found")
|
|
|
|
|
result = self.provisioning.provision(ProvisioningRequest(
|
|
|
|
|
user_id=user.user_id,
|
|
|
|
|
tenant=tenant,
|
|
|
|
|
primary_email=user.primary_email,
|
|
|
|
|
display_name=user.display_name,
|
|
|
|
|
idempotency_key=f"portal-{user.user_id}-{tenant}",
|
|
|
|
|
correlation_id=correlation_id,
|
|
|
|
|
roles=tuple(
|
|
|
|
|
item.kind for item in self.service.store.memberships_for_user(
|
|
|
|
|
user.user_id, tenant=tenant
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
))
|
|
|
|
|
self.service.link_identity(
|
|
|
|
|
actor, user.user_id, issuer="urn:netkingdom:directory",
|
|
|
|
|
subject=result.external_subject, provider=result.provider,
|
|
|
|
|
correlation_id=correlation_id,
|
|
|
|
|
)
|
2026-07-28 17:30:17 +02:00
|
|
|
if result.password_setup_url:
|
|
|
|
|
return self._html(
|
|
|
|
|
start_response,
|
|
|
|
|
self._password_setup_handoff(
|
|
|
|
|
result.password_setup_url, tenant
|
|
|
|
|
),
|
|
|
|
|
correlation_id,
|
|
|
|
|
)
|
2026-07-28 01:22:55 +02:00
|
|
|
query = urlencode({"provisioned": user.user_id, "status": result.status})
|
|
|
|
|
return self._redirect(start_response, f"/admin/{tenant}?{query}", correlation_id)
|
|
|
|
|
if len(parts) == 6 and parts[3] == "users" and parts[5] == "status":
|
|
|
|
|
status = AccountStatus(str(body.get("status", "")))
|
|
|
|
|
if status not in {AccountStatus.ACTIVE, AccountStatus.SUSPENDED}:
|
|
|
|
|
raise ValidationError("browser lifecycle supports active or suspended")
|
|
|
|
|
self._change_status(
|
|
|
|
|
actor, tenant, parts[4], status,
|
|
|
|
|
idempotency_key=f"portal-status-{parts[4]}-{status.value}",
|
|
|
|
|
correlation_id=correlation_id,
|
|
|
|
|
)
|
|
|
|
|
return self._redirect(start_response, f"/admin/{tenant}", correlation_id)
|
2026-07-27 22:45:42 +02:00
|
|
|
return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", correlation_id)
|
|
|
|
|
|
2026-07-28 01:22:55 +02:00
|
|
|
def _change_status(
|
|
|
|
|
self,
|
|
|
|
|
actor: Any,
|
|
|
|
|
tenant: str,
|
|
|
|
|
user_id: str,
|
|
|
|
|
status: AccountStatus,
|
|
|
|
|
*,
|
|
|
|
|
idempotency_key: str,
|
|
|
|
|
correlation_id: str,
|
|
|
|
|
) -> Any:
|
|
|
|
|
if self.provisioning is None:
|
|
|
|
|
raise ValidationError("identity provisioning is unavailable")
|
|
|
|
|
self.service.resolve_tenant_context(actor, tenant)
|
|
|
|
|
identity = next(
|
|
|
|
|
(
|
|
|
|
|
item for item in self.service.store.identities_for_user(user_id)
|
|
|
|
|
if item.provider == "netkingdom-lldap"
|
|
|
|
|
),
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
if identity is None:
|
|
|
|
|
raise ValidationError("user has no managed login identity")
|
|
|
|
|
if status == AccountStatus.SUSPENDED:
|
|
|
|
|
self.provisioning.suspend(
|
|
|
|
|
external_subject=identity.subject,
|
|
|
|
|
idempotency_key=idempotency_key,
|
|
|
|
|
correlation_id=correlation_id,
|
|
|
|
|
)
|
|
|
|
|
elif status == AccountStatus.ACTIVE:
|
|
|
|
|
self.provisioning.reactivate(
|
|
|
|
|
external_subject=identity.subject,
|
|
|
|
|
idempotency_key=idempotency_key,
|
|
|
|
|
correlation_id=correlation_id,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
raise ValidationError("provider lifecycle supports active or suspended")
|
|
|
|
|
return self.service.set_tenant_account_status(
|
|
|
|
|
actor, user_id, status, tenant=tenant, correlation_id=correlation_id
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-27 22:45:42 +02:00
|
|
|
def _claims(self, environ: Mapping[str, Any]) -> Mapping[str, Any]:
|
2026-07-28 00:06:21 +02:00
|
|
|
if self.oidc_client is not None:
|
|
|
|
|
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
|
|
|
|
|
if session_id:
|
|
|
|
|
claims = self.oidc_client.claims(session_id)
|
|
|
|
|
if claims is not None:
|
|
|
|
|
return claims
|
2026-07-27 22:45:42 +02:00
|
|
|
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
|
|
|
|
|
|
2026-07-28 01:22:55 +02:00
|
|
|
@staticmethod
|
|
|
|
|
def _form_body(environ: Mapping[str, Any]) -> Mapping[str, str]:
|
|
|
|
|
content_type = str(environ.get("CONTENT_TYPE", "")).partition(";")[0]
|
|
|
|
|
if content_type != "application/x-www-form-urlencoded":
|
|
|
|
|
raise ValidationError("form content type is required")
|
|
|
|
|
length = min(int(environ.get("CONTENT_LENGTH") or 0), 65536)
|
|
|
|
|
payload = environ["wsgi.input"].read(length).decode("utf-8")
|
|
|
|
|
return {key: values[0] for key, values in parse_qs(payload).items()}
|
|
|
|
|
|
|
|
|
|
def _csrf_token(self, environ: Mapping[str, Any]) -> str:
|
|
|
|
|
if self.oidc_client is None:
|
|
|
|
|
raise AuthorizationDenied("browser session required")
|
|
|
|
|
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
|
|
|
|
|
token = self.oidc_client.csrf_token(session_id or "")
|
|
|
|
|
if token is None:
|
|
|
|
|
raise AuthorizationDenied("browser session required")
|
|
|
|
|
return token
|
|
|
|
|
|
|
|
|
|
def _require_csrf(self, environ: Mapping[str, Any], supplied: str) -> None:
|
|
|
|
|
expected = self._csrf_token(environ)
|
|
|
|
|
if not supplied or not secrets.compare_digest(supplied, expected):
|
|
|
|
|
raise AuthorizationDenied("invalid CSRF token")
|
|
|
|
|
|
2026-07-27 22:45:42 +02:00
|
|
|
@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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-28 01:22:55 +02:00
|
|
|
def _admin(self, tenant: str, memberships: tuple[Any, ...], csrf_token: str) -> str:
|
2026-07-27 22:45:42 +02:00
|
|
|
rows = "".join(
|
2026-07-28 01:22:55 +02:00
|
|
|
self._admin_row(tenant, item, csrf_token)
|
2026-07-27 22:45:42 +02:00
|
|
|
for item in memberships
|
2026-07-28 01:22:55 +02:00
|
|
|
) or '<tr><td colspan="6">No members yet.</td></tr>'
|
2026-07-27 22:45:42 +02:00
|
|
|
return self._page_html(
|
|
|
|
|
f"{tenant} users",
|
2026-07-28 01:22:55 +02:00
|
|
|
f"""<h1>{escape(tenant)} users</h1>
|
|
|
|
|
<section><h2>Add a user</h2>
|
|
|
|
|
<form method="post" action="/admin/{escape(tenant)}/users">
|
|
|
|
|
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
|
|
|
|
<label>Display name <input name="display_name" required autocomplete="name"></label>
|
|
|
|
|
<label>Email <input name="primary_email" type="email" required autocomplete="email"></label>
|
|
|
|
|
<label>Role <select name="role"><option value="user">User</option><option value="tenant-admin">Tenant administrator</option></select></label>
|
|
|
|
|
<button type="submit">Add user</button></form></section>
|
|
|
|
|
<section><h2>Members</h2><table><thead><tr><th>User</th><th>Email</th><th>Role</th><th>Status</th><th>Directory</th><th>Action</th></tr></thead><tbody>{rows}</tbody></table></section>""",
|
2026-07-27 22:45:42 +02:00
|
|
|
)
|
|
|
|
|
|
2026-07-28 01:22:55 +02:00
|
|
|
def _admin_row(self, tenant: str, membership: Any, csrf_token: str) -> str:
|
|
|
|
|
user = self.service.store.user(membership.user_id)
|
|
|
|
|
identities = self.service.store.identities_for_user(membership.user_id)
|
|
|
|
|
directory = next(
|
|
|
|
|
(item for item in identities if item.provider == "netkingdom-lldap"),
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
tenant_account = self.service.store.tenant_account(tenant, membership.user_id)
|
|
|
|
|
status = tenant_account.status if tenant_account else AccountStatus.INVITED
|
|
|
|
|
action = (
|
|
|
|
|
f"""<span>Linked as {escape(directory.subject)}</span>
|
2026-07-28 17:33:12 +02:00
|
|
|
<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/provision">
|
|
|
|
|
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
|
|
|
|
<button type="submit">Create password setup link</button></form>
|
2026-07-28 01:22:55 +02:00
|
|
|
<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/status">
|
|
|
|
|
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
|
|
|
|
<input type="hidden" name="status" value="{'active' if status == AccountStatus.SUSPENDED else 'suspended'}">
|
|
|
|
|
<button type="submit">{'Reactivate' if status == AccountStatus.SUSPENDED else 'Suspend'}</button></form>"""
|
|
|
|
|
if directory
|
|
|
|
|
else f"""<form method="post" action="/admin/{escape(tenant)}/users/{escape(membership.user_id)}/provision">
|
|
|
|
|
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
|
|
|
|
|
<button type="submit">Create login</button></form>"""
|
|
|
|
|
)
|
|
|
|
|
return (
|
|
|
|
|
f"<tr><td>{escape(user.display_name or membership.user_id) if user else escape(membership.user_id)}</td>"
|
|
|
|
|
f"<td>{escape(user.primary_email or '') if user else ''}</td>"
|
|
|
|
|
f"<td>{escape(membership.kind)}</td>"
|
|
|
|
|
f"<td>{escape(status.value)}</td>"
|
|
|
|
|
f"<td>{'linked' if directory else 'pending'}</td><td>{action}</td></tr>"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-28 17:30:17 +02:00
|
|
|
def _password_setup_handoff(self, setup_url: str, tenant: str) -> str:
|
|
|
|
|
if not setup_url.startswith("https://"):
|
|
|
|
|
raise ValidationError("password setup handoff must use HTTPS")
|
|
|
|
|
return self._page_html(
|
|
|
|
|
"Password setup",
|
|
|
|
|
"<h1>Login identity created</h1>"
|
|
|
|
|
"<p>The password is handled only by the NetKingdom identity "
|
|
|
|
|
"surface. This short-lived link is single use.</p>"
|
|
|
|
|
f'<p><a class="button" rel="noreferrer" href="{escape(setup_url)}">'
|
|
|
|
|
"Continue to password setup</a></p>"
|
|
|
|
|
f'<p><a href="/admin/{escape(tenant)}">'
|
|
|
|
|
"Return to tenant administration</a></p>",
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-28 01:22:55 +02:00
|
|
|
def _redirect(self, start_response: StartResponse, location: str, correlation_id: str) -> list[bytes]:
|
|
|
|
|
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
|
|
|
|
|
return [b""]
|
|
|
|
|
|
2026-07-27 22:45:42 +02:00
|
|
|
@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)}}
|
2026-07-28 01:22:55 +02:00
|
|
|
section{{margin:2rem 0}}form{{display:grid;gap:.8rem;max-width:42rem}}label{{display:grid;gap:.25rem}}
|
|
|
|
|
input,select,button{{font:inherit;padding:.65rem}}button{{background:var(--accent);color:white;border:0;border-radius:.3rem;cursor:pointer}}
|
|
|
|
|
a:focus-visible,input:focus-visible,select:focus-visible,button:focus-visible{{outline:3px solid #e59f24;outline-offset:3px}}@media(max-width:640px){{body{{font-size:16px}}table{{display:block;overflow-x:auto}}}}
|
2026-07-27 22:45:42 +02:00
|
|
|
</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]
|
|
|
|
|
|
2026-07-29 23:52:06 +02:00
|
|
|
def _metrics(
|
|
|
|
|
self, start_response: StartResponse, correlation_id: str
|
|
|
|
|
) -> list[bytes]:
|
|
|
|
|
report = self.service.readiness()
|
|
|
|
|
counts = self.service.operability_snapshot().metrics
|
|
|
|
|
lines = [
|
|
|
|
|
"# HELP user_engine_ready Whether runtime dependencies are ready.",
|
|
|
|
|
"# TYPE user_engine_ready gauge",
|
|
|
|
|
f"user_engine_ready {1 if report.ready else 0}",
|
|
|
|
|
"# HELP user_engine_records Durable logical record counts by kind.",
|
|
|
|
|
"# TYPE user_engine_records gauge",
|
|
|
|
|
]
|
|
|
|
|
for kind, count in sorted(counts.items()):
|
|
|
|
|
safe_kind = "".join(
|
|
|
|
|
character for character in str(kind)
|
|
|
|
|
if character.isalnum() or character in "_-"
|
|
|
|
|
)
|
|
|
|
|
lines.append(f'user_engine_records{{kind="{safe_kind}"}} {int(count)}')
|
|
|
|
|
data = ("\n".join(lines) + "\n").encode()
|
|
|
|
|
start_response(
|
|
|
|
|
"200 OK",
|
|
|
|
|
[
|
|
|
|
|
("Content-Type", "text/plain; version=0.0.4; charset=utf-8"),
|
|
|
|
|
("Content-Length", str(len(data))),
|
|
|
|
|
*self._security_headers(correlation_id),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
return [data]
|
|
|
|
|
|
2026-07-27 22:45:42 +02:00
|
|
|
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'"),
|
|
|
|
|
]
|