diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..b526626 --- /dev/null +++ b/Containerfile @@ -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"] diff --git a/docs/portal-threat-model.md b/docs/portal-threat-model.md new file mode 100644 index 0000000..6312ee3 --- /dev/null +++ b/docs/portal-threat-model.md @@ -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. diff --git a/openapi/portal-v1.yaml b/openapi/portal-v1.yaml new file mode 100644 index 0000000..14d5a86 --- /dev/null +++ b/openapi/portal-v1.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 140c32b..c486202 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/user_engine/adapters/__init__.py b/src/user_engine/adapters/__init__.py index 722ebfc..c3b24ae 100644 --- a/src/user_engine/adapters/__init__.py +++ b/src/user_engine/adapters/__init__.py @@ -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", ] diff --git a/src/user_engine/adapters/claims.py b/src/user_engine/adapters/claims.py new file mode 100644 index 0000000..fe44256 --- /dev/null +++ b/src/user_engine/adapters/claims.py @@ -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) diff --git a/src/user_engine/ports.py b/src/user_engine/ports.py index 6b88483..e2978c2 100644 --- a/src/user_engine/ports.py +++ b/src/user_engine/ports.py @@ -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. diff --git a/src/user_engine/runtime.py b/src/user_engine/runtime.py new file mode 100644 index 0000000..92b453d --- /dev/null +++ b/src/user_engine/runtime.py @@ -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() diff --git a/src/user_engine/web.py b/src/user_engine/web.py new file mode 100644 index 0000000..77f8749 --- /dev/null +++ b/src/user_engine/web.py @@ -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"
Signed in as {escape(actor.preferred_username)}.
" + if actor is not None + else f'' + ) + return self._page_html( + "Identity & access", + "Join a tenant, complete onboarding, and manage access without exposing credentials to applications.
" + + identity, + ) + + def _admin(self, tenant: str, memberships: tuple[Any, ...]) -> str: + rows = "".join( + f"| User | Role | Scope |
|---|