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

View File

@@ -523,6 +523,31 @@ class SchedulingInvitationActionRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
action: Literal["copy", "send"] 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): class SchedulingInvitationActionResponse(BaseModel):

View File

@@ -2866,6 +2866,19 @@ def _participant_for_invitation_action(
return participant 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( def _new_public_invitation_expiry(
request: SchedulingRequest, request: SchedulingRequest,
) -> datetime | None: ) -> datetime | None:
@@ -2904,6 +2917,7 @@ def issue_scheduling_participant_invitation(
tenant_id: str, tenant_id: str,
request_id: str, request_id: str,
participant_id: str, participant_id: str,
participant_revision: str,
action: str, action: str,
) -> SchedulingInvitationActionResult: ) -> SchedulingInvitationActionResult:
"""Rotate one bearer link for explicit copy or immediate delivery. """Rotate one bearer link for explicit copy or immediate delivery.
@@ -2925,6 +2939,10 @@ def issue_scheduling_participant_invitation(
request, request,
participant_id=participant_id, participant_id=participant_id,
) )
_require_current_invitation_participant_revision(
participant,
participant_revision=participant_revision,
)
if request.poll_id is None: if request.poll_id is None:
raise SchedulingError("Scheduling invitation action is unavailable") raise SchedulingError("Scheduling invitation action is unavailable")
participation_provider = _poll_participation_provider() participation_provider = _poll_participation_provider()
@@ -3016,8 +3034,9 @@ def revoke_scheduling_participant_invitation(
tenant_id: str, tenant_id: str,
request_id: str, request_id: str,
participant_id: str, participant_id: str,
participant_revision: str,
) -> SchedulingInvitationActionResult: ) -> 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( request = _lock_scheduling_request(
session, session,
@@ -3029,6 +3048,10 @@ def revoke_scheduling_participant_invitation(
request, request,
participant_id=participant_id, participant_id=participant_id,
) )
_require_current_invitation_participant_revision(
participant,
participant_revision=participant_revision,
)
invitation_id = participant.poll_invitation_id invitation_id = participant.poll_invitation_id
replayed = invitation_id is None replayed = invitation_id is None
if invitation_id is not None: if invitation_id is not None:

View File

@@ -214,6 +214,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
tenant_id=request.tenant_id, tenant_id=request.tenant_id,
request_id=request.id, request_id=request.id,
participant_id=participant.id, participant_id=participant.id,
participant_revision=scheduling_participant_revision(participant),
action="copy", action="copy",
) )
self.assertIsNotNone(result.action_url) self.assertIsNotNone(result.action_url)
@@ -1753,6 +1754,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
tenant_id=request.tenant_id, tenant_id=request.tenant_id,
request_id=request.id, request_id=request.id,
participant_id=participant.id, participant_id=participant.id,
participant_revision=scheduling_participant_revision(participant),
action="copy", action="copy",
) )

View File

@@ -43,6 +43,7 @@ from govoplan_scheduling.backend.schemas import (
SchedulingCandidateSlotInput, SchedulingCandidateSlotInput,
SchedulingDecisionRequest, SchedulingDecisionRequest,
SchedulingInvitationActionRequest, SchedulingInvitationActionRequest,
SchedulingInvitationRevokeRequest,
SchedulingParticipantInput, SchedulingParticipantInput,
SchedulingPublicParticipationAccessRequest, SchedulingPublicParticipationAccessRequest,
SchedulingPublicParticipationSubmitRequest, SchedulingPublicParticipationSubmitRequest,
@@ -82,6 +83,7 @@ from govoplan_scheduling.backend.service import (
open_scheduling_request, open_scheduling_request,
require_visible_scheduling_results, require_visible_scheduling_results,
scheduling_request_summary, scheduling_request_summary,
scheduling_participant_revision,
scheduling_slot_revision, scheduling_slot_revision,
submit_public_scheduling_participation, submit_public_scheduling_participation,
update_scheduling_request, update_scheduling_request,
@@ -222,6 +224,7 @@ class SchedulingServiceTests(unittest.TestCase):
tenant_id=request.tenant_id, tenant_id=request.tenant_id,
request_id=request.id, request_id=request.id,
participant_id=participant.id, participant_id=participant.id,
participant_revision=scheduling_participant_revision(participant),
action="copy", action="copy",
) )
self.assertIsNotNone(result.action_url) self.assertIsNotNone(result.action_url)
@@ -1517,6 +1520,7 @@ class SchedulingServiceTests(unittest.TestCase):
tenant_id=request.tenant_id, tenant_id=request.tenant_id,
request_id=request.id, request_id=request.id,
participant_id=participant.id, participant_id=participant.id,
participant_revision=scheduling_participant_revision(participant),
action="send", action="send",
) )
for participant in request.participants for participant in request.participants
@@ -1576,6 +1580,7 @@ class SchedulingServiceTests(unittest.TestCase):
tenant_id=request.tenant_id, tenant_id=request.tenant_id,
request_id=request.id, request_id=request.id,
participant_id=participant.id, participant_id=participant.id,
participant_revision=scheduling_participant_revision(participant),
action="send", action="send",
) )
@@ -1625,6 +1630,7 @@ class SchedulingServiceTests(unittest.TestCase):
tenant_id=request.tenant_id, tenant_id=request.tenant_id,
request_id=request.id, request_id=request.id,
participant_id=participant.id, participant_id=participant.id,
participant_revision=scheduling_participant_revision(participant),
action="send", action="send",
) )
@@ -1644,6 +1650,142 @@ class SchedulingServiceTests(unittest.TestCase):
request.id, request.id,
) )
def test_stale_copy_is_rejected_before_rotating_invitation(self) -> None:
request, _automatic_tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload().model_copy(
update={"calendar": SchedulingCalendarPreferences()}
),
)
participant = request.participants[0]
stale_revision = scheduling_participant_revision(participant)
self._issue_copy(request, participant)
invitation_id = participant.poll_invitation_id
organizer = self._principal("user-1", scopes={SCHEDULING_WRITE_SCOPE})
with (
patch("govoplan_scheduling.backend.router.audit_event") as audit,
self.assertRaises(HTTPException) as conflict,
):
api_issue_scheduling_participant_invitation(
request.id,
participant.id,
SchedulingInvitationActionRequest(
action="copy",
participant_revision=stale_revision,
),
Response(),
session=self.session,
principal=organizer,
)
self.assertEqual(conflict.exception.status_code, 409)
self.assertEqual(participant.poll_invitation_id, invitation_id)
invitation = self.session.query(PollInvitation).filter(
PollInvitation.id == invitation_id
).one()
self.assertIsNone(invitation.revoked_at)
audit.assert_not_called()
def test_stale_send_is_rejected_before_delivery_or_rotation(self) -> None:
class CapturingNotificationProvider:
def __init__(self) -> None:
self.requests = []
def enqueue_notification(self, _session, request, *, enqueue_delivery):
self.requests.append(request)
return {"id": "notification-1", "status": "queued"}
provider = CapturingNotificationProvider()
request, _automatic_tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload().model_copy(
update={"calendar": SchedulingCalendarPreferences()}
),
)
participant = request.participants[0]
stale_revision = scheduling_participant_revision(participant)
self._issue_copy(request, participant)
invitation_id = participant.poll_invitation_id
organizer = self._principal("user-1", scopes={SCHEDULING_WRITE_SCOPE})
with (
patch(
"govoplan_scheduling.backend.service.notification_dispatch_provider",
return_value=provider,
),
patch("govoplan_scheduling.backend.router.audit_event") as audit,
self.assertRaises(HTTPException) as conflict,
):
api_issue_scheduling_participant_invitation(
request.id,
participant.id,
SchedulingInvitationActionRequest(
action="send",
participant_revision=stale_revision,
),
Response(),
session=self.session,
principal=organizer,
)
self.assertEqual(conflict.exception.status_code, 409)
self.assertEqual(participant.poll_invitation_id, invitation_id)
invitation = self.session.query(PollInvitation).filter(
PollInvitation.id == invitation_id
).one()
self.assertIsNone(invitation.revoked_at)
self.assertEqual(provider.requests, [])
self.assertEqual(
self.session.query(SchedulingNotification).filter(
SchedulingNotification.request_id == request.id
).count(),
0,
)
audit.assert_not_called()
def test_stale_revoke_is_rejected_before_changing_access(self) -> None:
request, _automatic_tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload().model_copy(
update={"calendar": SchedulingCalendarPreferences()}
),
)
participant = request.participants[0]
stale_revision = scheduling_participant_revision(participant)
self._issue_copy(request, participant)
invitation_id = participant.poll_invitation_id
organizer = self._principal("user-1", scopes={SCHEDULING_WRITE_SCOPE})
with (
patch("govoplan_scheduling.backend.router.audit_event") as audit,
self.assertRaises(HTTPException) as conflict,
):
api_revoke_scheduling_participant_invitation(
request.id,
participant.id,
SchedulingInvitationRevokeRequest(
participant_revision=stale_revision,
),
Response(),
session=self.session,
principal=organizer,
)
self.assertEqual(conflict.exception.status_code, 409)
self.assertEqual(participant.poll_invitation_id, invitation_id)
invitation = self.session.query(PollInvitation).filter(
PollInvitation.id == invitation_id
).one()
self.assertIsNone(invitation.revoked_at)
audit.assert_not_called()
def test_invitation_router_rotates_revokes_and_enforces_management_policy(self) -> None: def test_invitation_router_rotates_revokes_and_enforces_management_policy(self) -> None:
request, automatic_tokens = create_scheduling_request( request, automatic_tokens = create_scheduling_request(
self.session, self.session,
@@ -1663,7 +1805,10 @@ class SchedulingServiceTests(unittest.TestCase):
first = api_issue_scheduling_participant_invitation( first = api_issue_scheduling_participant_invitation(
request.id, request.id,
participant.id, participant.id,
SchedulingInvitationActionRequest(action="copy"), SchedulingInvitationActionRequest(
action="copy",
participant_revision=scheduling_participant_revision(participant),
),
response_headers, response_headers,
session=self.session, session=self.session,
principal=organizer, principal=organizer,
@@ -1688,7 +1833,10 @@ class SchedulingServiceTests(unittest.TestCase):
api_issue_scheduling_participant_invitation( api_issue_scheduling_participant_invitation(
request.id, request.id,
participant.id, participant.id,
SchedulingInvitationActionRequest(action="copy"), SchedulingInvitationActionRequest(
action="copy",
participant_revision=scheduling_participant_revision(participant),
),
Response(), Response(),
session=self.session, session=self.session,
principal=unrelated_writer, principal=unrelated_writer,
@@ -1713,7 +1861,10 @@ class SchedulingServiceTests(unittest.TestCase):
rotated = api_issue_scheduling_participant_invitation( rotated = api_issue_scheduling_participant_invitation(
request.id, request.id,
participant.id, participant.id,
SchedulingInvitationActionRequest(action="copy"), SchedulingInvitationActionRequest(
action="copy",
participant_revision=scheduling_participant_revision(participant),
),
Response(), Response(),
session=self.session, session=self.session,
principal=administrator, principal=administrator,
@@ -1737,6 +1888,9 @@ class SchedulingServiceTests(unittest.TestCase):
revoked = api_revoke_scheduling_participant_invitation( revoked = api_revoke_scheduling_participant_invitation(
request.id, request.id,
participant.id, participant.id,
SchedulingInvitationRevokeRequest(
participant_revision=scheduling_participant_revision(participant),
),
Response(), Response(),
session=self.session, session=self.session,
principal=organizer, principal=organizer,
@@ -1744,6 +1898,9 @@ class SchedulingServiceTests(unittest.TestCase):
replayed = api_revoke_scheduling_participant_invitation( replayed = api_revoke_scheduling_participant_invitation(
request.id, request.id,
participant.id, participant.id,
SchedulingInvitationRevokeRequest(
participant_revision=scheduling_participant_revision(participant),
),
Response(), Response(),
session=self.session, session=self.session,
principal=organizer, principal=organizer,
@@ -1793,7 +1950,12 @@ class SchedulingServiceTests(unittest.TestCase):
issued = api_issue_scheduling_participant_invitation( issued = api_issue_scheduling_participant_invitation(
request.id, request.id,
request.participants[0].id, request.participants[0].id,
SchedulingInvitationActionRequest(action="copy"), SchedulingInvitationActionRequest(
action="copy",
participant_revision=scheduling_participant_revision(
request.participants[0]
),
),
Response(), Response(),
session=self.session, session=self.session,
principal=organizer, principal=organizer,
@@ -1838,7 +2000,10 @@ class SchedulingServiceTests(unittest.TestCase):
result = api_issue_scheduling_participant_invitation( result = api_issue_scheduling_participant_invitation(
request.id, request.id,
participant.id, participant.id,
SchedulingInvitationActionRequest(action="send"), SchedulingInvitationActionRequest(
action="send",
participant_revision=scheduling_participant_revision(participant),
),
Response(), Response(),
session=self.session, session=self.session,
principal=organizer, principal=organizer,