Implement user-engine portal foundation
This commit is contained in:
parent
60446e8b40
commit
d9d49c9c90
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:fd10a15414a5b0a829ebdb78573a84e244fcb3857c5f4c2ec218963440ce9ea2
|
||||||
|
|
||||||
|
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."
|
description = "Headless user-domain and profile engine."
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
postgres = ["psycopg[binary]>=3.2,<4"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
user-engine-portal = "user_engine.runtime:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=69"]
|
requires = ["setuptools>=69"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,11 @@ from user_engine.adapters.local import (
|
||||||
LocalAuthorizationCheckPort,
|
LocalAuthorizationCheckPort,
|
||||||
)
|
)
|
||||||
from user_engine.adapters.postgres import PostgresUserEngineStore
|
from user_engine.adapters.postgres import PostgresUserEngineStore
|
||||||
|
from user_engine.adapters.claims import VerifiedIdentityClaimsAdapter
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"InMemoryUserEngineStore",
|
"InMemoryUserEngineStore",
|
||||||
"LocalAuthorizationCheckPort",
|
"LocalAuthorizationCheckPort",
|
||||||
"PostgresUserEngineStore",
|
"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 __future__ import annotations
|
||||||
|
|
||||||
from contextlib import AbstractContextManager
|
from contextlib import AbstractContextManager
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any, Iterable, Mapping, Protocol
|
from typing import Any, Iterable, Mapping, Protocol
|
||||||
|
|
||||||
from user_engine.domain import (
|
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):
|
class UserEngineStore(Protocol):
|
||||||
"""Durable persistence boundary for user-engine service behavior.
|
"""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"
|
title: "Production self-service and user administration portal"
|
||||||
domain: communication
|
domain: communication
|
||||||
repo: user-engine
|
repo: user-engine
|
||||||
status: ready
|
status: active
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: netkingdom
|
topic_slug: netkingdom
|
||||||
created: "2026-07-27"
|
created: "2026-07-27"
|
||||||
|
|
@ -34,7 +34,7 @@ authorization decisions.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0020-T01
|
id: USER-WP-0020-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "9886ac8d-7456-4de0-b019-351dfd74ec20"
|
state_hub_task_id: "9886ac8d-7456-4de0-b019-351dfd74ec20"
|
||||||
```
|
```
|
||||||
|
|
@ -54,7 +54,7 @@ metadata for that later stage.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0020-T02
|
id: USER-WP-0020-T02
|
||||||
status: wait
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "fdb0c322-3efe-4077-bfba-1648787ef411"
|
state_hub_task_id: "fdb0c322-3efe-4077-bfba-1648787ef411"
|
||||||
```
|
```
|
||||||
|
|
@ -70,7 +70,7 @@ OpenAPI, health/readiness, redacted audit, and transactional outbox behavior.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0020-T03
|
id: USER-WP-0020-T03
|
||||||
status: wait
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "45ed1485-003d-4e5f-99fb-91b1b430f3fa"
|
state_hub_task_id: "45ed1485-003d-4e5f-99fb-91b1b430f3fa"
|
||||||
```
|
```
|
||||||
|
|
@ -86,7 +86,7 @@ admin credentials or store password/MFA secrets.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0020-T04
|
id: USER-WP-0020-T04
|
||||||
status: wait
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "16555b68-17ef-4902-bd30-f9a0cfe10f9e"
|
state_hub_task_id: "16555b68-17ef-4902-bd30-f9a0cfe10f9e"
|
||||||
```
|
```
|
||||||
|
|
@ -103,7 +103,7 @@ platform or other-tenant data.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: USER-WP-0020-T05
|
id: USER-WP-0020-T05
|
||||||
status: wait
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "65ff5c96-134a-4ec2-ad92-511f0e5f6f04"
|
state_hub_task_id: "65ff5c96-134a-4ec2-ad92-511f0e5f6f04"
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue