From e23674d343dacd644416633b34591fff3878634f Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 28 Jul 2026 01:22:55 +0200 Subject: [PATCH] Add CSRF-safe tenant identity administration --- src/user_engine/oidc.py | 14 ++- src/user_engine/web.py | 213 ++++++++++++++++++++++++++++++++++++++-- tests/test_oidc.py | 1 + tests/test_web.py | 152 +++++++++++++++++++++++++++- 4 files changed, 367 insertions(+), 13 deletions(-) diff --git a/src/user_engine/oidc.py b/src/user_engine/oidc.py index 1cf1091..89a673b 100644 --- a/src/user_engine/oidc.py +++ b/src/user_engine/oidc.py @@ -23,6 +23,7 @@ class PendingLogin: class BrowserSession: claims: Mapping[str, Any] expires_at: float + csrf_token: str = "" class OIDCClient: @@ -88,7 +89,11 @@ class OIDCClient: claims = self._verify(token) session_id = secrets.token_urlsafe(32) expiry = min(float(claims.get("exp", time.time() + self.session_ttl)), time.time() + self.session_ttl) - self.sessions[session_id] = BrowserSession(claims=claims, expires_at=expiry) + self.sessions[session_id] = BrowserSession( + claims=claims, + expires_at=expiry, + csrf_token=secrets.token_urlsafe(32), + ) self._prune() return session_id @@ -102,6 +107,13 @@ class OIDCClient: def logout(self, session_id: str) -> None: self.sessions.pop(session_id, None) + def csrf_token(self, session_id: str) -> str | None: + session = self.sessions.get(session_id) + if session is None or session.expires_at <= time.time(): + self.sessions.pop(session_id, None) + return None + return session.csrf_token + def _verify(self, token: str) -> Mapping[str, Any]: if not token: raise ValueError("OIDC token response is missing a token") diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 964a9d6..a5101f6 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -15,7 +15,7 @@ from html import escape import json import secrets from typing import Any, Callable, Iterable, Mapping -from urllib.parse import parse_qs +from urllib.parse import parse_qs, urlencode from user_engine.domain import AccountStatus from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError @@ -68,6 +68,14 @@ class PortalApplication: return self._dispatch(environ, start_response, str(correlation_id)) except (ValidationError, ConflictError, ValueError) as exc: return self._error(start_response, "400 Bad Request", "invalid_request", str(exc), correlation_id) + except RuntimeError: + return self._error( + start_response, + "502 Bad Gateway", + "provisioning_unavailable", + "Identity provisioning is temporarily unavailable.", + correlation_id, + ) except AuthorizationDenied: return self._error(start_response, "403 Forbidden", "access_denied", "Access denied.", correlation_id) except NotFoundError: @@ -211,23 +219,143 @@ class PortalApplication: ) ), )) - return self._json(start_response, "200 OK", _jsonable(result), correlation_id) + 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) 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 + 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, ) 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._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, + ) + 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) return self._error(start_response, "404 Not Found", "not_found", "Resource not found.", correlation_id) + 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 + ) + def _claims(self, environ: Mapping[str, Any]) -> Mapping[str, Any]: if self.oidc_client is not None: session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session") @@ -264,6 +392,29 @@ class PortalApplication: raise ValidationError("request body must be an object") return value + @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") + @staticmethod def _page(environ: Mapping[str, Any]) -> tuple[int, int]: query = parse_qs(str(environ.get("QUERY_STRING", ""))) @@ -284,16 +435,56 @@ class PortalApplication: + identity, ) - def _admin(self, tenant: str, memberships: tuple[Any, ...]) -> str: + def _admin(self, tenant: str, memberships: tuple[Any, ...], csrf_token: str) -> str: rows = "".join( - f"{escape(item.user_id)}{escape(item.kind)}{escape(item.scope_id)}" + self._admin_row(tenant, item, csrf_token) for item in memberships - ) or 'No members yet.' + ) or 'No members yet.' return self._page_html( f"{tenant} users", - f"

{escape(tenant)} users

{rows}
UserRoleScope
", + f"""

{escape(tenant)} users

+

Add a user

+
+ + + + +
+

Members

{rows}
UserEmailRoleStatusDirectoryAction
""", ) + 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"""Linked as {escape(directory.subject)} +
+ + +
""" + if directory + else f"""
+ +
""" + ) + return ( + f"{escape(user.display_name or membership.user_id) if user else escape(membership.user_id)}" + f"{escape(user.primary_email or '') if user else ''}" + f"{escape(membership.kind)}" + f"{escape(status.value)}" + f"{'linked' if directory else 'pending'}{action}" + ) + + 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""] + @staticmethod def _page_html(title: str, body: str) -> str: return f""" @@ -305,7 +496,9 @@ header,main{{max-width:68rem;margin:auto;padding:1.25rem}}header{{border-bottom: 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}}}} +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}}}}
Railiance identity
{body}
""" def _html(self, start_response: StartResponse, body: str, correlation_id: str) -> list[bytes]: diff --git a/tests/test_oidc.py b/tests/test_oidc.py index 1c2efe2..54d7e80 100644 --- a/tests/test_oidc.py +++ b/tests/test_oidc.py @@ -26,6 +26,7 @@ class OIDCClientTests(unittest.TestCase): claims={"sub": "person"}, expires_at=9999999999 ) self.assertEqual("person", self.client.claims("opaque")["sub"]) + self.assertEqual("", self.client.csrf_token("opaque")) self.assertEqual("opaque", cookie_value("x=1; ue_session=opaque", "ue_session")) self.client.logout("opaque") self.assertIsNone(self.client.claims("opaque")) diff --git a/tests/test_web.py b/tests/test_web.py index ce594f2..5756d5f 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -1,8 +1,11 @@ import io import json import unittest +from urllib.parse import urlencode from user_engine.adapters import InMemoryUserEngineStore, LocalAuthorizationCheckPort +from user_engine.oidc import BrowserSession, OIDCClient +from user_engine.ports import ProvisioningResult from user_engine.service import UserEngineService from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims from user_engine.web import PortalApplication @@ -11,8 +14,15 @@ 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() +def invoke( + app, path, *, method="GET", claims=None, marker=SECRET, body=None, + form=None, cookie=None, +): + payload = ( + urlencode(form).encode() + if form is not None + else json.dumps(body or {}).encode() + ) environ = { "REQUEST_METHOD": method, "PATH_INFO": path, @@ -21,6 +31,10 @@ def invoke(app, path, *, method="GET", claims=None, marker=SECRET, body=None): "wsgi.input": io.BytesIO(payload), "HTTP_X_REQUEST_ID": "corr_test", } + if form is not None: + environ["CONTENT_TYPE"] = "application/x-www-form-urlencoded" + if cookie is not None: + environ["HTTP_COOKIE"] = cookie if claims is not None: environ["HTTP_X_VERIFIED_OIDC_CLAIMS"] = json.dumps(claims) environ["HTTP_X_USER_ENGINE_PROXY_SECRET"] = marker @@ -84,6 +98,140 @@ class PortalApplicationTests(unittest.TestCase): self.assertEqual("corr_test", result["headers"]["X-Request-ID"]) self.assertEqual("factor_pending", json.loads(payload)["status"]) + def test_provision_api_links_provider_subject(self): + self.app.provisioning = FakeProvisioning() + created, payload = invoke( + self.app, + "/api/v1/tenants/tenant:friendly:binky/users", + method="POST", + claims=self.claims, + body={ + "display_name": "Ada Admin", + "primary_email": "ada@example.test", + "role": "tenant-admin", + }, + ) + self.assertEqual("201 Created", created["status"]) + user_id = json.loads(payload)["user"]["user_id"] + provisioned, payload = invoke( + self.app, + f"/api/v1/tenants/tenant:friendly:binky/users/{user_id}/provision", + method="POST", + claims=self.claims, + ) + # The helper does not set an idempotency header. + self.assertEqual("400 Bad Request", provisioned["status"]) + result, payload = invoke_with_idempotency( + self.app, + f"/api/v1/tenants/tenant:friendly:binky/users/{user_id}/provision", + self.claims, + ) + self.assertEqual("200 OK", result["status"]) + self.assertEqual("ada", json.loads(payload)["identity"]["subject"]) + changed, payload = invoke_with_idempotency( + self.app, + f"/api/v1/tenants/tenant:friendly:binky/users/{user_id}", + self.claims, + method="PATCH", + body={"status": "suspended"}, + ) + self.assertEqual("200 OK", changed["status"]) + self.assertEqual("suspended", json.loads(payload)["status"]) + self.assertIn(("suspend", "ada"), self.app.provisioning.actions) + + def test_admin_form_requires_csrf_and_supports_two_step_provisioning(self): + oidc = OIDCClient( + issuer="https://kc.example", + client_id="portal", + redirect_uri="https://users.example/oidc/callback", + audience="portal", + ) + oidc.sessions["browser"] = BrowserSession( + claims=self.claims, + expires_at=9999999999, + csrf_token="csrf-test-token", + ) + self.app.oidc_client = oidc + self.app.provisioning = FakeProvisioning() + denied, _ = invoke( + self.app, + "/admin/tenant:friendly:binky/users", + method="POST", + cookie="ue_session=browser", + form={ + "csrf_token": "wrong", + "display_name": "Ada Admin", + "primary_email": "ada@example.test", + "role": "tenant-admin", + }, + ) + self.assertEqual("403 Forbidden", denied["status"]) + created, _ = invoke( + self.app, + "/admin/tenant:friendly:binky/users", + method="POST", + cookie="ue_session=browser", + form={ + "csrf_token": "csrf-test-token", + "display_name": "Ada Admin", + "primary_email": "ada@example.test", + "role": "tenant-admin", + }, + ) + self.assertEqual("303 See Other", created["status"]) + page, html = invoke( + self.app, + "/admin/tenant:friendly:binky", + cookie="ue_session=browser", + ) + self.assertEqual("200 OK", page["status"]) + self.assertIn(b"ada@example.test", html) + self.assertIn(b"Create login", html) + + +class FakeProvisioning: + def __init__(self): + self.actions = [] + + def provision(self, request): + self.actions.append(("provision", request.primary_email)) + return ProvisioningResult( + provider="netkingdom-lldap", + external_subject=request.primary_email.split("@")[0], + status="password_setup_required", + ) + + def suspend(self, *, external_subject, idempotency_key, correlation_id): + self.actions.append(("suspend", external_subject)) + return ProvisioningResult("netkingdom-lldap", external_subject, "suspended") + + def reactivate(self, *, external_subject, idempotency_key, correlation_id): + self.actions.append(("reactivate", external_subject)) + return ProvisioningResult("netkingdom-lldap", external_subject, "active") + + +def invoke_with_idempotency(app, path, claims, *, method="POST", 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", + "HTTP_X_VERIFIED_OIDC_CLAIMS": json.dumps(claims), + "HTTP_X_USER_ENGINE_PROXY_SECRET": SECRET, + "HTTP_IDEMPOTENCY_KEY": "test-idempotency-123456", + } + captured = {} + response = b"".join(app( + environ, + lambda status, headers: captured.update( + {"status": status, "headers": dict(headers)} + ), + )) + return captured, response + if __name__ == "__main__": unittest.main()