feat(scheduling): enforce governed response policies

This commit is contained in:
2026-07-21 21:17:26 +02:00
parent 886579942f
commit b1725b8f59
12 changed files with 3546 additions and 181 deletions

View File

@@ -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,