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:
|
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"]),
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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"):
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue