fix(scheduling): reject stale invitation actions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -214,6 +214,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
||||
tenant_id=request.tenant_id,
|
||||
request_id=request.id,
|
||||
participant_id=participant.id,
|
||||
participant_revision=scheduling_participant_revision(participant),
|
||||
action="copy",
|
||||
)
|
||||
self.assertIsNotNone(result.action_url)
|
||||
@@ -1753,6 +1754,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
||||
tenant_id=request.tenant_id,
|
||||
request_id=request.id,
|
||||
participant_id=participant.id,
|
||||
participant_revision=scheduling_participant_revision(participant),
|
||||
action="copy",
|
||||
)
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ from govoplan_scheduling.backend.schemas import (
|
||||
SchedulingCandidateSlotInput,
|
||||
SchedulingDecisionRequest,
|
||||
SchedulingInvitationActionRequest,
|
||||
SchedulingInvitationRevokeRequest,
|
||||
SchedulingParticipantInput,
|
||||
SchedulingPublicParticipationAccessRequest,
|
||||
SchedulingPublicParticipationSubmitRequest,
|
||||
@@ -82,6 +83,7 @@ from govoplan_scheduling.backend.service import (
|
||||
open_scheduling_request,
|
||||
require_visible_scheduling_results,
|
||||
scheduling_request_summary,
|
||||
scheduling_participant_revision,
|
||||
scheduling_slot_revision,
|
||||
submit_public_scheduling_participation,
|
||||
update_scheduling_request,
|
||||
@@ -222,6 +224,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
tenant_id=request.tenant_id,
|
||||
request_id=request.id,
|
||||
participant_id=participant.id,
|
||||
participant_revision=scheduling_participant_revision(participant),
|
||||
action="copy",
|
||||
)
|
||||
self.assertIsNotNone(result.action_url)
|
||||
@@ -1517,6 +1520,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
tenant_id=request.tenant_id,
|
||||
request_id=request.id,
|
||||
participant_id=participant.id,
|
||||
participant_revision=scheduling_participant_revision(participant),
|
||||
action="send",
|
||||
)
|
||||
for participant in request.participants
|
||||
@@ -1576,6 +1580,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
tenant_id=request.tenant_id,
|
||||
request_id=request.id,
|
||||
participant_id=participant.id,
|
||||
participant_revision=scheduling_participant_revision(participant),
|
||||
action="send",
|
||||
)
|
||||
|
||||
@@ -1625,6 +1630,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
tenant_id=request.tenant_id,
|
||||
request_id=request.id,
|
||||
participant_id=participant.id,
|
||||
participant_revision=scheduling_participant_revision(participant),
|
||||
action="send",
|
||||
)
|
||||
|
||||
@@ -1644,6 +1650,142 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
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:
|
||||
request, automatic_tokens = create_scheduling_request(
|
||||
self.session,
|
||||
@@ -1663,7 +1805,10 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
first = api_issue_scheduling_participant_invitation(
|
||||
request.id,
|
||||
participant.id,
|
||||
SchedulingInvitationActionRequest(action="copy"),
|
||||
SchedulingInvitationActionRequest(
|
||||
action="copy",
|
||||
participant_revision=scheduling_participant_revision(participant),
|
||||
),
|
||||
response_headers,
|
||||
session=self.session,
|
||||
principal=organizer,
|
||||
@@ -1688,7 +1833,10 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
api_issue_scheduling_participant_invitation(
|
||||
request.id,
|
||||
participant.id,
|
||||
SchedulingInvitationActionRequest(action="copy"),
|
||||
SchedulingInvitationActionRequest(
|
||||
action="copy",
|
||||
participant_revision=scheduling_participant_revision(participant),
|
||||
),
|
||||
Response(),
|
||||
session=self.session,
|
||||
principal=unrelated_writer,
|
||||
@@ -1713,7 +1861,10 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
rotated = api_issue_scheduling_participant_invitation(
|
||||
request.id,
|
||||
participant.id,
|
||||
SchedulingInvitationActionRequest(action="copy"),
|
||||
SchedulingInvitationActionRequest(
|
||||
action="copy",
|
||||
participant_revision=scheduling_participant_revision(participant),
|
||||
),
|
||||
Response(),
|
||||
session=self.session,
|
||||
principal=administrator,
|
||||
@@ -1737,6 +1888,9 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
revoked = api_revoke_scheduling_participant_invitation(
|
||||
request.id,
|
||||
participant.id,
|
||||
SchedulingInvitationRevokeRequest(
|
||||
participant_revision=scheduling_participant_revision(participant),
|
||||
),
|
||||
Response(),
|
||||
session=self.session,
|
||||
principal=organizer,
|
||||
@@ -1744,6 +1898,9 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
replayed = api_revoke_scheduling_participant_invitation(
|
||||
request.id,
|
||||
participant.id,
|
||||
SchedulingInvitationRevokeRequest(
|
||||
participant_revision=scheduling_participant_revision(participant),
|
||||
),
|
||||
Response(),
|
||||
session=self.session,
|
||||
principal=organizer,
|
||||
@@ -1793,7 +1950,12 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
issued = api_issue_scheduling_participant_invitation(
|
||||
request.id,
|
||||
request.participants[0].id,
|
||||
SchedulingInvitationActionRequest(action="copy"),
|
||||
SchedulingInvitationActionRequest(
|
||||
action="copy",
|
||||
participant_revision=scheduling_participant_revision(
|
||||
request.participants[0]
|
||||
),
|
||||
),
|
||||
Response(),
|
||||
session=self.session,
|
||||
principal=organizer,
|
||||
@@ -1838,7 +2000,10 @@ class SchedulingServiceTests(unittest.TestCase):
|
||||
result = api_issue_scheduling_participant_invitation(
|
||||
request.id,
|
||||
participant.id,
|
||||
SchedulingInvitationActionRequest(action="send"),
|
||||
SchedulingInvitationActionRequest(
|
||||
action="send",
|
||||
participant_revision=scheduling_participant_revision(participant),
|
||||
),
|
||||
Response(),
|
||||
session=self.session,
|
||||
principal=organizer,
|
||||
|
||||
Reference in New Issue
Block a user