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,