fix(scheduling): reject stale invitation actions

This commit is contained in:
2026-07-22 04:16:59 +02:00
parent ed39f83688
commit f512784dd3
5 changed files with 225 additions and 6 deletions

View File

@@ -19,6 +19,7 @@ from govoplan_scheduling.backend.schemas import (
SchedulingDecisionRequest,
SchedulingInvitationActionRequest,
SchedulingInvitationActionResponse,
SchedulingInvitationRevokeRequest,
SchedulingNotificationCreateRequest,
SchedulingNotificationListResponse,
SchedulingNotificationResponse,
@@ -482,6 +483,7 @@ def api_issue_scheduling_participant_invitation(
tenant_id=principal.tenant_id,
request_id=request_id,
participant_id=participant_id,
participant_revision=payload.participant_revision,
action=payload.action,
)
except SchedulingError as exc:
@@ -543,6 +545,7 @@ def api_issue_scheduling_participant_invitation(
def api_revoke_scheduling_participant_invitation(
request_id: str,
participant_id: str,
payload: SchedulingInvitationRevokeRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
@@ -558,6 +561,7 @@ def api_revoke_scheduling_participant_invitation(
tenant_id=principal.tenant_id,
request_id=request_id,
participant_id=participant_id,
participant_revision=payload.participant_revision,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc

View File

@@ -523,6 +523,31 @@ class SchedulingInvitationActionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
action: Literal["copy", "send"]
participant_revision: str = Field(
min_length=64,
max_length=64,
pattern=r"^[0-9a-f]{64}$",
description=(
"Semantic revision from the participant management projection; "
"stale actions are rejected before rotating or delivering a link."
),
)
class SchedulingInvitationRevokeRequest(BaseModel):
"""Revoke the link represented by one current participant projection."""
model_config = ConfigDict(extra="forbid")
participant_revision: str = Field(
min_length=64,
max_length=64,
pattern=r"^[0-9a-f]{64}$",
description=(
"Semantic revision from the participant management projection; "
"stale revocations are rejected before changing access."
),
)
class SchedulingInvitationActionResponse(BaseModel):

View File

@@ -2866,6 +2866,19 @@ def _participant_for_invitation_action(
return participant
def _require_current_invitation_participant_revision(
participant: SchedulingParticipant,
*,
participant_revision: str,
) -> None:
"""Reject a stale invitation command while the participant row is locked."""
if participant_revision != scheduling_participant_revision(participant):
raise SchedulingConflictError(
"Scheduling participant invitation changed; reload and try again"
)
def _new_public_invitation_expiry(
request: SchedulingRequest,
) -> datetime | None:
@@ -2904,6 +2917,7 @@ def issue_scheduling_participant_invitation(
tenant_id: str,
request_id: str,
participant_id: str,
participant_revision: str,
action: str,
) -> SchedulingInvitationActionResult:
"""Rotate one bearer link for explicit copy or immediate delivery.
@@ -2925,6 +2939,10 @@ def issue_scheduling_participant_invitation(
request,
participant_id=participant_id,
)
_require_current_invitation_participant_revision(
participant,
participant_revision=participant_revision,
)
if request.poll_id is None:
raise SchedulingError("Scheduling invitation action is unavailable")
participation_provider = _poll_participation_provider()
@@ -3016,8 +3034,9 @@ def revoke_scheduling_participant_invitation(
tenant_id: str,
request_id: str,
participant_id: str,
participant_revision: str,
) -> SchedulingInvitationActionResult:
"""Immediately revoke the current link; exact repeats are safe no-ops."""
"""Revoke the current link; refreshed no-link projections replay safely."""
request = _lock_scheduling_request(
session,
@@ -3029,6 +3048,10 @@ def revoke_scheduling_participant_invitation(
request,
participant_id=participant_id,
)
_require_current_invitation_participant_revision(
participant,
participant_revision=participant_revision,
)
invitation_id = participant.poll_invitation_id
replayed = invitation_id is None
if invitation_id is not None: