feat: add governed public self-enrollment links

This commit is contained in:
2026-08-20 13:03:57 +02:00
parent 79cfaa951a
commit 539e3cbb5e
18 changed files with 2481 additions and 13 deletions
+73 -4
View File
@@ -182,11 +182,48 @@ DOCUMENTATION = (
],
},
),
DocumentationTopic(
id="scheduling.public-self-enrollment",
title="Use governed public self-enrollment links",
summary="Issue reusable scheduling links with explicit capacity, expiry, identity, account-binding, and abuse controls.",
body=(
"A public self-enrollment link is distinct from a participant-specific invitation. "
"Organizers must choose a capacity and future expiry, and can independently allow anonymous and signed-in enrollment. "
"Every participant supplies a display name; email is required only when the request policy says so. Anonymous participants create and retain a separate recovery proof, which is submitted in the request body and is never embedded in the link, logs, analytics, or durable clear text. "
"Signed-in participants must explicitly confirm account binding. A later signed-in submission may bind an anonymous enrollment only when its recovery proof is supplied; the binding is audited. "
"Deployment policy can disable self-enrollment or cap its maximum capacity. Redis provides shared fixed-window throttling when configured, while development uses the bounded single-node fallback. Existing personalized invitation links are unchanged. "
"Revoking or expiring the reusable link prevents new access immediately. Capacity is serialized with participant creation, retries are idempotent through the caller's idempotency key, and existing proof holders can update only while request policy permits updates. Other participants receive only the request's existing aggregate or governed roster projection."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("organizer", "participant", "module_admin", "tenant_admin"),
conditions=(
DocumentationCondition(
any_scopes=(WRITE_SCOPE, ADMIN_SCOPE, RESPOND_SCOPE),
),
),
related_modules=("poll", "access", "policy"),
metadata={
"kind": "workflow",
"route": "/scheduling",
"help_contexts": [
"scheduling.public-self-enrollment",
"scheduling.public-self-enrollment-governance",
],
"steps": [
"Choose a bounded capacity, expiry, and permitted identity modes.",
"Copy the newly issued link; the raw credential is shown only once.",
"Monitor enrollment count and revoke the link when it is no longer needed.",
"Require recovery proof before updating or binding an anonymous response.",
],
"verification": "The link list shows status, expiry, capacity use, access modes, and revocation without redisplaying its credential.",
},
),
)
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingPublicEnrollmentLink, SchedulingRequest
return {
"scheduling_requests": (
@@ -209,6 +246,14 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
.filter(SchedulingNotification.tenant_id == tenant_id, SchedulingNotification.status == "pending")
.count()
),
"scheduling_public_enrollment_links": (
session.query(SchedulingPublicEnrollmentLink)
.filter(
SchedulingPublicEnrollmentLink.tenant_id == tenant_id,
SchedulingPublicEnrollmentLink.revoked_at.is_(None),
)
.count()
),
}
@@ -225,10 +270,27 @@ def _public_tenant_resolver(request: object, session: object) -> str | None:
request_id = str(path_params.get("request_id") or "").strip()
token = str(path_params.get("token") or "").strip()
path = str(getattr(getattr(request, "url", None), "path", ""))
if not request_id or not token or "/scheduling/public/" not in path:
if not request_id or not token:
return None
from govoplan_scheduling.backend.db.models import SchedulingRequest
from govoplan_scheduling.backend.db.models import SchedulingPublicEnrollmentLink, SchedulingRequest
from govoplan_scheduling.backend.security import public_credential_hash
from govoplan_core.db.base import utcnow
if "/scheduling/public-enrollment/" in path:
link = (
session.query(SchedulingPublicEnrollmentLink)
.filter(
SchedulingPublicEnrollmentLink.request_id == request_id,
SchedulingPublicEnrollmentLink.token_hash == public_credential_hash(token),
SchedulingPublicEnrollmentLink.revoked_at.is_(None),
SchedulingPublicEnrollmentLink.expires_at > utcnow(),
)
.one_or_none()
)
return link.tenant_id if link is not None else None
if "/scheduling/public/" not in path:
return None
app = getattr(request, "app", None)
registry = getattr(getattr(app, "state", None), "govoplan_registry", None)
@@ -315,6 +377,11 @@ manifest = ModuleManifest(
component="SchedulingPublicPage",
order=10,
),
PublicFrontendRoute(
path="/scheduling/enrol/:requestId/:token",
component="SchedulingEnrollmentPage",
order=11,
),
),
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),),
product_areas=(
@@ -350,6 +417,7 @@ manifest = ModuleManifest(
scheduling_models.SchedulingRequest,
scheduling_models.SchedulingCandidateSlot,
scheduling_models.SchedulingParticipant,
scheduling_models.SchedulingPublicEnrollmentLink,
scheduling_models.SchedulingNotification,
label="Scheduling",
),
@@ -360,6 +428,7 @@ manifest = ModuleManifest(
scheduling_models.SchedulingRequest,
scheduling_models.SchedulingCandidateSlot,
scheduling_models.SchedulingParticipant,
scheduling_models.SchedulingPublicEnrollmentLink,
scheduling_models.SchedulingNotification,
label="Scheduling",
),
@@ -372,7 +441,7 @@ manifest = ModuleManifest(
documentation_ref="README.md",
test_ref="tests/test_service.py",
known_limits=("Reference deployment notification delivery and every calendar-provider constraint remain incomplete.",),
owned_concepts=("scheduling request", "candidate slot", "scheduling participant", "scheduling decision"),
owned_concepts=("scheduling request", "candidate slot", "scheduling participant", "public self-enrollment link", "scheduling decision"),
non_owned_concepts=("poll response primitive", "calendar event", "mail delivery"),
recovery_docs=("README.md",),
security_docs=("README.md",),