Implement user-engine portal foundation
This commit is contained in:
parent
60446e8b40
commit
0980d1fd41
12 changed files with 676 additions and 6 deletions
12
Containerfile
Normal file
12
Containerfile
Normal 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"]
|
||||
49
docs/portal-threat-model.md
Normal file
49
docs/portal-threat-model.md
Normal 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
83
openapi/portal-v1.yaml
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
57
src/user_engine/adapters/claims.py
Normal file
57
src/user_engine/adapters/claims.py
Normal 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)
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
64
src/user_engine/runtime.py
Normal file
64
src/user_engine/runtime.py
Normal 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
230
src/user_engine/web.py
Normal 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'"),
|
||||
]
|
||||
31
tests/test_verified_claims.py
Normal file
31
tests/test_verified_claims.py
Normal 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
89
tests/test_web.py
Normal 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()
|
||||
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue