feat(scheduling): enforce governed response policies
This commit is contained in:
@@ -37,6 +37,14 @@ class SchedulingRequest(Base, TimestampMixin):
|
||||
allow_participant_updates: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
result_visibility: Mapped[str] = mapped_column(String(30), default="after_close", nullable=False)
|
||||
participant_visibility: Mapped[str] = mapped_column(String(32), default="aggregates_only", nullable=False)
|
||||
notify_on_answers: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
single_choice: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
max_participants_per_option: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
allow_maybe: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
allow_comments: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
participant_email_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
anonymous_password_protection_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
anonymous_password_hash: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
calendar_integration_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
calendar_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
calendar_freebusy_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
@@ -106,8 +114,10 @@ class SchedulingParticipant(Base, TimestampMixin):
|
||||
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
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)
|
||||
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)
|
||||
deleted_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)
|
||||
|
||||
|
||||
@@ -20,11 +20,12 @@ from govoplan_core.core.modules import (
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
|
||||
from govoplan_core.core.policy import CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY
|
||||
from govoplan_poll.backend.participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY
|
||||
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
|
||||
|
||||
MODULE_ID = "scheduling"
|
||||
MODULE_NAME = "Scheduling"
|
||||
MODULE_VERSION = "0.1.9"
|
||||
MODULE_VERSION = "0.1.10"
|
||||
READ_SCOPE = "scheduling:schedule:read"
|
||||
WRITE_SCOPE = "scheduling:schedule:write"
|
||||
ADMIN_SCOPE = "scheduling:schedule:admin"
|
||||
@@ -47,8 +48,8 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(READ_SCOPE, "View scheduling", "Read scheduling polls, proposals, participant state, and selected outcomes."),
|
||||
_permission(WRITE_SCOPE, "Manage scheduling", "Create and update scheduling polls, candidate slots, reminders, and decision handoff."),
|
||||
_permission(ADMIN_SCOPE, "Administer scheduling", "Configure tenant-level scheduling policies, external participation, and retention defaults."),
|
||||
_permission(WRITE_SCOPE, "Manage own scheduling", "Create scheduling polls and manage requests for which the account is the organizer."),
|
||||
_permission(ADMIN_SCOPE, "Administer scheduling", "Manage every tenant scheduling request and configure scheduling policies, external participation, and retention defaults."),
|
||||
_permission(RESPOND_SCOPE, "Respond to scheduling polls", "Submit and update own scheduling availability responses."),
|
||||
)
|
||||
|
||||
@@ -56,7 +57,7 @@ ROLE_TEMPLATES = (
|
||||
RoleTemplate(
|
||||
slug="scheduling_manager",
|
||||
name="Scheduling manager",
|
||||
description="Create scheduling polls, manage candidate slots, and decide outcomes.",
|
||||
description="Create scheduling polls and manage candidate slots and outcomes for requests the account organizes.",
|
||||
permissions=(READ_SCOPE, WRITE_SCOPE, RESPOND_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
@@ -137,16 +138,19 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_CALENDAR_SCHEDULING,
|
||||
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
|
||||
),
|
||||
required_capabilities=(CAPABILITY_POLL_SCHEDULING,),
|
||||
required_capabilities=(
|
||||
CAPABILITY_POLL_SCHEDULING,
|
||||
CAPABILITY_POLL_PARTICIPATION_GATEWAY,
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.9"),
|
||||
ModuleInterfaceProvider(name="scheduling.decision_handoff", version="0.1.9"),
|
||||
ModuleInterfaceProvider(name="scheduling.candidate_slots", version=MODULE_VERSION),
|
||||
ModuleInterfaceProvider(name="scheduling.decision_handoff", version=MODULE_VERSION),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(name="poll.availability_matrix", version_min="0.1.9", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.response_collection", version_min="0.1.9", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.9", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.signed_participation", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
||||
ModuleInterfaceRequirement(name="poll.availability_matrix", version_min="0.1.10", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.response_collection", version_min="0.1.10", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.10", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.governed_participation", version_min="0.1.10", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="evaluation.feedback", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
||||
ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
||||
ModuleInterfaceRequirement(name="addresses.lookup", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""v0.1.10 scheduling response settings
|
||||
|
||||
Revision ID: ad7e3c9b2f10
|
||||
Revises: 9c2f4a7d1e6b
|
||||
Create Date: 2026-07-21 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "ad7e3c9b2f10"
|
||||
down_revision = "9c2f4a7d1e6b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_REQUEST_COLUMNS = (
|
||||
"notify_on_answers",
|
||||
"single_choice",
|
||||
"max_participants_per_option",
|
||||
"allow_maybe",
|
||||
"allow_comments",
|
||||
"participant_email_required",
|
||||
"anonymous_password_protection_enabled",
|
||||
"anonymous_password_hash",
|
||||
)
|
||||
|
||||
|
||||
def _adopted_columns(inspector: sa.Inspector, table_name: str, names: tuple[str, ...]) -> bool:
|
||||
columns = {item["name"]: item for item in inspector.get_columns(table_name)}
|
||||
present = [name for name in names if name in columns]
|
||||
if not present:
|
||||
return False
|
||||
if len(present) != len(names):
|
||||
missing = sorted(set(names) - set(present))
|
||||
raise RuntimeError(
|
||||
f"Cannot adopt partial {table_name} scheduling response settings; missing columns: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _require_compatible_adopted_schema(inspector: sa.Inspector) -> None:
|
||||
request_columns = {
|
||||
item["name"]: item
|
||||
for item in inspector.get_columns("scheduling_requests")
|
||||
}
|
||||
for name in (
|
||||
"notify_on_answers",
|
||||
"single_choice",
|
||||
"allow_maybe",
|
||||
"allow_comments",
|
||||
"participant_email_required",
|
||||
"anonymous_password_protection_enabled",
|
||||
):
|
||||
column = request_columns[name]
|
||||
if column.get("nullable") or not isinstance(column["type"], sa.Boolean):
|
||||
raise RuntimeError(
|
||||
f"Cannot adopt scheduling_requests.{name} because its schema is unexpected"
|
||||
)
|
||||
capacity = request_columns["max_participants_per_option"]
|
||||
if not capacity.get("nullable") or not isinstance(capacity["type"], sa.Integer):
|
||||
raise RuntimeError(
|
||||
"Cannot adopt scheduling_requests.max_participants_per_option because its schema is unexpected"
|
||||
)
|
||||
password_hash = request_columns["anonymous_password_hash"]
|
||||
if (
|
||||
not password_hash.get("nullable")
|
||||
or not isinstance(password_hash["type"], sa.String)
|
||||
or password_hash["type"].length != 500
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Cannot adopt scheduling_requests.anonymous_password_hash because its schema is unexpected"
|
||||
)
|
||||
participant_columns = {
|
||||
item["name"]: item
|
||||
for item in inspector.get_columns("scheduling_participants")
|
||||
}
|
||||
comment = participant_columns["response_comment"]
|
||||
if not comment.get("nullable") or not isinstance(comment["type"], sa.Text):
|
||||
raise RuntimeError(
|
||||
"Cannot adopt scheduling_participants.response_comment because its schema is unexpected"
|
||||
)
|
||||
gateway = participant_columns["participation_gateway"]
|
||||
if (
|
||||
not gateway.get("nullable")
|
||||
or not isinstance(gateway["type"], sa.String)
|
||||
or gateway["type"].length != 40
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Cannot adopt scheduling_participants.participation_gateway because its schema is unexpected"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
request_columns_present = _adopted_columns(
|
||||
inspector,
|
||||
"scheduling_requests",
|
||||
_REQUEST_COLUMNS,
|
||||
)
|
||||
participant_columns_present = _adopted_columns(
|
||||
inspector,
|
||||
"scheduling_participants",
|
||||
("response_comment", "participation_gateway"),
|
||||
)
|
||||
if request_columns_present != participant_columns_present:
|
||||
raise RuntimeError(
|
||||
"Cannot adopt partial scheduling response settings across request and participant tables"
|
||||
)
|
||||
if request_columns_present:
|
||||
_require_compatible_adopted_schema(inspector)
|
||||
return
|
||||
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column("notify_on_answers", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column("single_choice", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column("max_participants_per_option", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column("allow_maybe", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column("allow_comments", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column("participant_email_required", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column(
|
||||
"anonymous_password_protection_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_requests",
|
||||
sa.Column("anonymous_password_hash", sa.String(length=500), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_participants",
|
||||
sa.Column("response_comment", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scheduling_participants",
|
||||
sa.Column("participation_gateway", sa.String(length=40), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("scheduling_participants", "participation_gateway")
|
||||
op.drop_column("scheduling_participants", "response_comment")
|
||||
for name in reversed(_REQUEST_COLUMNS):
|
||||
op.drop_column("scheduling_requests", name)
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
@@ -26,6 +26,9 @@ from govoplan_scheduling.backend.schemas import (
|
||||
SchedulingRequestResponse,
|
||||
SchedulingRequestUpdateRequest,
|
||||
SchedulingPollSummaryResponse,
|
||||
SchedulingPublicParticipationAccessRequest,
|
||||
SchedulingPublicParticipationResponse,
|
||||
SchedulingPublicParticipationSubmitRequest,
|
||||
SchedulingStatusResponse,
|
||||
SchedulingSummaryResponse,
|
||||
)
|
||||
@@ -34,6 +37,7 @@ from govoplan_scheduling.backend.service import (
|
||||
SchedulingConflictError,
|
||||
SchedulingError,
|
||||
SchedulingPermissionError,
|
||||
SchedulingPublicParticipationError,
|
||||
cancel_scheduling_request,
|
||||
close_scheduling_request,
|
||||
create_final_calendar_event,
|
||||
@@ -42,7 +46,9 @@ from govoplan_scheduling.backend.service import (
|
||||
create_tentative_calendar_holds,
|
||||
decide_scheduling_request,
|
||||
evaluate_calendar_freebusy,
|
||||
get_scheduling_request,
|
||||
get_scheduling_availability_response,
|
||||
get_public_scheduling_participation,
|
||||
get_visible_scheduling_request,
|
||||
list_visible_scheduling_notifications,
|
||||
list_visible_scheduling_requests,
|
||||
@@ -53,8 +59,9 @@ from govoplan_scheduling.backend.service import (
|
||||
scheduling_request_response,
|
||||
scheduling_request_summary,
|
||||
submit_scheduling_availability,
|
||||
submit_public_scheduling_participation,
|
||||
update_scheduling_candidate_slot,
|
||||
update_scheduling_request,
|
||||
update_scheduling_request_with_invitation_tokens,
|
||||
)
|
||||
|
||||
|
||||
@@ -112,7 +119,40 @@ def _principal_actor_ids(principal: ApiPrincipal) -> tuple[str, ...]:
|
||||
|
||||
|
||||
def _can_manage_scheduling(principal: ApiPrincipal) -> bool:
|
||||
return has_scope(principal, WRITE_SCOPE) or has_scope(principal, ADMIN_SCOPE)
|
||||
return has_scope(principal, ADMIN_SCOPE)
|
||||
|
||||
|
||||
def _require_scheduling_writer(principal: ApiPrincipal) -> None:
|
||||
if has_scope(principal, WRITE_SCOPE) or has_scope(principal, ADMIN_SCOPE):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing scope: {WRITE_SCOPE} or {ADMIN_SCOPE}",
|
||||
)
|
||||
|
||||
|
||||
def _require_request_editor(
|
||||
session: Session,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
request_id: str,
|
||||
) -> None:
|
||||
_require_scheduling_writer(principal)
|
||||
if has_scope(principal, ADMIN_SCOPE):
|
||||
return
|
||||
try:
|
||||
request = get_scheduling_request(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
if request.organizer_user_id not in _principal_actor_ids(principal):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the organizer or a scheduling administrator can edit this request",
|
||||
)
|
||||
|
||||
|
||||
def _scheduling_http_error(exc: SchedulingError) -> HTTPException:
|
||||
@@ -127,6 +167,25 @@ def _scheduling_http_error(exc: SchedulingError) -> HTTPException:
|
||||
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
|
||||
def _public_participation_http_error(
|
||||
exc: SchedulingPublicParticipationError,
|
||||
) -> HTTPException:
|
||||
if exc.retry_after_seconds:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=str(exc),
|
||||
headers={"Retry-After": str(exc.retry_after_seconds)},
|
||||
)
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
def _client_address(request: Request) -> str | None:
|
||||
return request.client.host if request.client is not None else None
|
||||
|
||||
|
||||
def _request_response(
|
||||
request,
|
||||
*,
|
||||
@@ -144,6 +203,58 @@ def _request_response(
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/public/{request_id}/{token}",
|
||||
response_model=SchedulingPublicParticipationResponse,
|
||||
)
|
||||
def api_get_public_scheduling_participation(
|
||||
request_id: str,
|
||||
token: str,
|
||||
payload: SchedulingPublicParticipationAccessRequest,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
) -> SchedulingPublicParticipationResponse:
|
||||
try:
|
||||
response = get_public_scheduling_participation(
|
||||
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
|
||||
return SchedulingPublicParticipationResponse.model_validate(response)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/public/{request_id}/{token}/responses",
|
||||
response_model=SchedulingPublicParticipationResponse,
|
||||
)
|
||||
def api_submit_public_scheduling_participation(
|
||||
request_id: str,
|
||||
token: str,
|
||||
payload: SchedulingPublicParticipationSubmitRequest,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
) -> SchedulingPublicParticipationResponse:
|
||||
try:
|
||||
response = submit_public_scheduling_participation(
|
||||
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 = SchedulingPublicParticipationResponse.model_validate(response)
|
||||
session.commit()
|
||||
return validated
|
||||
|
||||
|
||||
@router.get("/address-lookup", response_model=SchedulingAddressLookupResponse)
|
||||
def api_lookup_scheduling_addresses(
|
||||
query: str = Query(min_length=1),
|
||||
@@ -151,7 +262,7 @@ def api_lookup_scheduling_addresses(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingAddressLookupResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_scheduling_writer(principal)
|
||||
capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP)
|
||||
if capability is None or not hasattr(capability, "lookup"):
|
||||
return SchedulingAddressLookupResponse(available=False, candidates=[])
|
||||
@@ -198,7 +309,7 @@ def api_create_scheduling_request(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingRequestResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_scheduling_writer(principal)
|
||||
try:
|
||||
request, invitation_tokens = create_scheduling_request(
|
||||
session,
|
||||
@@ -294,9 +405,13 @@ def api_update_scheduling_request(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingRequestResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(
|
||||
session,
|
||||
principal=principal,
|
||||
request_id=request_id,
|
||||
)
|
||||
try:
|
||||
request = update_scheduling_request(
|
||||
request, invitation_tokens = update_scheduling_request_with_invitation_tokens(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
@@ -304,7 +419,11 @@ def api_update_scheduling_request(
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
response = _request_response(request, principal=principal)
|
||||
response = _request_response(
|
||||
request,
|
||||
principal=principal,
|
||||
invitation_tokens=invitation_tokens,
|
||||
)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
@@ -317,7 +436,11 @@ def api_update_scheduling_candidate_slot(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingRequestResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(
|
||||
session,
|
||||
principal=principal,
|
||||
request_id=request_id,
|
||||
)
|
||||
try:
|
||||
request = update_scheduling_candidate_slot(
|
||||
session,
|
||||
@@ -339,7 +462,7 @@ def api_open_scheduling_request(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingStatusResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
try:
|
||||
request = open_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||
except SchedulingError as exc:
|
||||
@@ -355,7 +478,7 @@ def api_close_scheduling_request(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingStatusResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
try:
|
||||
request = close_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||
except SchedulingError as exc:
|
||||
@@ -372,7 +495,7 @@ def api_decide_scheduling_request(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingStatusResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
try:
|
||||
request = decide_scheduling_request(
|
||||
session,
|
||||
@@ -398,7 +521,7 @@ def api_evaluate_calendar_freebusy(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingCalendarActionResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
_require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
|
||||
try:
|
||||
request, warnings = evaluate_calendar_freebusy(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||
@@ -419,7 +542,7 @@ def api_create_tentative_calendar_holds(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingCalendarActionResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||
try:
|
||||
request, created_event_ids, warnings = create_tentative_calendar_holds(
|
||||
@@ -446,7 +569,7 @@ def api_create_final_calendar_event(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingCalendarActionResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||
try:
|
||||
request, event_id, warnings = create_final_calendar_event(
|
||||
@@ -472,7 +595,7 @@ def api_cancel_scheduling_request(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingStatusResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
try:
|
||||
request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||
except SchedulingError as exc:
|
||||
@@ -547,7 +670,7 @@ def api_create_scheduling_notifications(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingNotificationListResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_request_editor(session, principal=principal, request_id=request_id)
|
||||
try:
|
||||
notifications = create_scheduling_notification_jobs(
|
||||
session,
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
|
||||
|
||||
|
||||
SchedulingStatus = Literal["draft", "collecting", "closed", "decided", "handed_off", "cancelled", "archived"]
|
||||
@@ -25,6 +25,24 @@ def _known_timezone(value: str | None) -> str | None:
|
||||
return value
|
||||
|
||||
|
||||
def _participant_email(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip().casefold()
|
||||
if not normalized:
|
||||
return None
|
||||
local, separator, domain = normalized.partition("@")
|
||||
if (
|
||||
separator != "@"
|
||||
or not local
|
||||
or not domain
|
||||
or "@" in domain
|
||||
or any(character.isspace() for character in normalized)
|
||||
):
|
||||
raise ValueError("participant_email must be a valid email address")
|
||||
return normalized
|
||||
|
||||
|
||||
class SchedulingCalendarPreferences(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -91,6 +109,30 @@ class SchedulingParticipantInput(BaseModel):
|
||||
required: bool = True
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
_validate_email = field_validator("email")(_participant_email)
|
||||
|
||||
|
||||
class SchedulingCandidateSlotReconcileInput(SchedulingCandidateSlotInput):
|
||||
id: str | None = Field(default=None, max_length=36)
|
||||
revision: str | None = Field(
|
||||
default=None,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
pattern=r"^[0-9a-f]{64}$",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_existing_revision(self) -> "SchedulingCandidateSlotReconcileInput":
|
||||
if self.id is not None and self.revision is None:
|
||||
raise ValueError("revision is required for an existing scheduling slot")
|
||||
if self.id is None and self.revision is not None:
|
||||
raise ValueError("revision can only be supplied for an existing scheduling slot")
|
||||
return self
|
||||
|
||||
|
||||
class SchedulingParticipantReconcileInput(SchedulingParticipantInput):
|
||||
id: str | None = Field(default=None, max_length=36)
|
||||
|
||||
|
||||
class SchedulingRequestCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -105,6 +147,14 @@ class SchedulingRequestCreateRequest(BaseModel):
|
||||
allow_participant_updates: bool = True
|
||||
result_visibility: SchedulingResultVisibility = "after_close"
|
||||
participant_visibility: SchedulingParticipantVisibility = "aggregates_only"
|
||||
notify_on_answers: bool = True
|
||||
single_choice: bool = False
|
||||
max_participants_per_option: int | None = Field(default=None, ge=1)
|
||||
allow_maybe: bool = True
|
||||
allow_comments: bool = False
|
||||
participant_email_required: bool = False
|
||||
anonymous_password_protection_enabled: bool = False
|
||||
anonymous_password: SecretStr | None = Field(default=None, min_length=8, max_length=1024)
|
||||
calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences)
|
||||
slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1)
|
||||
participants: list[SchedulingParticipantInput] = Field(default_factory=list)
|
||||
@@ -113,6 +163,14 @@ class SchedulingRequestCreateRequest(BaseModel):
|
||||
|
||||
_validate_timezone = field_validator("timezone")(_known_timezone)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_anonymous_password(self) -> "SchedulingRequestCreateRequest":
|
||||
if self.anonymous_password_protection_enabled and self.anonymous_password is None:
|
||||
raise ValueError("anonymous_password is required when password protection is enabled")
|
||||
if not self.anonymous_password_protection_enabled and self.anonymous_password is not None:
|
||||
raise ValueError("anonymous_password requires password protection to be enabled")
|
||||
return self
|
||||
|
||||
|
||||
class SchedulingRequestUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -125,9 +183,40 @@ class SchedulingRequestUpdateRequest(BaseModel):
|
||||
allow_participant_updates: bool | None = None
|
||||
result_visibility: SchedulingResultVisibility | None = None
|
||||
participant_visibility: SchedulingParticipantVisibility | None = None
|
||||
notify_on_answers: bool | None = None
|
||||
single_choice: bool | None = None
|
||||
max_participants_per_option: int | None = Field(default=None, ge=1)
|
||||
allow_maybe: bool | None = None
|
||||
allow_comments: bool | None = None
|
||||
participant_email_required: bool | None = None
|
||||
anonymous_password_protection_enabled: bool | None = None
|
||||
anonymous_password: SecretStr | None = Field(default=None, min_length=8, max_length=1024)
|
||||
calendar: SchedulingCalendarPreferences | None = None
|
||||
slots: list[SchedulingCandidateSlotReconcileInput] | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
)
|
||||
participants: list[SchedulingParticipantReconcileInput] | None = None
|
||||
create_participant_invitations: bool = True
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_anonymous_password(self) -> "SchedulingRequestUpdateRequest":
|
||||
if self.anonymous_password_protection_enabled is False and self.anonymous_password is not None:
|
||||
raise ValueError("anonymous_password cannot be set while password protection is disabled")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_reconciliation_ids(self) -> "SchedulingRequestUpdateRequest":
|
||||
for field_name in ("slots", "participants"):
|
||||
values = getattr(self, field_name)
|
||||
if values is None:
|
||||
continue
|
||||
ids = [value.id for value in values if value.id is not None]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError(f"Duplicate ids are not allowed in {field_name}")
|
||||
return self
|
||||
|
||||
|
||||
class SchedulingCandidateSlotResponse(BaseModel):
|
||||
id: str
|
||||
@@ -149,11 +238,12 @@ class SchedulingCandidateSlotResponse(BaseModel):
|
||||
|
||||
class SchedulingParticipantResponse(BaseModel):
|
||||
id: str
|
||||
is_current_participant: bool = False
|
||||
respondent_id: str | None = None
|
||||
display_name: str | None = None
|
||||
email: str | None = None
|
||||
participant_type: str
|
||||
required: bool
|
||||
participant_type: str | None = None
|
||||
required: bool | None = None
|
||||
status: str
|
||||
poll_invitation_id: str | None = None
|
||||
invitation_token: str | None = None
|
||||
@@ -178,7 +268,7 @@ class SchedulingParticipantVisibilityDecisionResponse(BaseModel):
|
||||
|
||||
class SchedulingRequestResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
tenant_id: str | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
@@ -192,14 +282,23 @@ class SchedulingRequestResponse(BaseModel):
|
||||
allow_participant_updates: bool
|
||||
result_visibility: str
|
||||
participant_visibility: SchedulingParticipantVisibility
|
||||
notify_on_answers: bool
|
||||
single_choice: bool
|
||||
max_participants_per_option: int | None = None
|
||||
allow_maybe: bool
|
||||
allow_comments: bool
|
||||
participant_email_required: bool
|
||||
anonymous_password_protection_enabled: bool
|
||||
public_participation_policy_enforcement_available: bool | None = None
|
||||
public_participation_policy_enforcement_reason: str | None = None
|
||||
effective_participant_visibility: SchedulingParticipantVisibility
|
||||
participant_aggregate: SchedulingParticipantAggregateResponse
|
||||
participant_visibility_decision: SchedulingParticipantVisibilityDecisionResponse
|
||||
calendar_integration_enabled: bool
|
||||
calendar_integration_enabled: bool | None = None
|
||||
calendar_id: str | None = None
|
||||
calendar_freebusy_enabled: bool
|
||||
calendar_hold_enabled: bool
|
||||
create_calendar_event_on_decision: bool
|
||||
calendar_freebusy_enabled: bool | None = None
|
||||
calendar_hold_enabled: bool | None = None
|
||||
create_calendar_event_on_decision: bool | None = None
|
||||
calendar_event_id: str | None = None
|
||||
handed_off_at: datetime | None = None
|
||||
cancelled_at: datetime | None = None
|
||||
@@ -238,6 +337,7 @@ class SchedulingAvailabilityResponseRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
answers: list[SchedulingAvailabilityAnswerInput] = Field(min_length=1)
|
||||
comment: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_slots(self) -> "SchedulingAvailabilityResponseRequest":
|
||||
@@ -258,6 +358,70 @@ class SchedulingAvailabilityResponse(BaseModel):
|
||||
has_response: bool = False
|
||||
submitted_at: datetime | None = None
|
||||
answers: list[SchedulingAvailabilityAnswerResponse] = Field(default_factory=list)
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class SchedulingPublicParticipationAccessRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
participant_email: str | None = Field(default=None, max_length=320)
|
||||
password: SecretStr | None = Field(default=None, max_length=1024)
|
||||
|
||||
_validate_participant_email = field_validator("participant_email")(_participant_email)
|
||||
|
||||
|
||||
class SchedulingPublicParticipationSubmitRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
answers: list[SchedulingAvailabilityAnswerInput] = Field(min_length=1)
|
||||
participant_email: str | None = Field(default=None, max_length=320)
|
||||
password: SecretStr | None = Field(default=None, max_length=1024)
|
||||
comment: str | None = Field(default=None, max_length=4000)
|
||||
idempotency_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
|
||||
_validate_participant_email = field_validator("participant_email")(_participant_email)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_slots(self) -> "SchedulingPublicParticipationSubmitRequest":
|
||||
slot_ids = [answer.slot_id for answer in self.answers]
|
||||
if len(slot_ids) != len(set(slot_ids)):
|
||||
raise ValueError("Each scheduling slot can be answered only once")
|
||||
return self
|
||||
|
||||
|
||||
class SchedulingPublicCandidateSlotResponse(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
description: str | None = None
|
||||
start_at: datetime
|
||||
end_at: datetime
|
||||
timezone: str
|
||||
location: str | None = None
|
||||
position: int
|
||||
revision: str
|
||||
|
||||
|
||||
class SchedulingPublicParticipationResponse(BaseModel):
|
||||
request_id: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
timezone: str
|
||||
status: str
|
||||
deadline_at: datetime | None = None
|
||||
participant_email_required: 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
|
||||
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):
|
||||
|
||||
61
src/govoplan_scheduling/backend/security.py
Normal file
61
src/govoplan_scheduling/backend/security.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
|
||||
|
||||
_ALGORITHM = "pbkdf2_sha256"
|
||||
_DEFAULT_ITERATIONS = 260_000
|
||||
_SALT_BYTES = 16
|
||||
|
||||
|
||||
def hash_participant_password(
|
||||
password: str,
|
||||
*,
|
||||
iterations: int = _DEFAULT_ITERATIONS,
|
||||
) -> str:
|
||||
"""Hash a public-participant access password for durable storage."""
|
||||
|
||||
salt = os.urandom(_SALT_BYTES)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt,
|
||||
iterations,
|
||||
)
|
||||
return "$".join(
|
||||
(
|
||||
_ALGORITHM,
|
||||
str(iterations),
|
||||
base64.b64encode(salt).decode("ascii"),
|
||||
base64.b64encode(digest).decode("ascii"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def verify_participant_password(password: str, encoded: str | None) -> bool:
|
||||
"""Verify a participant password without exposing the stored hash."""
|
||||
|
||||
if not encoded:
|
||||
return False
|
||||
try:
|
||||
algorithm, iterations_text, salt_b64, digest_b64 = encoded.split("$", 3)
|
||||
if algorithm != _ALGORITHM:
|
||||
return False
|
||||
iterations = int(iterations_text)
|
||||
salt = base64.b64decode(salt_b64.encode("ascii"), validate=True)
|
||||
expected = base64.b64decode(digest_b64.encode("ascii"), validate=True)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
actual = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt,
|
||||
iterations,
|
||||
)
|
||||
return hmac.compare_digest(actual, expected)
|
||||
|
||||
|
||||
__all__ = ["hash_participant_password", "verify_participant_password"]
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user