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: 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: if len(opaque_handle) < 32:
raise ValueError("verification handle is invalid") raise ValueError("verification handle is invalid")
result = self._post( result = self._post(path, {"handle": opaque_handle})
"/v1/registration-verifications/consume", {"handle": opaque_handle} if result.get("purpose") != expected_purpose:
)
if result.get("purpose") != "public-registration":
raise RuntimeError("verification evidence has the wrong purpose") raise RuntimeError("verification evidence has the wrong purpose")
return VerifiedRegistrationApplicant( return VerifiedRegistrationApplicant(
verification_id=str(result["verification_id"]), verification_id=str(result["verification_id"]),

View file

@ -181,6 +181,9 @@ class RegistrationVerificationPort(Protocol):
def consume(self, opaque_handle: str) -> VerifiedRegistrationApplicant: def consume(self, opaque_handle: str) -> VerifiedRegistrationApplicant:
"""Atomically consume verified, unexpired applicant evidence.""" """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): class UserEngineStore(Protocol):
"""Durable persistence boundary for user-engine service behavior. """Durable persistence boundary for user-engine service behavior.

View file

@ -133,9 +133,11 @@ class PortalApplication:
path = str(environ.get("PATH_INFO", "/")).rstrip("/") or "/" path = str(environ.get("PATH_INFO", "/")).rstrip("/") or "/"
if method == "POST" and path in { if method == "POST" and path in {
"/register", "/registration/verify", "/registration/resume", "/register", "/registration/verify", "/registration/resume",
"/registration/cancel",
"/api/v1/public/registrations", "/api/v1/public/registrations",
"/api/v1/public/registrations/verify", "/api/v1/public/registrations/verify",
"/api/v1/public/registrations/resume", "/api/v1/public/registrations/resume",
"/api/v1/public/registrations/cancel",
} and not self._accept_registration_attempt(environ): } and not self._accept_registration_attempt(environ):
return self._error( return self._error(
start_response, "429 Too Many Requests", "rate_limited", start_response, "429 Too Many Requests", "rate_limited",
@ -229,6 +231,23 @@ class PortalApplication:
return self._resume_public_registration( return self._resume_public_registration(
environ, start_response, correlation_id, body=body, browser=True 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": if path == "/api/v1/public/registrations" and method == "POST":
return self._start_public_registration( return self._start_public_registration(
@ -242,6 +261,10 @@ class PortalApplication:
return self._resume_public_registration( return self._resume_public_registration(
environ, start_response, correlation_id 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) actor = self._actor(environ)
if path == "/api/v1/me" and method == "GET": if path == "/api/v1/me" and method == "GET":
@ -1058,6 +1081,51 @@ class PortalApplication:
browser=browser, 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( def _provision_public_registration(
self, start_response, actor, user, session, email, display_name, self, start_response, actor, user, session, email, display_name,
preferred_username, correlation_id, preferred_username, correlation_id,
@ -1305,6 +1373,18 @@ class PortalApplication:
<input type="hidden" name="resume_handle" value="{escape(resume_handle)}"> <input type="hidden" name="resume_handle" value="{escape(resume_handle)}">
<button type="submit">Try identity setup again</button></form>""", <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( def _admin(
self, tenant: str, memberships: tuple[Any, ...], self, tenant: str, memberships: tuple[Any, ...],
invitations: tuple[Any, ...], diagnostics: Any, invitations: tuple[Any, ...], diagnostics: Any,

View file

@ -68,6 +68,23 @@ class RegistrationVerificationAdapterTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "wrong purpose"): with self.assertRaisesRegex(RuntimeError, "wrong purpose"):
adapter.consume("x" * 32) 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") @patch("user_engine.adapters.registration_verification.urlopen")
def test_expired_and_replayed_handles_have_the_same_redacted_failure(self, opener): def test_expired_and_replayed_handles_have_the_same_redacted_failure(self, opener):
adapter = HTTPRegistrationVerificationAdapter( adapter = HTTPRegistrationVerificationAdapter(

View file

@ -284,6 +284,35 @@ class PortalApplicationTests(unittest.TestCase):
self.assertEqual("200 OK", completed["status"]) self.assertEqual("200 OK", completed["status"])
self.assertIn(b"Create password", html) 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): def test_public_registration_rejects_untrusted_password_setup_origin(self):
verifier = FakeRegistrationVerification() verifier = FakeRegistrationVerification()
self.app.registration_verification = verifier self.app.registration_verification = verifier
@ -885,6 +914,9 @@ class FakeRegistrationVerification:
display_name=self.requested.display_name, display_name=self.requested.display_name,
) )
def cancel(self, opaque_handle):
return self.consume(opaque_handle)
class FakeProvisioning: class FakeProvisioning:
def __init__(self, password_setup_url="https://kc.example/setup/password?token=opaque"): 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" title: "Expand user-engine portal beyond the proven Binky MVP"
domain: communication domain: communication
repo: user-engine repo: user-engine
status: active status: blocked
owner: codex owner: codex
topic_slug: netkingdom topic_slug: netkingdom
created: "2026-07-30" created: "2026-07-30"
updated: "2026-08-10" updated: "2026-08-10"
depends_on: depends_on:
- USER-WP-0020 - USER-WP-0020
- TEN-WP-0005
state_hub_workstream_id: "ba217f48-5fa5-4178-9c79-73aa225f3f2e" 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 ```task
id: USER-WP-0021-T01 id: USER-WP-0021-T01
status: progress status: wait
priority: high priority: high
state_hub_task_id: "342299b8-d9a3-408d-bf0d-914496714d5f" 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 metadata-update or retirement operation. Portal update/retirement routes must
remain out until that authority owns the corresponding lifecycle contract; remain out until that authority owns the corresponding lifecycle contract;
user-engine will not simulate authoritative tenant state locally. 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" title: "Public registration and JIT application profiles"
domain: communication domain: communication
repo: user-engine repo: user-engine
status: active status: blocked
owner: codex owner: codex
topic_slug: netkingdom topic_slug: netkingdom
created: "2026-08-09" 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 130 tests with three environment-dependent skips. Cluster ingress throttling
remains defense in depth before public enablement. 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 ## T02 - Orchestrate provider identity creation
```task ```task
@ -179,7 +187,7 @@ step-up. user-engine must not become the token assurance authority.
```task ```task
id: USER-WP-0022-T05 id: USER-WP-0022-T05
status: progress status: wait
priority: high priority: high
state_hub_task_id: "0a5a5f3a-0d47-4d5d-bda5-e2c7c737fee6" 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 the identity-link uniqueness boundary, leaves the original link unchanged, and
never transfers that identity to the later registration. The full suite passes never transfers that identity to the later registration. The full suite passes
131 tests with three environment-dependent skips. 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.