feat: add governed public self-enrollment links
This commit is contained in:
@@ -1,5 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
|
||||
from govoplan_scheduling.backend.db.models import (
|
||||
SchedulingCandidateSlot,
|
||||
SchedulingNotification,
|
||||
SchedulingParticipant,
|
||||
SchedulingPublicEnrollmentLink,
|
||||
SchedulingRequest,
|
||||
)
|
||||
|
||||
__all__ = ["SchedulingCandidateSlot", "SchedulingNotification", "SchedulingParticipant", "SchedulingRequest"]
|
||||
__all__ = [
|
||||
"SchedulingCandidateSlot",
|
||||
"SchedulingNotification",
|
||||
"SchedulingParticipant",
|
||||
"SchedulingPublicEnrollmentLink",
|
||||
"SchedulingRequest",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
@@ -67,6 +67,40 @@ class SchedulingRequest(Base, TimestampMixin):
|
||||
cascade="all, delete-orphan",
|
||||
order_by="SchedulingParticipant.created_at",
|
||||
)
|
||||
enrollment_links: Mapped[list["SchedulingPublicEnrollmentLink"]] = relationship(
|
||||
back_populates="request",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="SchedulingPublicEnrollmentLink.created_at",
|
||||
)
|
||||
|
||||
|
||||
class SchedulingPublicEnrollmentLink(Base, TimestampMixin):
|
||||
"""Reusable public credential that may create bounded participants."""
|
||||
|
||||
__tablename__ = "scheduling_public_enrollment_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("token_hash", name="uq_scheduling_enrollment_link_token_hash"),
|
||||
Index("ix_scheduling_enrollment_links_request", "tenant_id", "request_id"),
|
||||
Index("ix_scheduling_enrollment_links_expiry", "tenant_id", "expires_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
request_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("scheduling_requests.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
max_enrollments: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
allow_anonymous: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
allow_authenticated: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
request: Mapped[SchedulingRequest] = relationship(back_populates="enrollment_links")
|
||||
|
||||
|
||||
class SchedulingCandidateSlot(Base, TimestampMixin):
|
||||
@@ -116,6 +150,14 @@ class SchedulingParticipant(Base, TimestampMixin):
|
||||
status: Mapped[str] = mapped_column(String(40), default="invited", nullable=False, index=True)
|
||||
poll_invitation_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
participation_gateway: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
self_enrollment_link_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("scheduling_public_enrollment_links.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
self_enrollment_proof_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
bound_account_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
account_bound_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
responded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
response_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -150,6 +192,7 @@ __all__ = [
|
||||
"SchedulingCandidateSlot",
|
||||
"SchedulingNotification",
|
||||
"SchedulingParticipant",
|
||||
"SchedulingPublicEnrollmentLink",
|
||||
"SchedulingRequest",
|
||||
"new_uuid",
|
||||
]
|
||||
|
||||
@@ -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",),
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
"""Governed public self-enrollment links.
|
||||
|
||||
Revision ID: d7a4c1e8f205
|
||||
Revises: c9d4e7f1a2b3
|
||||
Create Date: 2026-08-20 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d7a4c1e8f205"
|
||||
down_revision = "c9d4e7f1a2b3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
table_names = set(inspector.get_table_names())
|
||||
if "scheduling_public_enrollment_links" not in table_names:
|
||||
op.create_table(
|
||||
"scheduling_public_enrollment_links",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("request_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("max_enrollments", sa.Integer(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("allow_anonymous", sa.Boolean(), nullable=False),
|
||||
sa.Column("allow_authenticated", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["request_id"],
|
||||
["scheduling_requests.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"token_hash",
|
||||
name="uq_scheduling_enrollment_link_token_hash",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scheduling_enrollment_links_request",
|
||||
"scheduling_public_enrollment_links",
|
||||
["tenant_id", "request_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scheduling_enrollment_links_expiry",
|
||||
"scheduling_public_enrollment_links",
|
||||
["tenant_id", "expires_at"],
|
||||
)
|
||||
for column in ("tenant_id", "request_id", "expires_at", "created_by", "revoked_at"):
|
||||
op.create_index(
|
||||
f"ix_scheduling_public_enrollment_links_{column}",
|
||||
"scheduling_public_enrollment_links",
|
||||
[column],
|
||||
)
|
||||
else:
|
||||
columns = {item["name"] for item in inspector.get_columns("scheduling_public_enrollment_links")}
|
||||
expected = {
|
||||
"id", "tenant_id", "request_id", "token_hash", "max_enrollments",
|
||||
"expires_at", "allow_anonymous", "allow_authenticated", "created_by",
|
||||
"revoked_at", "metadata", "created_at", "updated_at",
|
||||
}
|
||||
if columns != expected:
|
||||
raise RuntimeError(
|
||||
"Cannot adopt scheduling_public_enrollment_links because its schema is unexpected"
|
||||
)
|
||||
|
||||
participant_columns = {
|
||||
item["name"] for item in sa.inspect(op.get_bind()).get_columns("scheduling_participants")
|
||||
}
|
||||
additions = (
|
||||
("self_enrollment_link_id", sa.String(length=36)),
|
||||
("self_enrollment_proof_hash", sa.String(length=64)),
|
||||
("bound_account_id", sa.String(length=255)),
|
||||
("account_bound_at", sa.DateTime(timezone=True)),
|
||||
)
|
||||
present = {name for name, _type in additions if name in participant_columns}
|
||||
if present and len(present) != len(additions):
|
||||
raise RuntimeError(
|
||||
"Cannot adopt partial scheduling participant self-enrollment columns"
|
||||
)
|
||||
if not present:
|
||||
with op.batch_alter_table("scheduling_participants") as batch:
|
||||
batch.add_column(
|
||||
sa.Column("self_enrollment_link_id", sa.String(length=36), nullable=True)
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column("self_enrollment_proof_hash", sa.String(length=64), nullable=True)
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column("account_bound_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
batch.add_column(
|
||||
sa.Column("bound_account_id", sa.String(length=255), nullable=True)
|
||||
)
|
||||
batch.create_foreign_key(
|
||||
"fk_scheduling_participants_enrollment_link",
|
||||
"scheduling_public_enrollment_links",
|
||||
["self_enrollment_link_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
batch.create_index(
|
||||
"ix_scheduling_participants_self_enrollment_link_id",
|
||||
["self_enrollment_link_id"],
|
||||
)
|
||||
batch.create_index(
|
||||
"ix_scheduling_participants_bound_account_id",
|
||||
["bound_account_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("scheduling_participants") as batch:
|
||||
batch.drop_index("ix_scheduling_participants_bound_account_id")
|
||||
batch.drop_index("ix_scheduling_participants_self_enrollment_link_id")
|
||||
batch.drop_constraint(
|
||||
"fk_scheduling_participants_enrollment_link",
|
||||
type_="foreignkey",
|
||||
)
|
||||
batch.drop_column("account_bound_at")
|
||||
batch.drop_column("self_enrollment_proof_hash")
|
||||
batch.drop_column("self_enrollment_link_id")
|
||||
batch.drop_column("bound_account_id")
|
||||
op.drop_table("scheduling_public_enrollment_links")
|
||||
@@ -14,12 +14,17 @@ from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPON
|
||||
from govoplan_scheduling.backend.schemas import (
|
||||
SchedulingAvailabilityResponse,
|
||||
SchedulingAvailabilityResponseRequest,
|
||||
SchedulingAuthenticatedEnrollmentSubmitRequest,
|
||||
SchedulingCalendarActionResponse,
|
||||
SchedulingCandidateSlotUpdateRequest,
|
||||
SchedulingDecisionRequest,
|
||||
SchedulingInvitationActionRequest,
|
||||
SchedulingInvitationActionResponse,
|
||||
SchedulingInvitationRevokeRequest,
|
||||
SchedulingEnrollmentLinkActionResponse,
|
||||
SchedulingEnrollmentLinkCreateRequest,
|
||||
SchedulingEnrollmentLinkListResponse,
|
||||
SchedulingEnrollmentLinkResponse,
|
||||
SchedulingNotificationCreateRequest,
|
||||
SchedulingNotificationListResponse,
|
||||
SchedulingNotificationResponse,
|
||||
@@ -34,6 +39,9 @@ from govoplan_scheduling.backend.schemas import (
|
||||
SchedulingPublicParticipationAccessRequest,
|
||||
SchedulingPublicParticipationResponse,
|
||||
SchedulingPublicParticipationSubmitRequest,
|
||||
SchedulingPublicEnrollmentAccessRequest,
|
||||
SchedulingPublicEnrollmentResponse,
|
||||
SchedulingPublicEnrollmentSubmitRequest,
|
||||
SchedulingStatusResponse,
|
||||
SchedulingSummaryResponse,
|
||||
)
|
||||
@@ -47,6 +55,7 @@ from govoplan_scheduling.backend.service import (
|
||||
close_scheduling_request,
|
||||
create_final_calendar_event,
|
||||
create_scheduling_notification_jobs,
|
||||
create_scheduling_enrollment_link,
|
||||
create_scheduling_request,
|
||||
create_tentative_calendar_holds,
|
||||
decide_scheduling_request,
|
||||
@@ -54,18 +63,25 @@ from govoplan_scheduling.backend.service import (
|
||||
get_scheduling_request,
|
||||
get_scheduling_availability_response,
|
||||
get_public_scheduling_participation,
|
||||
get_public_scheduling_enrollment,
|
||||
get_visible_scheduling_request,
|
||||
list_visible_scheduling_notifications,
|
||||
list_visible_scheduling_requests,
|
||||
list_scheduling_enrollment_links,
|
||||
issue_scheduling_participant_invitation,
|
||||
open_scheduling_request,
|
||||
require_visible_scheduling_results,
|
||||
revoke_scheduling_participant_invitation,
|
||||
revoke_scheduling_enrollment_link,
|
||||
response_datetime,
|
||||
scheduling_enrollment_link_response,
|
||||
scheduling_notification_response,
|
||||
scheduling_request_response,
|
||||
scheduling_request_summary,
|
||||
submit_scheduling_availability,
|
||||
submit_public_scheduling_participation,
|
||||
submit_authenticated_scheduling_enrollment,
|
||||
submit_public_scheduling_enrollment,
|
||||
update_scheduling_candidate_slot,
|
||||
update_scheduling_request_with_change_log,
|
||||
)
|
||||
@@ -253,6 +269,107 @@ def api_submit_public_scheduling_participation(
|
||||
return validated
|
||||
|
||||
|
||||
@router.post(
|
||||
"/public-enrollment/{request_id}/{token}",
|
||||
response_model=SchedulingPublicEnrollmentResponse,
|
||||
)
|
||||
def api_get_public_scheduling_enrollment(
|
||||
request_id: str,
|
||||
token: str,
|
||||
payload: SchedulingPublicEnrollmentAccessRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
) -> SchedulingPublicEnrollmentResponse:
|
||||
try:
|
||||
result = get_public_scheduling_enrollment(
|
||||
session,
|
||||
request_id=request_id,
|
||||
token=token,
|
||||
payload=payload,
|
||||
client_address=_client_address(request),
|
||||
)
|
||||
except SchedulingPublicParticipationError as exc:
|
||||
raise _public_participation_http_error(exc) from exc
|
||||
_set_sensitive_response_headers(response)
|
||||
return SchedulingPublicEnrollmentResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/public-enrollment/{request_id}/{token}/responses",
|
||||
response_model=SchedulingPublicEnrollmentResponse,
|
||||
)
|
||||
def api_submit_public_scheduling_enrollment(
|
||||
request_id: str,
|
||||
token: str,
|
||||
payload: SchedulingPublicEnrollmentSubmitRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
) -> SchedulingPublicEnrollmentResponse:
|
||||
try:
|
||||
result = submit_public_scheduling_enrollment(
|
||||
session,
|
||||
request_id=request_id,
|
||||
token=token,
|
||||
payload=payload,
|
||||
client_address=_client_address(request),
|
||||
)
|
||||
except SchedulingPublicParticipationError as exc:
|
||||
raise _public_participation_http_error(exc) from exc
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
validated = SchedulingPublicEnrollmentResponse.model_validate(result)
|
||||
_set_sensitive_response_headers(response)
|
||||
session.commit()
|
||||
return validated
|
||||
|
||||
|
||||
@router.post(
|
||||
"/public-enrollment/{request_id}/{token}/authenticated-responses",
|
||||
response_model=SchedulingPublicEnrollmentResponse,
|
||||
)
|
||||
def api_submit_authenticated_scheduling_enrollment(
|
||||
request_id: str,
|
||||
token: str,
|
||||
payload: SchedulingAuthenticatedEnrollmentSubmitRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingPublicEnrollmentResponse:
|
||||
_require_scope(principal, RESPOND_SCOPE)
|
||||
try:
|
||||
result = submit_authenticated_scheduling_enrollment(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
token=token,
|
||||
account_id=principal.account_id,
|
||||
account_email=principal.email,
|
||||
payload=payload,
|
||||
client_address=_client_address(request),
|
||||
)
|
||||
except SchedulingPublicParticipationError as exc:
|
||||
raise _public_participation_http_error(exc) from exc
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=(getattr(principal.user, "id", None) or principal.account_id),
|
||||
api_key_id=principal.api_key_id,
|
||||
action="scheduling.self_enrollment_account_bound",
|
||||
object_type="scheduling_request",
|
||||
object_id=request_id,
|
||||
details={"link_id": result["link_id"]},
|
||||
)
|
||||
validated = SchedulingPublicEnrollmentResponse.model_validate(result)
|
||||
_set_sensitive_response_headers(response)
|
||||
session.commit()
|
||||
return validated
|
||||
|
||||
|
||||
@router.get("/people", response_model=SchedulingPeopleSearchResponse)
|
||||
def api_search_scheduling_people(
|
||||
query: str = Query(min_length=1),
|
||||
@@ -443,6 +560,126 @@ def api_update_scheduling_request(
|
||||
return response
|
||||
|
||||
|
||||
@router.get(
|
||||
"/requests/{request_id}/enrollment-links",
|
||||
response_model=SchedulingEnrollmentLinkListResponse,
|
||||
)
|
||||
def api_list_scheduling_enrollment_links(
|
||||
request_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingEnrollmentLinkListResponse:
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
try:
|
||||
links = list_scheduling_enrollment_links(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingEnrollmentLinkListResponse(
|
||||
links=[
|
||||
SchedulingEnrollmentLinkResponse.model_validate(
|
||||
scheduling_enrollment_link_response(session, link)
|
||||
)
|
||||
for link in links
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/requests/{request_id}/enrollment-links",
|
||||
response_model=SchedulingEnrollmentLinkActionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_create_scheduling_enrollment_link(
|
||||
request_id: str,
|
||||
payload: SchedulingEnrollmentLinkCreateRequest,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingEnrollmentLinkActionResponse:
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
try:
|
||||
link, token = create_scheduling_enrollment_link(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
created_by=principal.account_id,
|
||||
payload=payload,
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=(getattr(principal.user, "id", None) or principal.account_id),
|
||||
api_key_id=principal.api_key_id,
|
||||
action="scheduling.self_enrollment_link_issued",
|
||||
object_type="scheduling_request",
|
||||
object_id=request_id,
|
||||
details={
|
||||
"link_id": link.id,
|
||||
"expires_at": response_datetime(link.expires_at).isoformat(),
|
||||
"max_enrollments": link.max_enrollments,
|
||||
"allow_anonymous": link.allow_anonymous,
|
||||
"allow_authenticated": link.allow_authenticated,
|
||||
},
|
||||
)
|
||||
validated = SchedulingEnrollmentLinkActionResponse(
|
||||
link=SchedulingEnrollmentLinkResponse.model_validate(
|
||||
scheduling_enrollment_link_response(session, link)
|
||||
),
|
||||
action_url=f"/scheduling/enrol/{request_id}/{token}",
|
||||
)
|
||||
_set_sensitive_response_headers(response)
|
||||
session.commit()
|
||||
return validated
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/requests/{request_id}/enrollment-links/{link_id}",
|
||||
response_model=SchedulingEnrollmentLinkActionResponse,
|
||||
)
|
||||
def api_revoke_scheduling_enrollment_link(
|
||||
request_id: str,
|
||||
link_id: str,
|
||||
response: Response,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingEnrollmentLinkActionResponse:
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
try:
|
||||
link, replayed = revoke_scheduling_enrollment_link(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
link_id=link_id,
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=(getattr(principal.user, "id", None) or principal.account_id),
|
||||
api_key_id=principal.api_key_id,
|
||||
action="scheduling.self_enrollment_link_revoked",
|
||||
object_type="scheduling_request",
|
||||
object_id=request_id,
|
||||
details={"link_id": link.id, "replayed": replayed},
|
||||
)
|
||||
validated = SchedulingEnrollmentLinkActionResponse(
|
||||
link=SchedulingEnrollmentLinkResponse.model_validate(
|
||||
scheduling_enrollment_link_response(session, link)
|
||||
),
|
||||
replayed=replayed,
|
||||
)
|
||||
_set_sensitive_response_headers(response)
|
||||
session.commit()
|
||||
return validated
|
||||
|
||||
|
||||
@router.post(
|
||||
"/requests/{request_id}/participants/{participant_id}/invitation",
|
||||
response_model=SchedulingInvitationActionResponse,
|
||||
|
||||
@@ -458,6 +458,108 @@ class SchedulingPublicParticipationResponse(BaseModel):
|
||||
slots: list[SchedulingPublicCandidateSlotResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SchedulingEnrollmentLinkCreateRequest(BaseModel):
|
||||
"""Organizer policy for one reusable, bounded self-enrollment link."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expires_at: AwareDatetime
|
||||
max_enrollments: int = Field(ge=1, le=10_000)
|
||||
allow_anonymous: bool = True
|
||||
allow_authenticated: bool = True
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_access_modes(self) -> "SchedulingEnrollmentLinkCreateRequest":
|
||||
if not self.allow_anonymous and not self.allow_authenticated:
|
||||
raise ValueError("At least one enrollment access mode must be enabled")
|
||||
return self
|
||||
|
||||
|
||||
class SchedulingEnrollmentLinkResponse(BaseModel):
|
||||
id: str
|
||||
request_id: str
|
||||
status: Literal["active", "expired", "revoked", "exhausted"]
|
||||
expires_at: datetime
|
||||
max_enrollments: int
|
||||
enrollment_count: int
|
||||
allow_anonymous: bool
|
||||
allow_authenticated: bool
|
||||
created_at: datetime
|
||||
revoked_at: datetime | None = None
|
||||
|
||||
|
||||
class SchedulingEnrollmentLinkListResponse(BaseModel):
|
||||
links: list[SchedulingEnrollmentLinkResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SchedulingEnrollmentLinkActionResponse(BaseModel):
|
||||
link: SchedulingEnrollmentLinkResponse
|
||||
action_url: str | None = None
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
class SchedulingPublicEnrollmentAccessRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
password: SecretStr | None = Field(default=None, max_length=1024)
|
||||
|
||||
|
||||
class SchedulingPublicEnrollmentSubmitRequest(SchedulingAvailabilityResponseRequest):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
display_name: str = Field(min_length=1, max_length=500)
|
||||
email: str | None = Field(default=None, max_length=320)
|
||||
password: SecretStr | None = Field(default=None, max_length=1024)
|
||||
participant_proof: SecretStr = Field(min_length=32, max_length=1024)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
_validate_email = field_validator("email")(_participant_email)
|
||||
|
||||
|
||||
class SchedulingAuthenticatedEnrollmentSubmitRequest(SchedulingAvailabilityResponseRequest):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
display_name: str = Field(min_length=1, max_length=500)
|
||||
email: str | None = Field(default=None, max_length=320)
|
||||
password: SecretStr | None = Field(default=None, max_length=1024)
|
||||
bind_account_confirmed: bool
|
||||
participant_proof: SecretStr | None = Field(default=None, min_length=32, max_length=1024)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
_validate_email = field_validator("email")(_participant_email)
|
||||
|
||||
|
||||
class SchedulingPublicEnrollmentResponse(BaseModel):
|
||||
request_id: str
|
||||
link_id: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
timezone: str
|
||||
status: str
|
||||
deadline_at: datetime | None = None
|
||||
enrollment_expires_at: datetime
|
||||
enrollment_remaining: int
|
||||
display_name_required: bool = True
|
||||
participant_email_required: bool
|
||||
anonymous_allowed: bool
|
||||
authenticated_allowed: bool
|
||||
anonymous_password_required: bool
|
||||
single_choice: bool
|
||||
max_participants_per_option: int | None = None
|
||||
allow_maybe: bool
|
||||
allow_comments: bool
|
||||
allow_participant_updates: bool
|
||||
enrolled: bool = False
|
||||
account_bound: bool = False
|
||||
has_response: bool = False
|
||||
submitted_at: datetime | None = None
|
||||
answers: list[SchedulingAvailabilityAnswerResponse] = Field(default_factory=list)
|
||||
comment: str | None = None
|
||||
replayed: bool = False
|
||||
slots: list[SchedulingPublicCandidateSlotResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SchedulingPollOptionResultResponse(BaseModel):
|
||||
option_id: str
|
||||
option_key: str
|
||||
|
||||
@@ -4,6 +4,7 @@ import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
|
||||
|
||||
_ALGORITHM = "pbkdf2_sha256"
|
||||
@@ -11,6 +12,22 @@ _DEFAULT_ITERATIONS = 260_000
|
||||
_SALT_BYTES = 16
|
||||
|
||||
|
||||
def new_public_credential() -> str:
|
||||
"""Create a URL-safe credential with at least 256 bits of entropy."""
|
||||
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def public_credential_hash(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def verify_public_credential(value: str, expected_hash: str | None) -> bool:
|
||||
if not expected_hash:
|
||||
return False
|
||||
return hmac.compare_digest(public_credential_hash(value), expected_hash)
|
||||
|
||||
|
||||
def hash_participant_password(
|
||||
password: str,
|
||||
*,
|
||||
@@ -58,4 +75,10 @@ def verify_participant_password(password: str, encoded: str | None) -> bool:
|
||||
return hmac.compare_digest(actual, expected)
|
||||
|
||||
|
||||
__all__ = ["hash_participant_password", "verify_participant_password"]
|
||||
__all__ = [
|
||||
"hash_participant_password",
|
||||
"new_public_credential",
|
||||
"public_credential_hash",
|
||||
"verify_participant_password",
|
||||
"verify_public_credential",
|
||||
]
|
||||
|
||||
@@ -55,13 +55,23 @@ from govoplan_core.core.throttling import (
|
||||
build_fixed_window_throttle,
|
||||
)
|
||||
from govoplan_core.db.base import utcnow
|
||||
from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
|
||||
from govoplan_scheduling.backend.db.models import (
|
||||
SchedulingCandidateSlot,
|
||||
SchedulingNotification,
|
||||
SchedulingParticipant,
|
||||
SchedulingPublicEnrollmentLink,
|
||||
SchedulingRequest,
|
||||
)
|
||||
from govoplan_scheduling.backend.schemas import (
|
||||
SchedulingAvailabilityResponseRequest,
|
||||
SchedulingCandidateSlotReconcileInput,
|
||||
SchedulingCandidateSlotUpdateRequest,
|
||||
SchedulingDecisionRequest,
|
||||
SchedulingParticipantReconcileInput,
|
||||
SchedulingAuthenticatedEnrollmentSubmitRequest,
|
||||
SchedulingEnrollmentLinkCreateRequest,
|
||||
SchedulingPublicEnrollmentAccessRequest,
|
||||
SchedulingPublicEnrollmentSubmitRequest,
|
||||
SchedulingPublicParticipationAccessRequest,
|
||||
SchedulingPublicParticipationSubmitRequest,
|
||||
SchedulingRequestCreateRequest,
|
||||
@@ -70,7 +80,10 @@ from govoplan_scheduling.backend.schemas import (
|
||||
from govoplan_scheduling.backend.runtime import get_registry, get_settings
|
||||
from govoplan_scheduling.backend.security import (
|
||||
hash_participant_password,
|
||||
new_public_credential,
|
||||
public_credential_hash,
|
||||
verify_participant_password,
|
||||
verify_public_credential,
|
||||
)
|
||||
|
||||
|
||||
@@ -137,6 +150,9 @@ SCHEDULING_PARTICIPATION_GATEWAY = "scheduling"
|
||||
PARTICIPATION_PASSWORD_ATTEMPT_LIMIT = 10
|
||||
PARTICIPATION_PASSWORD_REQUEST_LIMIT = 100
|
||||
PARTICIPATION_PASSWORD_WINDOW_SECONDS = 15 * 60
|
||||
SELF_ENROLLMENT_ATTEMPT_LIMIT = 50
|
||||
SELF_ENROLLMENT_IDENTITY_LIMIT = 10
|
||||
SELF_ENROLLMENT_WINDOW_SECONDS = 15 * 60
|
||||
|
||||
|
||||
def response_datetime(value: datetime | None) -> datetime | None:
|
||||
@@ -191,6 +207,8 @@ def scheduling_participant_revision(participant: SchedulingParticipant) -> str:
|
||||
"status": participant.status,
|
||||
"poll_invitation_id": participant.poll_invitation_id,
|
||||
"participation_gateway": participant.participation_gateway,
|
||||
"self_enrollment_link_id": participant.self_enrollment_link_id,
|
||||
"bound_account_id": participant.bound_account_id,
|
||||
"metadata": participant.metadata_ or {},
|
||||
}
|
||||
encoded = json.dumps(
|
||||
@@ -1528,7 +1546,10 @@ def _participant_matches_actor(
|
||||
return _identity_matches_actor(
|
||||
participant.respondent_id,
|
||||
actor_ids,
|
||||
) or _identity_matches_actor(participant.email, actor_ids)
|
||||
) or _identity_matches_actor(
|
||||
participant.email,
|
||||
actor_ids,
|
||||
) or _identity_matches_actor(participant.bound_account_id, actor_ids)
|
||||
|
||||
|
||||
def scheduling_request_is_visible(
|
||||
@@ -1583,6 +1604,7 @@ def list_visible_scheduling_requests(
|
||||
SchedulingParticipant.deleted_at.is_(None),
|
||||
or_(
|
||||
SchedulingParticipant.respondent_id.in_(ids or ("",)),
|
||||
SchedulingParticipant.bound_account_id.in_(ids or ("",)),
|
||||
func.lower(SchedulingParticipant.email).in_(
|
||||
email_ids or ("",)
|
||||
),
|
||||
@@ -1988,6 +2010,686 @@ def _availability_response_answers(
|
||||
return answers
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _configured_self_enrollment_throttle(
|
||||
redis_url: str | None,
|
||||
) -> FixedWindowThrottle:
|
||||
return build_fixed_window_throttle(
|
||||
redis_url=redis_url,
|
||||
window_seconds=SELF_ENROLLMENT_WINDOW_SECONDS,
|
||||
key_prefix="govoplan:scheduling:self-enrollment:v1",
|
||||
)
|
||||
|
||||
|
||||
def _self_enrollment_throttle() -> FixedWindowThrottle:
|
||||
configured_url = getattr(get_settings(), "redis_url", None)
|
||||
redis_url = configured_url if isinstance(configured_url, str) else None
|
||||
return _configured_self_enrollment_throttle(redis_url)
|
||||
|
||||
|
||||
def _self_enrollment_dimensions(
|
||||
link: SchedulingPublicEnrollmentLink,
|
||||
*,
|
||||
token: str,
|
||||
client_address: str | None,
|
||||
identity: str | None = None,
|
||||
) -> tuple[ThrottleDimension, ...]:
|
||||
dimensions = [
|
||||
ThrottleDimension(
|
||||
namespace="public-self-enrollment-request",
|
||||
subject=":".join(
|
||||
(
|
||||
link.tenant_id,
|
||||
link.request_id,
|
||||
participation_token_fingerprint(token),
|
||||
client_address or "unknown-client",
|
||||
)
|
||||
),
|
||||
limit=SELF_ENROLLMENT_ATTEMPT_LIMIT,
|
||||
)
|
||||
]
|
||||
if identity:
|
||||
dimensions.append(
|
||||
ThrottleDimension(
|
||||
namespace="public-self-enrollment-identity",
|
||||
subject=":".join(
|
||||
(
|
||||
link.tenant_id,
|
||||
link.request_id,
|
||||
public_credential_hash(identity.strip().casefold()),
|
||||
)
|
||||
),
|
||||
limit=SELF_ENROLLMENT_IDENTITY_LIMIT,
|
||||
)
|
||||
)
|
||||
return tuple(dimensions)
|
||||
|
||||
|
||||
def _require_self_enrollment_enabled() -> None:
|
||||
if getattr(get_settings(), "scheduling_public_self_enrollment_enabled", True) is False:
|
||||
raise SchedulingError("Public self-enrollment is disabled by deployment policy")
|
||||
|
||||
|
||||
def _self_enrollment_count(
|
||||
session: Session,
|
||||
link: SchedulingPublicEnrollmentLink,
|
||||
) -> int:
|
||||
return (
|
||||
session.query(SchedulingParticipant)
|
||||
.filter(
|
||||
SchedulingParticipant.tenant_id == link.tenant_id,
|
||||
SchedulingParticipant.request_id == link.request_id,
|
||||
SchedulingParticipant.self_enrollment_link_id == link.id,
|
||||
SchedulingParticipant.deleted_at.is_(None),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
|
||||
def _self_enrollment_link_status(
|
||||
link: SchedulingPublicEnrollmentLink,
|
||||
*,
|
||||
enrollment_count: int,
|
||||
) -> str:
|
||||
if link.revoked_at is not None:
|
||||
return "revoked"
|
||||
if response_datetime(link.expires_at) <= _now():
|
||||
return "expired"
|
||||
if enrollment_count >= link.max_enrollments:
|
||||
return "exhausted"
|
||||
return "active"
|
||||
|
||||
|
||||
def scheduling_enrollment_link_response(
|
||||
session: Session,
|
||||
link: SchedulingPublicEnrollmentLink,
|
||||
) -> dict[str, Any]:
|
||||
enrollment_count = _self_enrollment_count(session, link)
|
||||
return {
|
||||
"id": link.id,
|
||||
"request_id": link.request_id,
|
||||
"status": _self_enrollment_link_status(
|
||||
link,
|
||||
enrollment_count=enrollment_count,
|
||||
),
|
||||
"expires_at": response_datetime(link.expires_at),
|
||||
"max_enrollments": link.max_enrollments,
|
||||
"enrollment_count": enrollment_count,
|
||||
"allow_anonymous": link.allow_anonymous,
|
||||
"allow_authenticated": link.allow_authenticated,
|
||||
"created_at": response_datetime(link.created_at),
|
||||
"revoked_at": response_datetime(link.revoked_at),
|
||||
}
|
||||
|
||||
|
||||
def list_scheduling_enrollment_links(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
request_id: str,
|
||||
) -> list[SchedulingPublicEnrollmentLink]:
|
||||
get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
|
||||
return (
|
||||
session.query(SchedulingPublicEnrollmentLink)
|
||||
.filter(
|
||||
SchedulingPublicEnrollmentLink.tenant_id == tenant_id,
|
||||
SchedulingPublicEnrollmentLink.request_id == request_id,
|
||||
)
|
||||
.order_by(SchedulingPublicEnrollmentLink.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def create_scheduling_enrollment_link(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
request_id: str,
|
||||
created_by: str | None,
|
||||
payload: SchedulingEnrollmentLinkCreateRequest,
|
||||
) -> tuple[SchedulingPublicEnrollmentLink, str]:
|
||||
_require_self_enrollment_enabled()
|
||||
request = _lock_scheduling_request(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
request_id=request_id,
|
||||
lock_participants=True,
|
||||
)
|
||||
_require_scheduling_response_collection_open(request)
|
||||
expires_at = response_datetime(payload.expires_at)
|
||||
if expires_at is None or expires_at <= _now():
|
||||
raise SchedulingError("Self-enrollment link expiry must be in the future")
|
||||
deadline = response_datetime(request.deadline_at)
|
||||
if deadline is not None and expires_at > deadline:
|
||||
raise SchedulingError("Self-enrollment link expiry cannot exceed the response deadline")
|
||||
configured_max = getattr(
|
||||
get_settings(),
|
||||
"scheduling_public_self_enrollment_max_capacity",
|
||||
10_000,
|
||||
)
|
||||
try:
|
||||
max_capacity = max(1, min(int(configured_max), 10_000))
|
||||
except (TypeError, ValueError):
|
||||
max_capacity = 10_000
|
||||
if payload.max_enrollments > max_capacity:
|
||||
raise SchedulingError(
|
||||
f"Self-enrollment capacity exceeds the deployment maximum of {max_capacity}"
|
||||
)
|
||||
if payload.allow_anonymous and not request.allow_external_participants:
|
||||
raise SchedulingError("Anonymous self-enrollment requires external participants")
|
||||
token = new_public_credential()
|
||||
link = SchedulingPublicEnrollmentLink(
|
||||
tenant_id=tenant_id,
|
||||
request_id=request.id,
|
||||
token_hash=public_credential_hash(token),
|
||||
max_enrollments=payload.max_enrollments,
|
||||
expires_at=expires_at,
|
||||
allow_anonymous=payload.allow_anonymous,
|
||||
allow_authenticated=payload.allow_authenticated,
|
||||
created_by=created_by,
|
||||
metadata_={"policy_version": 1},
|
||||
)
|
||||
session.add(link)
|
||||
session.flush()
|
||||
return link, token
|
||||
|
||||
|
||||
def revoke_scheduling_enrollment_link(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
request_id: str,
|
||||
link_id: str,
|
||||
) -> tuple[SchedulingPublicEnrollmentLink, bool]:
|
||||
get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
|
||||
link = (
|
||||
session.query(SchedulingPublicEnrollmentLink)
|
||||
.filter(
|
||||
SchedulingPublicEnrollmentLink.id == link_id,
|
||||
SchedulingPublicEnrollmentLink.tenant_id == tenant_id,
|
||||
SchedulingPublicEnrollmentLink.request_id == request_id,
|
||||
)
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
if link is None:
|
||||
raise SchedulingError("Self-enrollment link not found")
|
||||
replayed = link.revoked_at is not None
|
||||
if not replayed:
|
||||
link.revoked_at = _now()
|
||||
session.flush()
|
||||
return link, replayed
|
||||
|
||||
|
||||
def _resolve_self_enrollment_link(
|
||||
session: Session,
|
||||
*,
|
||||
request_id: str,
|
||||
token: str,
|
||||
client_address: str | None,
|
||||
password: Any,
|
||||
lock_for_submission: bool = False,
|
||||
identity: str | None = None,
|
||||
) -> tuple[SchedulingRequest, SchedulingPublicEnrollmentLink]:
|
||||
request = _public_scheduling_request(session, request_id=request_id)
|
||||
if lock_for_submission:
|
||||
request = _lock_public_scheduling_submission(session, request)
|
||||
query = session.query(SchedulingPublicEnrollmentLink).filter(
|
||||
SchedulingPublicEnrollmentLink.request_id == request.id,
|
||||
SchedulingPublicEnrollmentLink.tenant_id == request.tenant_id,
|
||||
SchedulingPublicEnrollmentLink.token_hash == public_credential_hash(token),
|
||||
)
|
||||
if lock_for_submission:
|
||||
query = query.with_for_update()
|
||||
link = query.one_or_none()
|
||||
if link is None:
|
||||
raise SchedulingPublicParticipationError()
|
||||
enrollment_count = _self_enrollment_count(session, link)
|
||||
if _self_enrollment_link_status(
|
||||
link,
|
||||
enrollment_count=enrollment_count,
|
||||
) in {"expired", "revoked"}:
|
||||
raise SchedulingPublicParticipationError()
|
||||
dimensions = _self_enrollment_dimensions(
|
||||
link,
|
||||
token=token,
|
||||
client_address=client_address,
|
||||
identity=identity,
|
||||
)
|
||||
throttle = _self_enrollment_throttle()
|
||||
decision = throttle.check(dimensions)
|
||||
if not decision.allowed:
|
||||
raise SchedulingPublicParticipationError(
|
||||
retry_after_seconds=decision.retry_after_seconds
|
||||
)
|
||||
access = SchedulingPublicParticipationAccessRequest(password=password)
|
||||
try:
|
||||
password_dimensions = _verify_public_participation_password(
|
||||
request,
|
||||
token=token,
|
||||
payload=access,
|
||||
client_address=client_address,
|
||||
)
|
||||
except SchedulingPublicParticipationError:
|
||||
decision = throttle.record(dimensions)
|
||||
retry_after = decision.retry_after_seconds if not decision.allowed else 0
|
||||
raise SchedulingPublicParticipationError(retry_after_seconds=retry_after) from None
|
||||
if password_dimensions:
|
||||
_participation_password_throttle().reset(password_dimensions[:1])
|
||||
if lock_for_submission:
|
||||
decision = throttle.record(dimensions)
|
||||
if not decision.allowed:
|
||||
raise SchedulingPublicParticipationError(
|
||||
retry_after_seconds=decision.retry_after_seconds
|
||||
)
|
||||
return request, link
|
||||
|
||||
|
||||
def _self_enrollment_participant_by_proof(
|
||||
request: SchedulingRequest,
|
||||
link: SchedulingPublicEnrollmentLink,
|
||||
proof: str,
|
||||
) -> SchedulingParticipant | None:
|
||||
matches = [
|
||||
participant
|
||||
for participant in _active_participants(request)
|
||||
if participant.self_enrollment_link_id == link.id
|
||||
and verify_public_credential(proof, participant.self_enrollment_proof_hash)
|
||||
]
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def _self_enrollment_participant_by_account(
|
||||
request: SchedulingRequest,
|
||||
link: SchedulingPublicEnrollmentLink,
|
||||
account_id: str,
|
||||
) -> SchedulingParticipant | None:
|
||||
matches = [
|
||||
participant
|
||||
for participant in _active_participants(request)
|
||||
if participant.self_enrollment_link_id == link.id
|
||||
and participant.bound_account_id == account_id
|
||||
]
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def _create_self_enrolled_participant(
|
||||
session: Session,
|
||||
*,
|
||||
request: SchedulingRequest,
|
||||
link: SchedulingPublicEnrollmentLink,
|
||||
display_name: str,
|
||||
email: str | None,
|
||||
proof: str | None,
|
||||
account_id: str | None,
|
||||
) -> SchedulingParticipant:
|
||||
if _self_enrollment_count(session, link) >= link.max_enrollments:
|
||||
raise SchedulingConflictError("Self-enrollment capacity has been reached")
|
||||
normalized_email = email.strip().casefold() if email else None
|
||||
if request.participant_email_required and normalized_email is None:
|
||||
raise SchedulingError("Participant email is required")
|
||||
if normalized_email is not None and any(
|
||||
(participant.email or "").strip().casefold() == normalized_email
|
||||
for participant in _active_participants(request)
|
||||
):
|
||||
raise SchedulingPublicParticipationError()
|
||||
if account_id is not None and any(
|
||||
participant.bound_account_id == account_id
|
||||
for participant in _active_participants(request)
|
||||
):
|
||||
raise SchedulingPublicParticipationError()
|
||||
participant = SchedulingParticipant(
|
||||
tenant_id=request.tenant_id,
|
||||
request_id=request.id,
|
||||
respondent_id=account_id,
|
||||
bound_account_id=account_id,
|
||||
account_bound_at=_now() if account_id else None,
|
||||
display_name=display_name.strip(),
|
||||
email=normalized_email,
|
||||
participant_type="internal" if account_id else "external",
|
||||
required=False,
|
||||
status="invited",
|
||||
participation_gateway=SCHEDULING_PARTICIPATION_GATEWAY,
|
||||
self_enrollment_link_id=link.id,
|
||||
self_enrollment_proof_hash=(
|
||||
public_credential_hash(proof) if proof is not None else None
|
||||
),
|
||||
metadata_={"self_enrollment_policy_version": 1},
|
||||
)
|
||||
session.add(participant)
|
||||
session.flush()
|
||||
respondent_id = _stable_participant_respondent_id(participant)
|
||||
provider = _poll_participation_provider()
|
||||
if provider is None or request.poll_id is None:
|
||||
raise SchedulingError("Poll governed participation capability is unavailable")
|
||||
try:
|
||||
invitation = provider.create_governed_invitation(
|
||||
session,
|
||||
tenant_id=request.tenant_id,
|
||||
poll_id=request.poll_id,
|
||||
command=PollGovernedInvitationCommand(
|
||||
gateway=_public_participation_gateway(request.id),
|
||||
policy=_public_participation_policy(request),
|
||||
respondent_id=respondent_id,
|
||||
respondent_label=participant.display_name,
|
||||
email=participant.email,
|
||||
expires_at=min(
|
||||
value
|
||||
for value in (
|
||||
response_datetime(link.expires_at),
|
||||
response_datetime(request.deadline_at),
|
||||
)
|
||||
if value is not None
|
||||
),
|
||||
metadata={
|
||||
"scheduling_request_id": request.id,
|
||||
"scheduling_participant_id": participant.id,
|
||||
"self_enrollment_link_id": link.id,
|
||||
"public_link_issued": False,
|
||||
},
|
||||
),
|
||||
)
|
||||
except PollCapabilityError as exc:
|
||||
raise SchedulingError(str(exc)) from exc
|
||||
participant.poll_invitation_id = invitation.id
|
||||
return participant
|
||||
|
||||
|
||||
def _self_enrollment_poll_response(
|
||||
session: Session,
|
||||
*,
|
||||
request: SchedulingRequest,
|
||||
participant: SchedulingParticipant,
|
||||
) -> PollGovernedResponseRef | None:
|
||||
if request.poll_id is None or participant.poll_invitation_id is None:
|
||||
return None
|
||||
provider = _poll_participation_provider()
|
||||
if provider is None:
|
||||
raise SchedulingError("Poll governed participation capability is unavailable")
|
||||
try:
|
||||
context = provider.resolve_authenticated_participation(
|
||||
session,
|
||||
tenant_id=request.tenant_id,
|
||||
poll_id=request.poll_id,
|
||||
invitation_id=participant.poll_invitation_id,
|
||||
gateway=_public_participation_gateway(request.id),
|
||||
respondent_id=_stable_participant_respondent_id(participant),
|
||||
)
|
||||
except PollCapabilityError as exc:
|
||||
raise SchedulingPublicParticipationError() from exc
|
||||
return context.response
|
||||
|
||||
|
||||
def _self_enrollment_response(
|
||||
session: Session,
|
||||
*,
|
||||
request: SchedulingRequest,
|
||||
link: SchedulingPublicEnrollmentLink,
|
||||
participant: SchedulingParticipant | None = None,
|
||||
response: PollGovernedResponseRef | None = None,
|
||||
) -> dict[str, Any]:
|
||||
current_response = response
|
||||
if participant is not None and current_response is None:
|
||||
current_response = _self_enrollment_poll_response(
|
||||
session,
|
||||
request=request,
|
||||
participant=participant,
|
||||
)
|
||||
enrollment_count = _self_enrollment_count(session, link)
|
||||
poll_response = current_response.response if current_response is not None else None
|
||||
return {
|
||||
"request_id": request.id,
|
||||
"link_id": link.id,
|
||||
"title": request.title,
|
||||
"description": request.description,
|
||||
"location": request.location,
|
||||
"timezone": request.timezone,
|
||||
"status": request.status,
|
||||
"deadline_at": response_datetime(request.deadline_at),
|
||||
"enrollment_expires_at": response_datetime(link.expires_at),
|
||||
"enrollment_remaining": max(0, link.max_enrollments - enrollment_count),
|
||||
"display_name_required": True,
|
||||
"participant_email_required": request.participant_email_required,
|
||||
"anonymous_allowed": link.allow_anonymous,
|
||||
"authenticated_allowed": link.allow_authenticated,
|
||||
"anonymous_password_required": request.anonymous_password_protection_enabled,
|
||||
"single_choice": request.single_choice,
|
||||
"max_participants_per_option": request.max_participants_per_option,
|
||||
"allow_maybe": request.allow_maybe,
|
||||
"allow_comments": request.allow_comments,
|
||||
"allow_participant_updates": request.allow_participant_updates,
|
||||
"enrolled": participant is not None,
|
||||
"account_bound": participant is not None and participant.bound_account_id is not None,
|
||||
"has_response": current_response is not None,
|
||||
"submitted_at": (
|
||||
response_datetime(poll_response.submitted_at)
|
||||
if poll_response is not None
|
||||
else None
|
||||
),
|
||||
"answers": (
|
||||
_availability_response_answers(request, poll_response)
|
||||
if poll_response is not None
|
||||
else []
|
||||
),
|
||||
"comment": current_response.comment if current_response is not None else None,
|
||||
"replayed": current_response.replayed if current_response is not None else False,
|
||||
"slots": [
|
||||
{
|
||||
"id": slot.id,
|
||||
"label": slot.label,
|
||||
"description": slot.description,
|
||||
"start_at": response_datetime(slot.start_at),
|
||||
"end_at": response_datetime(slot.end_at),
|
||||
"timezone": slot.timezone,
|
||||
"location": slot.location,
|
||||
"position": slot.position,
|
||||
"revision": scheduling_slot_revision(slot),
|
||||
}
|
||||
for slot in _active_slots(request)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_public_scheduling_enrollment(
|
||||
session: Session,
|
||||
*,
|
||||
request_id: str,
|
||||
token: str,
|
||||
payload: SchedulingPublicEnrollmentAccessRequest,
|
||||
client_address: str | None,
|
||||
) -> dict[str, Any]:
|
||||
request, link = _resolve_self_enrollment_link(
|
||||
session,
|
||||
request_id=request_id,
|
||||
token=token,
|
||||
client_address=client_address,
|
||||
password=payload.password,
|
||||
)
|
||||
return _self_enrollment_response(
|
||||
session,
|
||||
request=request,
|
||||
link=link,
|
||||
)
|
||||
|
||||
|
||||
def _submit_self_enrollment_response(
|
||||
session: Session,
|
||||
*,
|
||||
request: SchedulingRequest,
|
||||
link: SchedulingPublicEnrollmentLink,
|
||||
participant: SchedulingParticipant,
|
||||
payload: SchedulingPublicEnrollmentSubmitRequest | SchedulingAuthenticatedEnrollmentSubmitRequest,
|
||||
) -> dict[str, Any]:
|
||||
if participant.status == "responded" and not request.allow_participant_updates:
|
||||
raise SchedulingConflictError("Participant responses cannot be updated")
|
||||
answers: list[PollAnswerRequest] = []
|
||||
for answer in payload.answers:
|
||||
slot = _selected_slot(request, slot_id=answer.slot_id)
|
||||
if slot.poll_option_id is None:
|
||||
raise SchedulingError("Scheduling slot has no backing poll option")
|
||||
if answer.option_revision != scheduling_slot_revision(slot):
|
||||
raise SchedulingConflictError(
|
||||
"Scheduling options changed after this response form was loaded; reload before responding"
|
||||
)
|
||||
answers.append(PollAnswerRequest(option_id=slot.poll_option_id, value=answer.value))
|
||||
provider = _poll_participation_provider()
|
||||
if provider is None or request.poll_id is None or participant.poll_invitation_id is None:
|
||||
raise SchedulingError("Poll governed participation capability is unavailable")
|
||||
try:
|
||||
governed_response = provider.submit_authenticated_response(
|
||||
session,
|
||||
tenant_id=request.tenant_id,
|
||||
poll_id=request.poll_id,
|
||||
invitation_id=participant.poll_invitation_id,
|
||||
gateway=_public_participation_gateway(request.id),
|
||||
respondent_id=_stable_participant_respondent_id(participant),
|
||||
command=PollGovernedResponseCommand(
|
||||
respondent_id=_stable_participant_respondent_id(participant),
|
||||
respondent_label=participant.display_name,
|
||||
participant_email=participant.email,
|
||||
# Scheduling has authenticated this pseudonymous participant
|
||||
# with either the recovery proof or a bound account before
|
||||
# invoking Poll's non-token in-process contract.
|
||||
participant_is_authenticated=True,
|
||||
answers=tuple(answers),
|
||||
comment=payload.comment,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
metadata={
|
||||
"scheduling_participant_id": participant.id,
|
||||
"self_enrollment_link_id": link.id,
|
||||
},
|
||||
),
|
||||
)
|
||||
except PollCapabilityError as exc:
|
||||
message = str(exc)
|
||||
if message == "Poll invitation not found":
|
||||
raise SchedulingPublicParticipationError() from exc
|
||||
if "Participant limit reached" in message or "Idempotency key" in message:
|
||||
raise SchedulingConflictError(message) from exc
|
||||
raise SchedulingError(message) from exc
|
||||
participant.status = "responded"
|
||||
participant.responded_at = response_datetime(governed_response.response.submitted_at)
|
||||
participant.response_comment = governed_response.comment
|
||||
if request.notify_on_answers and not governed_response.replayed:
|
||||
_emit_scheduling_center_notification(
|
||||
session,
|
||||
request=request,
|
||||
participant=participant,
|
||||
event_kind="scheduling.participant_self_enrolled",
|
||||
subject=f"Scheduling self-enrollment: {request.title}",
|
||||
body_text=f"{participant.display_name or 'A participant'} self-enrolled and responded.",
|
||||
)
|
||||
session.flush()
|
||||
return _self_enrollment_response(
|
||||
session,
|
||||
request=request,
|
||||
link=link,
|
||||
participant=participant,
|
||||
response=governed_response,
|
||||
)
|
||||
|
||||
|
||||
def submit_public_scheduling_enrollment(
|
||||
session: Session,
|
||||
*,
|
||||
request_id: str,
|
||||
token: str,
|
||||
payload: SchedulingPublicEnrollmentSubmitRequest,
|
||||
client_address: str | None,
|
||||
) -> dict[str, Any]:
|
||||
proof = payload.participant_proof.get_secret_value()
|
||||
request, link = _resolve_self_enrollment_link(
|
||||
session,
|
||||
request_id=request_id,
|
||||
token=token,
|
||||
client_address=client_address,
|
||||
password=payload.password,
|
||||
lock_for_submission=True,
|
||||
identity=payload.email or proof,
|
||||
)
|
||||
if not link.allow_anonymous:
|
||||
raise SchedulingPublicParticipationError()
|
||||
participant = _self_enrollment_participant_by_proof(request, link, proof)
|
||||
if participant is None:
|
||||
participant = _create_self_enrolled_participant(
|
||||
session,
|
||||
request=request,
|
||||
link=link,
|
||||
display_name=payload.display_name,
|
||||
email=payload.email,
|
||||
proof=proof,
|
||||
account_id=None,
|
||||
)
|
||||
elif (
|
||||
participant.display_name != payload.display_name.strip()
|
||||
or (participant.email or None) != (payload.email.strip().casefold() if payload.email else None)
|
||||
):
|
||||
raise SchedulingPublicParticipationError()
|
||||
return _submit_self_enrollment_response(
|
||||
session,
|
||||
request=request,
|
||||
link=link,
|
||||
participant=participant,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def submit_authenticated_scheduling_enrollment(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
request_id: str,
|
||||
token: str,
|
||||
account_id: str,
|
||||
account_email: str | None,
|
||||
payload: SchedulingAuthenticatedEnrollmentSubmitRequest,
|
||||
client_address: str | None,
|
||||
) -> dict[str, Any]:
|
||||
if not payload.bind_account_confirmed:
|
||||
raise SchedulingError("Account binding must be confirmed")
|
||||
request, link = _resolve_self_enrollment_link(
|
||||
session,
|
||||
request_id=request_id,
|
||||
token=token,
|
||||
client_address=client_address,
|
||||
password=payload.password,
|
||||
lock_for_submission=True,
|
||||
identity=account_id,
|
||||
)
|
||||
if request.tenant_id != tenant_id or not link.allow_authenticated:
|
||||
raise SchedulingPublicParticipationError()
|
||||
participant = _self_enrollment_participant_by_account(request, link, account_id)
|
||||
if participant is None and payload.participant_proof is not None:
|
||||
participant = _self_enrollment_participant_by_proof(
|
||||
request,
|
||||
link,
|
||||
payload.participant_proof.get_secret_value(),
|
||||
)
|
||||
if participant is None:
|
||||
raise SchedulingPublicParticipationError()
|
||||
participant.bound_account_id = account_id
|
||||
participant.account_bound_at = _now()
|
||||
if participant is None:
|
||||
participant = _create_self_enrolled_participant(
|
||||
session,
|
||||
request=request,
|
||||
link=link,
|
||||
display_name=payload.display_name,
|
||||
email=payload.email or account_email,
|
||||
proof=None,
|
||||
account_id=account_id,
|
||||
)
|
||||
elif participant.display_name != payload.display_name.strip():
|
||||
raise SchedulingPublicParticipationError()
|
||||
return _submit_self_enrollment_response(
|
||||
session,
|
||||
request=request,
|
||||
link=link,
|
||||
participant=participant,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_public_scheduling_participation(
|
||||
session: Session,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user