feat(scheduling): enforce governed response policies
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user