Add public registration cancellation
This commit is contained in:
parent
8170373148
commit
d5a5fed69a
7 changed files with 174 additions and 8 deletions
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue