From d5a5fed69aeaf2fb7808984041a20b1b43447f88 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 10 Aug 2026 19:27:41 +0200 Subject: [PATCH] Add public registration cancellation --- .../adapters/registration_verification.py | 20 ++++- src/user_engine/ports.py | 3 + src/user_engine/web.py | 80 +++++++++++++++++++ .../test_registration_verification_adapter.py | 17 ++++ tests/test_web.py | 32 ++++++++ .../USER-WP-0021-portal-product-expansion.md | 11 ++- ...gistration-and-jit-application-profiles.md | 19 ++++- 7 files changed, 174 insertions(+), 8 deletions(-) diff --git a/src/user_engine/adapters/registration_verification.py b/src/user_engine/adapters/registration_verification.py index 09e429c..715d497 100644 --- a/src/user_engine/adapters/registration_verification.py +++ b/src/user_engine/adapters/registration_verification.py @@ -45,12 +45,24 @@ class HTTPRegistrationVerificationAdapter: ) def consume(self, opaque_handle: str) -> VerifiedRegistrationApplicant: + return self._resolve( + opaque_handle, "/v1/registration-verifications/consume", + "public-registration", + ) + + def cancel(self, opaque_handle: str) -> VerifiedRegistrationApplicant: + return self._resolve( + opaque_handle, "/v1/registration-verifications/cancel", + "public-registration-cancel", + ) + + def _resolve( + self, opaque_handle: str, path: str, expected_purpose: str + ) -> VerifiedRegistrationApplicant: if len(opaque_handle) < 32: raise ValueError("verification handle is invalid") - result = self._post( - "/v1/registration-verifications/consume", {"handle": opaque_handle} - ) - if result.get("purpose") != "public-registration": + result = self._post(path, {"handle": opaque_handle}) + if result.get("purpose") != expected_purpose: raise RuntimeError("verification evidence has the wrong purpose") return VerifiedRegistrationApplicant( verification_id=str(result["verification_id"]), diff --git a/src/user_engine/ports.py b/src/user_engine/ports.py index b2a387a..426d86d 100644 --- a/src/user_engine/ports.py +++ b/src/user_engine/ports.py @@ -181,6 +181,9 @@ class RegistrationVerificationPort(Protocol): def consume(self, opaque_handle: str) -> VerifiedRegistrationApplicant: """Atomically consume verified, unexpired applicant evidence.""" + def cancel(self, opaque_handle: str) -> VerifiedRegistrationApplicant: + """Atomically cancel an unexpired applicant intent using mailbox evidence.""" + class UserEngineStore(Protocol): """Durable persistence boundary for user-engine service behavior. diff --git a/src/user_engine/web.py b/src/user_engine/web.py index b5217bd..1b6009b 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -133,9 +133,11 @@ class PortalApplication: path = str(environ.get("PATH_INFO", "/")).rstrip("/") or "/" if method == "POST" and path in { "/register", "/registration/verify", "/registration/resume", + "/registration/cancel", "/api/v1/public/registrations", "/api/v1/public/registrations/verify", "/api/v1/public/registrations/resume", + "/api/v1/public/registrations/cancel", } and not self._accept_registration_attempt(environ): return self._error( start_response, "429 Too Many Requests", "rate_limited", @@ -229,6 +231,23 @@ class PortalApplication: return self._resume_public_registration( environ, start_response, correlation_id, body=body, browser=True ) + if path == "/registration/cancel" and method == "GET": + query = parse_qs(str(environ.get("QUERY_STRING", ""))) + handle = str(query.get("handle", [""])[0]) + if len(handle) < 16: + raise ValidationError("cancellation handle is invalid") + token = secrets.token_urlsafe(32) + return self._html( + start_response, self._registration_cancel_form(token, handle), + correlation_id, + extra_headers=[("Set-Cookie", self._registration_csrf_cookie(token))], + ) + if path == "/registration/cancel" and method == "POST": + body = self._form_body(environ) + self._require_registration_csrf(environ, str(body.get("csrf_token", ""))) + return self._cancel_public_registration( + environ, start_response, correlation_id, body=body, browser=True + ) if path == "/api/v1/public/registrations" and method == "POST": return self._start_public_registration( @@ -242,6 +261,10 @@ class PortalApplication: return self._resume_public_registration( environ, start_response, correlation_id ) + if path == "/api/v1/public/registrations/cancel" and method == "POST": + return self._cancel_public_registration( + environ, start_response, correlation_id + ) actor = self._actor(environ) if path == "/api/v1/me" and method == "GET": @@ -1058,6 +1081,51 @@ class PortalApplication: browser=browser, ) + def _cancel_public_registration( + self, environ, start_response, correlation_id, *, + body: Mapping[str, Any] | None = None, browser: bool = False, + ): + if not self.public_registration or self.registration_verification is None: + raise NotFoundError("public registration is unavailable") + body = body if body is not None else self._body(environ) + evidence = self.registration_verification.cancel( + str(body.get("handle") or "") + ) + session = self.service.store.registration_session(evidence.registration_id) + if session is None: + raise NotFoundError("registration not found") + if ( + evidence.client_id != session.client_id + or evidence.tenant != session.tenant + or evidence.preferred_username != session.applicant_username + ): + raise AuthorizationDenied("cancellation evidence does not match registration") + actor = Actor( + issuer="urn:netkingdom:public-registration", + subject=str(session.started_by_subject), tenant=session.tenant, + principal_type=PrincipalType.HUMAN, audience=("user-engine",), + roles=("registration-applicant",), authorized_party=session.client_id, + preferred_username=session.applicant_username, + ) + self.service.abandon_registration( + actor, session.registration_id, correlation_id=correlation_id + ) + if browser: + return self._html( + start_response, + self._page_html( + "Registration canceled", + "

Registration canceled.

" + "

The request can no longer be verified. You may start again at any time.

" + '

Start again

', + ), + correlation_id, + ) + return self._json( + start_response, "200 OK", {"status": "registration_canceled"}, + correlation_id, + ) + def _provision_public_registration( self, start_response, actor, user, session, email, display_name, preferred_username, correlation_id, @@ -1305,6 +1373,18 @@ class PortalApplication: """, ) + + def _registration_cancel_form(self, csrf_token: str, handle: str) -> str: + return self._page_html( + "Cancel registration", + f"""

Cancel this registration?

+

This verification request will stop working. No login identity will be created.

+
+ + +
+

Keep registration

""", + ) def _admin( self, tenant: str, memberships: tuple[Any, ...], invitations: tuple[Any, ...], diagnostics: Any, diff --git a/tests/test_registration_verification_adapter.py b/tests/test_registration_verification_adapter.py index 12a749b..442018f 100644 --- a/tests/test_registration_verification_adapter.py +++ b/tests/test_registration_verification_adapter.py @@ -68,6 +68,23 @@ class RegistrationVerificationAdapterTests(unittest.TestCase): with self.assertRaisesRegex(RuntimeError, "wrong purpose"): adapter.consume("x" * 32) + @patch("user_engine.adapters.registration_verification.urlopen") + def test_cancel_requires_cancellation_purpose(self, opener): + opener.return_value = Response(json.dumps({ + "purpose": "public-registration-cancel", + "verification_id": "fvc_1", "registration_id": "reg_1", + "email": "person@example.test", "preferred_username": "person", + "client_id": "coulomb-social", "tenant": "tenant:coulomb", + "source_system": "mail-verifier", + "assurance": {"mailbox_control": True}, + }).encode()) + adapter = HTTPRegistrationVerificationAdapter( + base_url="http://verification", bearer_token="secret" + ) + evidence = adapter.cancel("x" * 32) + self.assertEqual("reg_1", evidence.registration_id) + self.assertTrue(opener.call_args.args[0].full_url.endswith("/cancel")) + @patch("user_engine.adapters.registration_verification.urlopen") def test_expired_and_replayed_handles_have_the_same_redacted_failure(self, opener): adapter = HTTPRegistrationVerificationAdapter( diff --git a/tests/test_web.py b/tests/test_web.py index 17b8d93..2a0167f 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -284,6 +284,35 @@ class PortalApplicationTests(unittest.TestCase): self.assertEqual("200 OK", completed["status"]) self.assertIn(b"Create password", html) + def test_public_registration_browser_cancellation_is_bound_and_terminal(self): + verifier = FakeRegistrationVerification() + self.app.registration_verification = verifier + self.app.registration_clients = frozenset({"coulomb-social"}) + self.app.registration_tenants = frozenset({"tenant:coulomb"}) + invoke(self.app, "/api/v1/public/registrations", method="POST", body={ + "username": "cancel.person", "email": "cancel@example.test", + "client_id": "coulomb-social", "tenant": "tenant:coulomb", + }) + verifier.registration_id = verifier.requested.registration_id + confirmation, html = invoke( + self.app, "/registration/cancel", query="handle=" + "x" * 32 + ) + cookie = confirmation["headers"]["Set-Cookie"] + token = re.search(rb'name="csrf_token" value="([^"]+)"', html).group(1).decode() + canceled, html = invoke( + self.app, "/registration/cancel", method="POST", cookie=cookie, + form={"csrf_token": token, "handle": "x" * 32}, + ) + self.assertEqual("200 OK", canceled["status"]) + self.assertIn(b"Registration canceled", html) + session = self.app.service.store.registration_session(verifier.registration_id) + self.assertEqual("abandoned", session.status.value) + verify, _ = invoke( + self.app, "/api/v1/public/registrations/verify", method="POST", + body={"handle": "x" * 32}, remote_addr="127.0.0.2", + ) + self.assertEqual("400 Bad Request", verify["status"]) + def test_public_registration_rejects_untrusted_password_setup_origin(self): verifier = FakeRegistrationVerification() self.app.registration_verification = verifier @@ -885,6 +914,9 @@ class FakeRegistrationVerification: display_name=self.requested.display_name, ) + def cancel(self, opaque_handle): + return self.consume(opaque_handle) + class FakeProvisioning: def __init__(self, password_setup_url="https://kc.example/setup/password?token=opaque"): diff --git a/workplans/USER-WP-0021-portal-product-expansion.md b/workplans/USER-WP-0021-portal-product-expansion.md index 4085b28..7402773 100644 --- a/workplans/USER-WP-0021-portal-product-expansion.md +++ b/workplans/USER-WP-0021-portal-product-expansion.md @@ -4,13 +4,14 @@ type: workplan title: "Expand user-engine portal beyond the proven Binky MVP" domain: communication repo: user-engine -status: active +status: blocked owner: codex topic_slug: netkingdom created: "2026-07-30" updated: "2026-08-10" depends_on: - USER-WP-0020 + - TEN-WP-0005 state_hub_workstream_id: "ba217f48-5fa5-4178-9c79-73aa225f3f2e" --- @@ -23,7 +24,7 @@ holding the proven production MVP open. Activate according to tenant demand. ```task id: USER-WP-0021-T01 -status: progress +status: wait priority: high state_hub_task_id: "342299b8-d9a3-408d-bf0d-914496714d5f" ``` @@ -205,3 +206,9 @@ supports tenant creation plus role/plan operations, but exposes no tenant metadata-update or retirement operation. Portal update/retirement routes must remain out until that authority owns the corresponding lifecycle contract; user-engine will not simulate authoritative tenant state locally. + +TEN-WP-0005 is now registered and ready in tenant-engine for the authoritative +metadata update, retirement, and reactivation contract. This task is waiting +on that workplan and on the OpenBao-backed event/mail delivery credentials. +The non-secret flex-auth runtime URL is live and verified, removing that item +from the rollout gate. diff --git a/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md b/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md index f09ea0d..9c7e9d2 100644 --- a/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md +++ b/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md @@ -4,7 +4,7 @@ type: workplan title: "Public registration and JIT application profiles" domain: communication repo: user-engine -status: active +status: blocked owner: codex topic_slug: netkingdom created: "2026-08-09" @@ -94,6 +94,14 @@ redacted rejection without provider or account detail. The full suite passes 130 tests with three environment-dependent skips. Cluster ingress throttling remains defense in depth before public enablement. +2026-08-10 cancellation increment: each verification email now carries a +fixed cancellation link using the same digest-only opaque handle. A +CSRF-protected confirmation consumes purpose-bound cancellation evidence and +terminally abandons the matching registration. Cancellation and verification +atomically invalidate one another, replay fails closed, and no identity is +created from cancellation. The full user-engine suite passes 133 tests with +three environment-dependent skips; email-connect passes all 24 tests. + ## T02 - Orchestrate provider identity creation ```task @@ -179,7 +187,7 @@ step-up. user-engine must not become the token assurance authority. ```task id: USER-WP-0022-T05 -status: progress +status: wait priority: high state_hub_task_id: "0a5a5f3a-0d47-4d5d-bda5-e2c7c737fee6" ``` @@ -204,3 +212,10 @@ linking to an existing account. Reusing an existing provider username fails at the identity-link uniqueness boundary, leaves the original link unchanged, and never transfers that identity to the later registration. The full suite passes 131 tests with three environment-dependent skips. + +The latest tested image is published at +`forgejo.coulomb.social/coulomb/user-engine@sha256:bbc4ae9373b09432e67bdc66bc32878b5c4ecbcb128ebed8c7ca25d2d4b3d741`. +The cluster already applies a Traefik-wide rate limit, and the runtime manifest +now explicitly configures the stricter application registration limit. Public +registration remains disabled pending its OpenBao verification/delivery tokens +and transactional SMTP configuration.