security: authorize every Campaign effect version
This commit is contained in:
@@ -95,6 +95,9 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
CampaignVersion,
|
CampaignVersion,
|
||||||
CampaignVersionWorkflowState,
|
CampaignVersionWorkflowState,
|
||||||
ImapAppendAttempt,
|
ImapAppendAttempt,
|
||||||
|
JobImapStatus,
|
||||||
|
JobQueueStatus,
|
||||||
|
JobSendStatus,
|
||||||
RecipientImportMappingProfile,
|
RecipientImportMappingProfile,
|
||||||
SendAttempt,
|
SendAttempt,
|
||||||
)
|
)
|
||||||
@@ -358,7 +361,11 @@ def _campaign_mail_profile_id(raw_json: dict[str, object] | None) -> str | None:
|
|||||||
|
|
||||||
def _require_mail_profile_use_if_needed(principal: ApiPrincipal, raw_json: dict[str, object] | None) -> None:
|
def _require_mail_profile_use_if_needed(principal: ApiPrincipal, raw_json: dict[str, object] | None) -> None:
|
||||||
if _campaign_mail_profile_id(raw_json) and not has_scope(principal, "mail:profile:use"):
|
if _campaign_mail_profile_id(raw_json) and not has_scope(principal, "mail:profile:use"):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail_servers:use")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail:profile:use")
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_response_context(principal: ApiPrincipal) -> dict[str, bool]:
|
||||||
|
return {"include_diagnostics": has_scope(principal, "campaigns:diagnostic:read")}
|
||||||
|
|
||||||
|
|
||||||
def _campaign_version_detail_response(
|
def _campaign_version_detail_response(
|
||||||
@@ -458,6 +465,23 @@ def _require_campaign_profile_use_if_needed(
|
|||||||
_require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {})
|
_require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {})
|
||||||
|
|
||||||
|
|
||||||
|
def _require_campaign_versions_profile_use(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
campaign_id: str,
|
||||||
|
version_ids: set[str],
|
||||||
|
) -> None:
|
||||||
|
"""Authorize every historical version affected by a campaign-wide action."""
|
||||||
|
|
||||||
|
for version_id in sorted(version_ids):
|
||||||
|
_require_campaign_profile_use_if_needed(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
campaign_id,
|
||||||
|
version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_version_for_principal(
|
def _get_version_for_principal(
|
||||||
session: Session,
|
session: Session,
|
||||||
version_id: str,
|
version_id: str,
|
||||||
@@ -3200,6 +3224,18 @@ def resume_campaign(
|
|||||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")),
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")),
|
||||||
):
|
):
|
||||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||||
|
version_ids = {
|
||||||
|
row[0]
|
||||||
|
for row in session.query(CampaignJob.campaign_version_id)
|
||||||
|
.filter(
|
||||||
|
CampaignJob.tenant_id == principal.tenant_id,
|
||||||
|
CampaignJob.campaign_id == campaign_id,
|
||||||
|
CampaignJob.queue_status == JobQueueStatus.PAUSED.value,
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
_require_campaign_versions_profile_use(session, principal, campaign_id, version_ids)
|
||||||
try:
|
try:
|
||||||
result = resume_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id)
|
result = resume_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id)
|
||||||
audit_from_principal(session, principal, action="campaign.resumed", object_type="campaign", object_id=campaign_id, details=result, commit=True)
|
audit_from_principal(session, principal, action="campaign.resumed", object_type="campaign", object_id=campaign_id, details=result, commit=True)
|
||||||
@@ -3232,7 +3268,23 @@ def append_sent(
|
|||||||
):
|
):
|
||||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||||
payload = payload or AppendSentRequest()
|
payload = payload or AppendSentRequest()
|
||||||
_require_campaign_profile_use_if_needed(session, principal, campaign_id)
|
version_ids = {
|
||||||
|
row[0]
|
||||||
|
for row in session.query(CampaignJob.campaign_version_id)
|
||||||
|
.filter(
|
||||||
|
CampaignJob.tenant_id == principal.tenant_id,
|
||||||
|
CampaignJob.campaign_id == campaign_id,
|
||||||
|
CampaignJob.send_status.in_(
|
||||||
|
[JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value]
|
||||||
|
),
|
||||||
|
CampaignJob.imap_status.in_(
|
||||||
|
[JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
_require_campaign_versions_profile_use(session, principal, campaign_id, version_ids)
|
||||||
try:
|
try:
|
||||||
result = enqueue_pending_imap_appends(
|
result = enqueue_pending_imap_appends(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import call, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -228,3 +228,21 @@ def test_fork_inherited_profile_requires_mail_profile_use_scope() -> None:
|
|||||||
|
|
||||||
assert captured.value.status_code == 403
|
assert captured.value.status_code == 403
|
||||||
fork.assert_not_called()
|
fork.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_wide_effect_authorizes_every_affected_version() -> None:
|
||||||
|
session = object()
|
||||||
|
principal = SimpleNamespace(tenant_id="tenant-1")
|
||||||
|
|
||||||
|
with patch.object(router, "_require_campaign_profile_use_if_needed") as require_profile:
|
||||||
|
router._require_campaign_versions_profile_use( # noqa: SLF001 - security boundary regression test
|
||||||
|
session, # type: ignore[arg-type]
|
||||||
|
principal, # type: ignore[arg-type]
|
||||||
|
"campaign-1",
|
||||||
|
{"version-2", "version-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert require_profile.call_args_list == [
|
||||||
|
call(session, principal, "campaign-1", "version-1"),
|
||||||
|
call(session, principal, "campaign-1", "version-2"),
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user