security: harden Campaign delivery effects and reconciliation
This commit is contained in:
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import dataclasses
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
@@ -99,7 +100,10 @@ from govoplan_campaign.backend.db.models import (
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
||||
from govoplan_campaign.backend.response_security import public_campaign_payload
|
||||
from govoplan_campaign.backend.response_security import (
|
||||
public_campaign_payload,
|
||||
public_delivery_result_message,
|
||||
)
|
||||
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
CampaignPersistenceError,
|
||||
@@ -108,9 +112,15 @@ from govoplan_campaign.backend.persistence.campaigns import (
|
||||
load_campaign_config_from_json,
|
||||
validate_campaign_version,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import files_integration
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
MailProfileError,
|
||||
SmtpConfigurationError,
|
||||
SmtpSendError,
|
||||
files_integration,
|
||||
)
|
||||
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_json
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_id
|
||||
from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_campaign.backend.persistence.versions import (
|
||||
@@ -130,6 +140,7 @@ from govoplan_campaign.backend.persistence.versions import (
|
||||
update_campaign_review_state,
|
||||
validate_campaign_partial,
|
||||
)
|
||||
|
||||
from govoplan_campaign.backend.dev.mock_campaign import MockCampaignSendError, run_mock_campaign_send
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, clear_execution_snapshot
|
||||
from govoplan_campaign.backend.sending.jobs import (
|
||||
@@ -147,6 +158,7 @@ from govoplan_campaign.backend.sending.jobs import (
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CAMPAIGN_JOBS_CURSOR_SCOPE = "campaign.jobs"
|
||||
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
||||
@@ -341,9 +353,7 @@ def _recipient_sections_changed(current: dict[str, object] | None, proposed: dic
|
||||
|
||||
|
||||
def _campaign_mail_profile_id(raw_json: dict[str, object] | None) -> str | None:
|
||||
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||
value = server.get("mail_profile_id") if isinstance(server, dict) else None
|
||||
return str(value) if value else None
|
||||
return campaign_mail_profile_id(raw_json)
|
||||
|
||||
|
||||
def _require_mail_profile_use_if_needed(principal: ApiPrincipal, raw_json: dict[str, object] | None) -> None:
|
||||
@@ -419,12 +429,14 @@ def _update_campaign_version_detail_response(
|
||||
source_filename=payload.source_filename,
|
||||
source_base_path=payload.source_base_path,
|
||||
autosave=autosave,
|
||||
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
|
||||
),
|
||||
audit_action=audit_action,
|
||||
details=lambda version: {
|
||||
"campaign_id": campaign_id,
|
||||
"current_flow": version.current_flow,
|
||||
"current_step": version.current_step,
|
||||
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
|
||||
},
|
||||
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
)
|
||||
@@ -1461,7 +1473,18 @@ def fork_version_for_edit(
|
||||
"""
|
||||
|
||||
payload = payload or CampaignVersionUpdateRequest()
|
||||
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
|
||||
source_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if source_version.campaign_id != campaign_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found")
|
||||
effective_json = (
|
||||
payload.campaign_json
|
||||
if isinstance(payload.campaign_json, dict)
|
||||
else source_version.raw_json
|
||||
)
|
||||
_require_mail_profile_use_if_needed(
|
||||
principal,
|
||||
effective_json if isinstance(effective_json, dict) else {},
|
||||
)
|
||||
try:
|
||||
version = fork_campaign_version_for_edit(
|
||||
session,
|
||||
@@ -1475,6 +1498,7 @@ def fork_version_for_edit(
|
||||
source_filename=payload.source_filename,
|
||||
source_base_path=payload.source_base_path,
|
||||
autosave=True,
|
||||
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
|
||||
)
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
audit_from_principal(
|
||||
@@ -1483,7 +1507,12 @@ def fork_version_for_edit(
|
||||
action="campaign.version_forked_for_edit",
|
||||
object_type="campaign_version",
|
||||
object_id=version.id,
|
||||
details={"campaign_id": campaign_id, "source_version_id": version_id, "version_number": version.version_number},
|
||||
details={
|
||||
"campaign_id": campaign_id,
|
||||
"source_version_id": version_id,
|
||||
"version_number": version.version_number,
|
||||
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return CampaignCreateResponse(
|
||||
@@ -1882,7 +1911,11 @@ def _job_summary_payload(
|
||||
"eml_size_bytes": job.eml_size_bytes,
|
||||
"eml_sha256": job.eml_sha256,
|
||||
"attempt_count": job.attempt_count,
|
||||
"last_error": job.last_error,
|
||||
"last_error": public_delivery_result_message(
|
||||
last_error=job.last_error,
|
||||
send_status=job.send_status,
|
||||
imap_status=job.imap_status,
|
||||
),
|
||||
"queued_at": job.queued_at,
|
||||
"claimed_at": job.claimed_at,
|
||||
"smtp_started_at": job.smtp_started_at,
|
||||
@@ -1925,14 +1958,14 @@ def _job_attempts_payload(
|
||||
"attempt_number": attempt.attempt_number,
|
||||
"status": attempt.status,
|
||||
"smtp_status_code": attempt.smtp_status_code,
|
||||
"smtp_response": attempt.smtp_response,
|
||||
"error_type": attempt.error_type,
|
||||
"error_message": attempt.error_message,
|
||||
"started_at": attempt.started_at,
|
||||
"finished_at": attempt.finished_at,
|
||||
}
|
||||
if include_diagnostics:
|
||||
payload["claim_token"] = attempt.claim_token
|
||||
payload["smtp_response"] = attempt.smtp_response
|
||||
payload["error_type"] = attempt.error_type
|
||||
payload["error_message"] = attempt.error_message
|
||||
smtp_payloads.append(payload)
|
||||
|
||||
imap_payloads: list[dict[str, object]] = []
|
||||
@@ -1942,12 +1975,12 @@ def _job_attempts_payload(
|
||||
"attempt_number": attempt.attempt_number,
|
||||
"status": attempt.status,
|
||||
"folder": attempt.folder,
|
||||
"error_message": attempt.error_message,
|
||||
"created_at": attempt.created_at,
|
||||
"updated_at": attempt.updated_at,
|
||||
}
|
||||
if include_diagnostics:
|
||||
payload["claim_token"] = attempt.claim_token
|
||||
payload["error_message"] = attempt.error_message
|
||||
imap_payloads.append(payload)
|
||||
return {"smtp": smtp_payloads, "imap": imap_payloads}
|
||||
|
||||
@@ -1972,6 +2005,7 @@ def _job_diagnostics_payload(
|
||||
"claimed_at": job.claimed_at,
|
||||
"smtp_started_at": job.smtp_started_at,
|
||||
"outcome_unknown_at": job.outcome_unknown_at,
|
||||
"last_error": job.last_error,
|
||||
},
|
||||
attempts=_job_attempts_payload(
|
||||
send_attempts,
|
||||
@@ -2587,6 +2621,7 @@ def campaign_summary(
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
include_jobs=include_jobs,
|
||||
include_recent_failures=include_jobs,
|
||||
)
|
||||
except CampaignReportError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
@@ -2612,6 +2647,7 @@ def campaign_report(
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
include_jobs=include_jobs,
|
||||
include_recent_failures=include_jobs,
|
||||
)
|
||||
except CampaignReportError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
@@ -2650,8 +2686,15 @@ def email_campaign_report(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:report:send")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
if payload.include_jobs:
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
selected_version_id = payload.version_id or campaign.current_version_id
|
||||
selected_version = session.get(CampaignVersion, selected_version_id) if selected_version_id else None
|
||||
if selected_version is not None and selected_version.campaign_id == campaign.id:
|
||||
_require_mail_profile_use_if_needed(
|
||||
principal,
|
||||
selected_version.raw_json if isinstance(selected_version.raw_json, dict) else {},
|
||||
)
|
||||
if payload.include_jobs or payload.attach_jobs_csv:
|
||||
_require_permission(principal, "campaigns:recipient:export")
|
||||
"""Generate a campaign report and send it to one or more email addresses."""
|
||||
|
||||
@@ -2679,8 +2722,14 @@ def email_campaign_report(
|
||||
return ReportEmailResponse(result=result.as_dict())
|
||||
except CampaignReportError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
except (CampaignReportEmailError, Exception) as exc:
|
||||
except (CampaignReportEmailError, MailProfileError, SmtpConfigurationError, SmtpSendError) as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.error("Campaign report email failed with an unexpected internal error")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Campaign report email could not be completed.",
|
||||
) from exc
|
||||
|
||||
|
||||
|
||||
@@ -2984,6 +3033,7 @@ def resolve_campaign_job_outcome(
|
||||
job_id=job_id,
|
||||
decision=payload.decision,
|
||||
note=payload.note,
|
||||
commit=False,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
@@ -2996,7 +3046,11 @@ def resolve_campaign_job_outcome(
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/mock-send", response_model=MockCampaignSendResponse)
|
||||
|
||||
Reference in New Issue
Block a user