fix(api): hide campaign operational internals

This commit is contained in:
2026-07-21 12:03:56 +02:00
parent 04bc6c430e
commit ce92499333
9 changed files with 593 additions and 48 deletions

View File

@@ -60,6 +60,7 @@ PERMISSIONS = (
_permission("campaigns:campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"),
_permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"),
_permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP attempts after inspection.", "Campaigns"),
_permission("campaigns:diagnostic:read", "View campaign diagnostics", "Inspect worker claims and internal storage locators for campaign delivery troubleshooting.", "Campaign operations"),
_permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"),
_permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"),
_permission("campaigns:recipient:import", "Import recipients", "Bulk-import recipient lists.", "Recipients"),
@@ -111,6 +112,7 @@ ROLE_TEMPLATES = (
"campaigns:campaign:send",
"campaigns:campaign:retry",
"campaigns:campaign:reconcile",
"campaigns:diagnostic:read",
"campaigns:recipient:read",
"campaigns:report:read",
"campaigns:report:send",

View File

@@ -1,13 +1,14 @@
from __future__ import annotations
import copy
import hashlib
import json
import os
from datetime import UTC, datetime
from email import policy
from email.parser import BytesParser
from pathlib import Path
from typing import Any
import copy
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import func
@@ -44,8 +45,9 @@ class CampaignPersistenceError(RuntimeError):
def _ensure_dirs() -> None:
CAMPAIGN_SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
for directory in (CAMPAIGN_SNAPSHOT_DIR, BUILD_OUTPUT_DIR):
directory.mkdir(parents=True, mode=0o700, exist_ok=True)
directory.chmod(0o700)
@@ -74,7 +76,18 @@ def load_campaign_config_from_json(
def _write_campaign_snapshot(version: CampaignVersion) -> Path:
_ensure_dirs()
path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json"
path.write_text(json.dumps(version.raw_json, ensure_ascii=False, indent=2), encoding="utf-8")
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(path, flags, 0o600)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
descriptor = -1
json.dump(version.raw_json, stream, ensure_ascii=False, indent=2)
finally:
if descriptor >= 0:
os.close(descriptor)
return path

View File

@@ -20,6 +20,7 @@ from govoplan_campaign.backend.db.models import (
SendAttempt,
)
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
from govoplan_campaign.backend.response_security import public_campaign_payload, public_source_filename
class CampaignReportError(RuntimeError):
@@ -62,10 +63,10 @@ def _version_info(version: CampaignVersion | None) -> dict[str, Any] | None:
"id": version.id,
"version_number": version.version_number,
"schema_version": version.schema_version,
"source_filename": version.source_filename,
"source_filename": public_source_filename(version.source_filename),
"created_at": version.created_at.isoformat() if version.created_at else None,
"validation_summary": version.validation_summary,
"build_summary": version.build_summary,
"validation_summary": public_campaign_payload(version.validation_summary),
"build_summary": public_campaign_payload(version.build_summary),
"execution_snapshot_hash": version.execution_snapshot_hash,
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
}
@@ -323,8 +324,6 @@ def _job_evidence_row(
"campaign_id": job.campaign_id,
"campaign_version_id": job.campaign_version_id,
"message_id_header": job.message_id_header,
"eml_storage_key": job.eml_storage_key,
"eml_local_path": job.eml_local_path,
"from": _address_summary(recipients.get("from")),
"to": _address_summary(recipients.get("to")),
"cc": _address_summary(recipients.get("cc")),
@@ -603,8 +602,6 @@ def generate_jobs_csv(
"last_error",
"eml_size_bytes",
"eml_sha256",
"eml_storage_key",
"eml_local_path",
"issues_count",
"attachment_config_count",
"matched_file_count",

View File

@@ -0,0 +1,134 @@
from __future__ import annotations
import copy
from pathlib import Path, PureWindowsPath
from typing import Any
# These fields locate process-local or storage-backend resources, or authorize
# a worker claim. They are useful for tightly controlled diagnostics but are
# not part of the campaign business-data contract.
CAMPAIGN_INTERNAL_RESPONSE_KEYS = frozenset(
{
"campaign_file",
"claim_token",
"eml_local_path",
"eml_path",
"eml_storage_key",
"local_path",
"source_base_path",
"storage_bucket",
"storage_key",
}
)
def public_campaign_payload(value: Any) -> Any:
"""Return a detached payload without infrastructure-only locators."""
if isinstance(value, dict):
return {
key: public_campaign_payload(item)
for key, item in value.items()
if key not in CAMPAIGN_INTERNAL_RESPONSE_KEYS
}
if isinstance(value, list):
return [public_campaign_payload(item) for item in value]
if isinstance(value, tuple):
return tuple(public_campaign_payload(item) for item in value)
return copy.deepcopy(value)
def public_campaign_configuration(value: Any) -> Any:
"""Return campaign JSON without infrastructure locators or mail secrets.
Password-named business fields are intentionally retained. Only the
schema-defined SMTP/IMAP credential paths under ``server`` are secrets.
"""
payload = public_campaign_payload(value)
if not isinstance(payload, dict):
return payload
server = payload.get("server")
if isinstance(server, dict):
for protocol in ("smtp", "imap"):
config = server.get(protocol)
if isinstance(config, dict):
config.pop("password", None)
credentials = server.get("credentials")
if isinstance(credentials, dict):
for protocol in ("smtp", "imap"):
config = credentials.get(protocol)
if isinstance(config, dict):
config.pop("password", None)
_sanitize_configuration_paths(payload)
return payload
def public_source_filename(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text:
return text
windows_path = PureWindowsPath(text)
return windows_path.name if windows_path.is_absolute() or "\\" in text else Path(text).name
def _sanitize_configuration_paths(payload: dict[str, Any]) -> None:
template = payload.get("template")
source = template.get("source") if isinstance(template, dict) else None
if isinstance(source, dict):
for key in ("subject_path", "text_path", "html_path"):
if key in source:
source[key] = _public_configuration_path(source[key])
entries = payload.get("entries")
entry_source = entries.get("source") if isinstance(entries, dict) else None
if isinstance(entry_source, dict) and "path" in entry_source:
entry_source["path"] = _public_configuration_path(entry_source["path"])
attachments = payload.get("attachments")
if isinstance(attachments, dict):
if "base_path" in attachments:
attachments["base_path"] = _public_configuration_path(attachments["base_path"])
base_paths = attachments.get("base_paths")
if isinstance(base_paths, list):
for item in base_paths:
if not isinstance(item, dict):
continue
for key in ("path", "source"):
if key in item:
item[key] = _public_configuration_path(item[key])
_sanitize_attachment_rules(attachments.get("global"))
if isinstance(entries, dict):
inline_entries = entries.get("inline")
if isinstance(inline_entries, list):
for entry in inline_entries:
if isinstance(entry, dict):
_sanitize_attachment_rules(entry.get("attachments"))
defaults = entries.get("defaults")
if isinstance(defaults, dict):
_sanitize_attachment_rules(defaults.get("attachments"))
def _sanitize_attachment_rules(value: Any) -> None:
if not isinstance(value, list):
return
for rule in value:
if isinstance(rule, dict) and "base_dir" in rule:
rule["base_dir"] = _public_configuration_path(rule["base_dir"])
def _public_configuration_path(value: Any) -> Any:
if not isinstance(value, str) or not value.strip():
return value
text = value.strip()
windows_path = PureWindowsPath(text)
path = Path(text)
if windows_path.is_absolute():
return windows_path.name
if path.is_absolute() or text.startswith("~"):
return path.name
return value

View File

@@ -38,6 +38,7 @@ from govoplan_campaign.backend.schemas import (
CampaignJobsResponse,
CampaignJobsDeltaResponse,
CampaignJobDetailResponse,
CampaignJobDiagnosticsResponse,
CampaignRetryJobsRequest,
CampaignSendJobRequest,
CampaignSendUnattemptedRequest,
@@ -98,6 +99,7 @@ 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.reports.emailing import CampaignReportEmailError, send_campaign_report_email
from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError,
@@ -1812,7 +1814,7 @@ def validate_version(
},
commit=True,
)
return result
return public_campaign_payload(result)
except HTTPException:
raise
except CampaignPersistenceError as exc:
@@ -1847,7 +1849,7 @@ def build_version(
details={"write_eml": payload.write_eml if payload else True, "built_count": result.get("built_count")},
commit=True,
)
return result
return public_campaign_payload(result)
except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except Exception as exc:
@@ -1904,14 +1906,81 @@ def _job_detail_payload(job: CampaignJob) -> dict[str, object]:
return {
**_job_summary_payload(job),
"message_id_header": job.message_id_header,
"eml_local_path": job.eml_local_path,
"eml_storage_key": job.eml_storage_key,
"issues": job.issues_snapshot or [],
"attachments": job.resolved_attachments or [],
"attachments": public_campaign_payload(job.resolved_attachments or []),
"resolved_recipients": job.resolved_recipients or {},
}
def _job_attempts_payload(
send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt],
*,
include_diagnostics: bool = False,
) -> dict[str, list[dict[str, object]]]:
smtp_payloads: list[dict[str, object]] = []
for attempt in send_attempts:
payload: dict[str, object] = {
"id": attempt.id,
"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
smtp_payloads.append(payload)
imap_payloads: list[dict[str, object]] = []
for attempt in imap_attempts:
payload = {
"id": attempt.id,
"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
imap_payloads.append(payload)
return {"smtp": smtp_payloads, "imap": imap_payloads}
def _job_diagnostics_payload(
job: CampaignJob,
send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt],
) -> CampaignJobDiagnosticsResponse:
return CampaignJobDiagnosticsResponse(
job_id=job.id,
campaign_id=job.campaign_id,
campaign_version_id=job.campaign_version_id,
storage={
"eml_local_path": job.eml_local_path,
"eml_storage_key": job.eml_storage_key,
"eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256,
},
worker_claim={
"claim_token": job.claim_token,
"claimed_at": job.claimed_at,
"smtp_started_at": job.smtp_started_at,
"outcome_unknown_at": job.outcome_unknown_at,
},
attempts=_job_attempts_payload(
send_attempts,
imap_attempts,
include_diagnostics=True,
),
)
def _review_metadata(
session: Session,
version: CampaignVersion | None,
@@ -2462,38 +2531,42 @@ def get_job_detail(
)
return CampaignJobDetailResponse(
job=_job_detail_payload(job),
attempts={
"smtp": [
{
"id": attempt.id,
"attempt_number": attempt.attempt_number,
"status": attempt.status,
"claim_token": attempt.claim_token,
"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,
}
for attempt in send_attempts
],
"imap": [
{
"id": attempt.id,
"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,
}
for attempt in imap_attempts
],
},
attempts=_job_attempts_payload(send_attempts, imap_attempts),
)
@router.get(
"/{campaign_id}/jobs/{job_id}/diagnostics",
response_model=CampaignJobDiagnosticsResponse,
)
def get_job_diagnostics(
campaign_id: str,
job_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:diagnostic:read")),
):
"""Return infrastructure details only to campaign operators/admins."""
_get_campaign_for_principal(session, campaign_id, principal)
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
job = session.get(CampaignJob, job_id)
if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found")
send_attempts = (
session.query(SendAttempt)
.filter(SendAttempt.job_id == job.id)
.order_by(SendAttempt.attempt_number.asc())
.all()
)
imap_attempts = (
session.query(ImapAppendAttempt)
.filter(ImapAppendAttempt.job_id == job.id)
.order_by(ImapAppendAttempt.attempt_number.asc())
.all()
)
return _job_diagnostics_payload(job, send_attempts, imap_attempts)
@router.get("/{campaign_id}/summary")
def campaign_summary(
campaign_id: str,

View File

@@ -3,11 +3,17 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.mail.config import ImapConfig, SmtpConfig
from govoplan_campaign.backend.response_security import (
public_campaign_configuration,
public_campaign_payload,
public_source_filename,
)
class CampaignCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -80,7 +86,6 @@ class CampaignVersionResponse(BaseModel):
version_number: int
schema_version: str
source_filename: str | None = None
source_base_path: str | None = None
workflow_state: str = "editing"
current_flow: str = "manual"
current_step: str | None = None
@@ -100,10 +105,25 @@ class CampaignVersionResponse(BaseModel):
execution_snapshot_hash: str | None = None
execution_snapshot_at: datetime | None = None
@field_validator("source_filename", mode="before")
@classmethod
def remove_source_directory(cls, value: Any) -> str | None:
return public_source_filename(value)
@field_validator("validation_summary", "build_summary", mode="before")
@classmethod
def remove_internal_summary_fields(cls, value: Any) -> Any:
return public_campaign_payload(value)
class CampaignVersionDetailResponse(CampaignVersionResponse):
raw_json: dict[str, Any]
@field_validator("raw_json", mode="before")
@classmethod
def remove_internal_configuration_fields(cls, value: Any) -> Any:
return public_campaign_configuration(value)
class CampaignPartialValidationResponse(BaseModel):
ok: bool
@@ -351,6 +371,15 @@ class CampaignJobDetailResponse(BaseModel):
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
class CampaignJobDiagnosticsResponse(BaseModel):
job_id: str
campaign_id: str
campaign_version_id: str
storage: dict[str, Any] = Field(default_factory=dict)
worker_claim: dict[str, Any] = Field(default_factory=dict)
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
class CampaignRetryJobsRequest(BaseModel):
model_config = ConfigDict(extra="forbid")