Add public registration cancellation
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

This commit is contained in:
tegwick 2026-08-10 19:27:41 +02:00
parent 8170373148
commit d5a5fed69a
7 changed files with 174 additions and 8 deletions

View file

@ -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"]),

View file

@ -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.

View file

@ -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",
"<h1>Registration canceled.</h1>"
"<p>The request can no longer be verified. You may start again at any time.</p>"
'<p><a class="button" href="/register">Start again</a></p>',
),
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:
<input type="hidden" name="resume_handle" value="{escape(resume_handle)}">
<button type="submit">Try identity setup again</button></form>""",
)
def _registration_cancel_form(self, csrf_token: str, handle: str) -> str:
return self._page_html(
"Cancel registration",
f"""<h1>Cancel this registration?</h1>
<p>This verification request will stop working. No login identity will be created.</p>
<form method="post" action="/registration/cancel">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<input type="hidden" name="handle" value="{escape(handle)}">
<button type="submit">Cancel registration</button></form>
<p><a href="/register">Keep registration</a></p>""",
)
def _admin(
self, tenant: str, memberships: tuple[Any, ...],
invitations: tuple[Any, ...], diagnostics: Any,

View file

@ -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(

View file

@ -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"):

View file

@ -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.

View file

@ -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.