security: make Campaign mutation audits atomic

This commit is contained in:
2026-07-21 17:56:59 +02:00
parent 60776803c5
commit 0ac903c82d
4 changed files with 331 additions and 48 deletions

View File

@@ -390,14 +390,22 @@ def _campaign_version_detail_response(
details=audit_details,
commit=True,
)
return CampaignVersionDetailResponse.model_validate(version)
_write_current_version_snapshot_if_available(version)
return CampaignVersionDetailResponse.model_validate(
version,
context=_campaign_response_context(principal),
)
except LockedCampaignVersionError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except CampaignPathSecurityError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except CampaignPersistenceError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except Exception as exc:
session.rollback()
if validation_error_status is None:
raise
raise HTTPException(status_code=validation_error_status, detail=str(exc)) from exc
@@ -437,6 +445,7 @@ def _update_campaign_version_detail_response(
source_base_path=payload.source_base_path,
autosave=autosave,
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
commit=False,
),
audit_action=audit_action,
details=lambda version: {
@@ -611,6 +620,7 @@ def create_campaign(
raw_json=payload.config,
source_filename=payload.source_filename,
source_base_path=payload.source_base_path,
commit=False,
)
audit_from_principal(
session,
@@ -621,11 +631,20 @@ def create_campaign(
details={"version_id": version.id, "external_id": campaign.external_id},
commit=True,
)
_write_current_version_snapshot_if_available(version)
except HTTPException:
session.rollback()
raise
except Exception as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
return CampaignCreateResponse(campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate(version))
return CampaignCreateResponse(
campaign=CampaignResponse.model_validate(campaign),
version=CampaignVersionResponse.model_validate(
version,
context=_campaign_response_context(principal),
),
)
@router.post("/new", response_model=CampaignCreateResponse)
@@ -651,6 +670,7 @@ def create_minimal_campaign_endpoint(
description=payload.description,
current_flow=payload.current_flow,
current_step=payload.current_step,
commit=False,
)
audit_from_principal(
session,
@@ -661,9 +681,20 @@ def create_minimal_campaign_endpoint(
details={"version_id": version.id, "external_id": campaign.external_id},
commit=True,
)
return CampaignCreateResponse(campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate(version))
_write_current_version_snapshot_if_available(version)
return CampaignCreateResponse(
campaign=CampaignResponse.model_validate(campaign),
version=CampaignVersionResponse.model_validate(
version,
context=_campaign_response_context(principal),
),
)
except CampaignPersistenceError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except Exception:
session.rollback()
raise
@@ -1045,14 +1076,30 @@ def _campaign_workspace_response(
campaign_id=campaign_id,
version_id=selected_version_id,
include_jobs=False,
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
)
except CampaignReportError:
summary_payload = None
return CampaignWorkspaceResponse(
campaign=CampaignResponse.model_validate(campaign),
versions=[CampaignVersionResponse.model_validate(item) for item in versions] if include_versions else [],
current_version=CampaignVersionDetailResponse.model_validate(current_version) if current_version is not None else None,
versions=[
CampaignVersionResponse.model_validate(
item,
context=_campaign_response_context(principal),
)
for item in versions
]
if include_versions
else [],
current_version=(
CampaignVersionDetailResponse.model_validate(
current_version,
context=_campaign_response_context(principal),
)
if current_version is not None
else None
),
summary=summary_payload,
selected_version_id=selected_version_id,
)
@@ -1167,7 +1214,13 @@ def _campaign_workspace_delta_response(
)
return CampaignWorkspaceDeltaResponse(
campaign=CampaignResponse.model_validate(campaign) if delta.changed_campaign else None,
versions=_campaign_workspace_delta_versions(session, campaign, delta, include_versions=include_versions),
versions=_campaign_workspace_delta_versions(
session,
principal,
campaign,
delta,
include_versions=include_versions,
),
current_version=_campaign_workspace_delta_current_version(
session,
principal,
@@ -1240,6 +1293,7 @@ def _campaign_workspace_delta_deleted(entries: list[object]) -> list[dict[str, o
def _campaign_workspace_delta_versions(
session: Session,
principal: ApiPrincipal,
campaign: Campaign,
delta: _WorkspaceDeltaState,
*,
@@ -1253,7 +1307,13 @@ def _campaign_workspace_delta_versions(
.order_by(CampaignVersion.version_number.desc())
.all()
)
return [CampaignVersionResponse.model_validate(item) for item in versions]
return [
CampaignVersionResponse.model_validate(
item,
context=_campaign_response_context(principal),
)
for item in versions
]
def _campaign_workspace_delta_current_version(
@@ -1274,7 +1334,14 @@ def _campaign_workspace_delta_current_version(
.filter(CampaignVersion.id == delta.selected_version_id, CampaignVersion.campaign_id == campaign.id)
.one_or_none()
)
return CampaignVersionDetailResponse.model_validate(current_version) if current_version is not None else None
return (
CampaignVersionDetailResponse.model_validate(
current_version,
context=_campaign_response_context(principal),
)
if current_version is not None
else None
)
def _campaign_workspace_delta_summary(
@@ -1296,6 +1363,7 @@ def _campaign_workspace_delta_summary(
campaign_id=campaign_id,
version_id=delta.selected_version_id,
include_jobs=False,
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
)
except CampaignReportError:
return None
@@ -1378,17 +1446,25 @@ def update_campaign_metadata_endpoint(
_sync_campaign_metadata_to_current_version(session, campaign)
session.add(campaign)
session.commit()
session.flush()
try:
audit_from_principal(
session,
principal,
action="campaign.metadata_updated",
object_type="campaign",
object_id=campaign.id,
details={"external_id": campaign.external_id, "name": campaign.name},
commit=True,
)
except Exception:
session.rollback()
raise
session.refresh(campaign)
audit_from_principal(
session,
principal,
action="campaign.metadata_updated",
object_type="campaign",
object_id=campaign.id,
details={"external_id": campaign.external_id, "name": campaign.name},
commit=True,
)
if campaign.current_version_id:
current_version = session.get(CampaignVersion, campaign.current_version_id)
if current_version is not None:
_write_current_version_snapshot_if_available(current_version)
return CampaignResponse.model_validate(campaign)
@@ -1458,7 +1534,13 @@ def list_versions(
.order_by(CampaignVersion.version_number.desc())
.all()
)
return [CampaignVersionResponse.model_validate(item) for item in versions]
return [
CampaignVersionResponse.model_validate(
item,
context=_campaign_response_context(principal),
)
for item in versions
]
@router.get("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse)
@@ -1474,7 +1556,10 @@ def get_version_detail(
version = get_campaign_version_for_tenant(
session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id
)
return CampaignVersionDetailResponse.model_validate(version)
return CampaignVersionDetailResponse.model_validate(
version,
context=_campaign_response_context(principal),
)
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -1523,6 +1608,7 @@ def fork_version_for_edit(
source_base_path=payload.source_base_path,
autosave=True,
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
commit=False,
)
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
audit_from_principal(
@@ -1539,16 +1625,26 @@ def fork_version_for_edit(
},
commit=True,
)
_write_current_version_snapshot_if_available(version)
return CampaignCreateResponse(
campaign=CampaignResponse.model_validate(campaign),
version=CampaignVersionResponse.model_validate(version),
version=CampaignVersionResponse.model_validate(
version,
context=_campaign_response_context(principal),
),
)
except LockedCampaignVersionError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except CampaignPathSecurityError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except CampaignPersistenceError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except Exception:
session.rollback()
raise
@router.post("/{campaign_id}/versions/{version_id}/unlock-validation", response_model=CampaignVersionDetailResponse)
@@ -1574,6 +1670,7 @@ def unlock_version_validation(
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
commit=False,
),
audit_action="campaign.version_validation_unlocked",
)
@@ -1597,6 +1694,7 @@ def lock_version_temporarily(
campaign_id=campaign_id,
version_id=version_id,
user_id=principal.user.id,
commit=False,
),
audit_action="campaign.version_user_locked_temporarily",
)
@@ -1619,6 +1717,7 @@ def unlock_version_user_lock(
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
commit=False,
),
audit_action="campaign.version_user_lock_removed",
)
@@ -1642,6 +1741,7 @@ def lock_version_permanently(
campaign_id=campaign_id,
version_id=version_id,
user_id=principal.user.id,
commit=False,
),
audit_action="campaign.version_user_locked_permanently",
)
@@ -1694,8 +1794,11 @@ def set_version_step(
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
try:
version = update_campaign_version(
return _campaign_version_detail_response(
session,
principal,
campaign_id,
lambda: update_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
@@ -1703,12 +1806,15 @@ def set_version_step(
current_flow=payload.current_flow,
current_step=payload.current_step,
autosave=True,
)
return CampaignVersionDetailResponse.model_validate(version)
except LockedCampaignVersionError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
commit=False,
),
audit_action="campaign.version_step_updated",
details={
"campaign_id": campaign_id,
"current_flow": payload.current_flow,
"current_step": payload.current_step,
},
)
@router.post("/{campaign_id}/versions/{version_id}/review-state", response_model=CampaignVersionDetailResponse)
@@ -1729,6 +1835,7 @@ def set_version_review_state(
inspection_complete=payload.inspection_complete,
reviewed_message_keys=payload.reviewed_message_keys,
user_id=principal.user.id,
commit=False,
)
audit_from_principal(
session,
@@ -1743,11 +1850,19 @@ def set_version_review_state(
},
commit=True,
)
return CampaignVersionDetailResponse.model_validate(version)
return CampaignVersionDetailResponse.model_validate(
version,
context=_campaign_response_context(principal),
)
except LockedCampaignVersionError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except CampaignPersistenceError 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}/versions/{version_id}/validate-partial", response_model=CampaignPartialValidationResponse)
@@ -1797,6 +1912,7 @@ def publish_version(
campaign_id=campaign_id,
version_id=version_id,
user_id=principal.user.id,
commit=False,
),
audit_action="campaign.version_user_locked_permanently",
)
@@ -1867,7 +1983,10 @@ def validate_version(
},
commit=True,
)
return public_campaign_payload(result)
return public_campaign_payload(
result,
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
)
except HTTPException:
raise
except CampaignPersistenceError as exc:
@@ -1902,7 +2021,10 @@ def build_version(
details={"write_eml": payload.write_eml if payload else True, "built_count": result.get("built_count")},
commit=True,
)
return public_campaign_payload(result)
return public_campaign_payload(
result,
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
)
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except Exception as exc:
@@ -1941,8 +2063,6 @@ def _job_summary_payload(
imap_status=job.imap_status,
),
"queued_at": job.queued_at,
"claimed_at": job.claimed_at,
"smtp_started_at": job.smtp_started_at,
"outcome_unknown_at": job.outcome_unknown_at,
"sent_at": job.sent_at,
"created_at": job.created_at,
@@ -2646,6 +2766,7 @@ def campaign_summary(
version_id=version_id,
include_jobs=include_jobs,
include_recent_failures=include_jobs,
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
)
except CampaignReportError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -2672,6 +2793,7 @@ def campaign_report(
version_id=version_id,
include_jobs=include_jobs,
include_recent_failures=include_jobs,
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
)
except CampaignReportError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -2690,7 +2812,11 @@ def campaign_jobs_csv(
try:
csv_text = generate_jobs_csv(
session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
)
except CampaignReportError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc