feat(security): explain Campaign child evidence

This commit is contained in:
2026-08-19 20:27:42 +02:00
parent 8e2f9d743d
commit 14e94873a9
4 changed files with 1127 additions and 13 deletions
+36 -10
View File
@@ -10,19 +10,45 @@ and they must identify that inheritance explicitly.
- Campaign version - Campaign version
- Campaign delivery job / built message - Campaign delivery job / built message
- Computed Campaign report, identified by Campaign, version, and report kind - Computed Campaign report, identified by Campaign, version, and report kind
- Recipient row, identified by `<version UUID>:<job UUID>`
- Frozen recipient source snapshot, identified by its Campaign-version UUID
- Campaign attachment binding and version-bound frozen attachment resolution
- Persisted validation issue, version-bound review decision, and attachment-policy override
- SMTP, IMAP append, Postbox, and printable-output attempts
- Message action, message-action attempt, and job reconciliation decision
## Planned Slices All persisted child IDs are random UUIDs. Embedded build/review children use a
version UUID plus a random job UUID, so callers cannot enumerate a recipient
index or infer an address. A version mismatch is reported as a stale reference.
Missing and cross-tenant children use the same non-disclosing not-found
provenance. Explanations never include recipient addresses, source rows,
filenames, object locators, transport responses, worker claims, target
snapshots, diagnostic text, or reconciliation notes.
1. Recipient rows and imported recipient-source snapshots ## Permission matrix
2. Attachment bindings and frozen attachment resolutions
3. Validation issues, review decisions, and attachment-policy overrides | Evidence | Parent boundary | Further restriction |
4. Delivery attempts, IMAP append attempts, Postbox attempts, and | --- | --- | --- |
reconciliation decisions | Recipient row or source snapshot | Campaign read/owner/share | `campaigns:recipient:read` |
5. Campaign shares and ownership-transfer records | Attachment binding/resolution, validation, review, override | Campaign read/owner/share | Campaign review and `campaigns:diagnostic:read` |
6. Import mapping profiles and import executions | Delivery status | Campaign read/owner/share | `campaigns:report:read` |
7. Reusable Campaign templates and template revisions when the template | Transport or worker diagnostics | Campaign read/owner/share | `campaigns:diagnostic:read` |
| Exported delivery evidence | Campaign read/owner/share | `campaigns:report:export` |
| Reconciliation decision | Campaign read/owner/share | Campaign reconcile and diagnostic read |
Postbox, Mail/IMAP, and printable attempts keep bounded Campaign-owned evidence
after provider acceptance. Their explanation therefore remains available when
an optional provider module is later disabled. A missing attempt reports only
the optional owner and `unavailable_or_hidden`; it does not distinguish absence
from hidden data.
## Remaining owner boundaries
1. Campaign shares and ownership-transfer records
2. Import mapping profiles and import executions
3. Reusable Campaign templates and template revisions when the template
library becomes persistent library becomes persistent
8. Export packages and protocol/report artifacts 4. Export packages and protocol/report artifacts
Each child explanation must include: Each child explanation must include:
@@ -24,10 +24,18 @@ from govoplan_core.core.ownership import (
from govoplan_core.security.module_permissions import scopes_grant_compatible from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_campaign.backend.db.models import ( from govoplan_campaign.backend.db.models import (
AttachmentInstance,
CampaignIssue,
Campaign, Campaign,
CampaignJob, CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
CampaignShare, CampaignShare,
CampaignVersion, CampaignVersion,
ImapAppendAttempt,
PostboxDeliveryAttempt,
PrintOutputAttempt,
SendAttempt,
) )
@@ -50,6 +58,84 @@ CAMPAIGN_REPORT_RESOURCE_TYPES = {
"campaign_report", "campaign_report",
"campaigns:report", "campaigns:report",
} }
CAMPAIGN_RECIPIENT_RESOURCE_TYPES = {
"campaign_recipient",
"campaigns:recipient",
}
CAMPAIGN_RECIPIENT_SNAPSHOT_RESOURCE_TYPES = {
"campaign_recipient_source_snapshot",
"campaigns:recipient_source_snapshot",
}
CAMPAIGN_ATTACHMENT_RESOLUTION_RESOURCE_TYPES = {
"campaign_attachment_resolution",
"campaigns:attachment_resolution",
}
CAMPAIGN_ATTACHMENT_BINDING_RESOURCE_TYPES = {
"campaign_attachment_binding",
"campaigns:attachment_binding",
}
CAMPAIGN_VALIDATION_ISSUE_RESOURCE_TYPES = {
"campaign_validation_issue",
"campaigns:validation_issue",
}
CAMPAIGN_REVIEW_DECISION_RESOURCE_TYPES = {
"campaign_review_decision",
"campaigns:review_decision",
}
CAMPAIGN_ATTACHMENT_OVERRIDE_RESOURCE_TYPES = {
"campaign_attachment_override",
"campaigns:attachment_override",
}
CAMPAIGN_SEND_ATTEMPT_RESOURCE_TYPES = {
"campaign_send_attempt",
"campaigns:send_attempt",
}
CAMPAIGN_IMAP_ATTEMPT_RESOURCE_TYPES = {
"campaign_imap_append_attempt",
"campaigns:imap_append_attempt",
}
CAMPAIGN_POSTBOX_ATTEMPT_RESOURCE_TYPES = {
"campaign_postbox_attempt",
"campaigns:postbox_attempt",
}
CAMPAIGN_PRINT_ATTEMPT_RESOURCE_TYPES = {
"campaign_print_attempt",
"campaigns:print_attempt",
}
CAMPAIGN_MESSAGE_ACTION_RESOURCE_TYPES = {
"campaign_message_action",
"campaigns:message_action",
}
CAMPAIGN_MESSAGE_ACTION_ATTEMPT_RESOURCE_TYPES = {
"campaign_message_action_attempt",
"campaigns:message_action_attempt",
}
CAMPAIGN_RECONCILIATION_RESOURCE_TYPES = {
"campaign_reconciliation_decision",
"campaigns:reconciliation_decision",
}
RECIPIENT_ACCESS_REQUIREMENTS = (
"campaigns:campaign:read",
"campaigns:recipient:read",
)
REVIEW_EVIDENCE_ACCESS_REQUIREMENTS = (
"campaigns:campaign:read",
"campaigns:campaign:review",
"campaigns:diagnostic:read",
)
DELIVERY_EVIDENCE_ACCESS_REQUIREMENTS = (
"campaigns:campaign:read",
"campaigns:report:read",
"campaigns:diagnostic:read",
)
DELIVERY_EXPORT_ACCESS_REQUIREMENTS = (
"campaigns:report:export",
)
RECONCILIATION_ACCESS_REQUIREMENTS = (
"campaigns:campaign:reconcile",
"campaigns:diagnostic:read",
)
def campaign_report_resource_id( def campaign_report_resource_id(
@@ -64,6 +150,12 @@ def campaign_report_resource_id(
return f"{campaign_id}:{version_id}:{kind}" return f"{campaign_id}:{version_id}:{kind}"
def campaign_version_child_resource_id(*, version_id: str, child_id: str) -> str:
if not version_id or not child_id or ":" in version_id or ":" in child_id:
raise ValueError("Campaign child references require bounded version and child ids")
return f"{version_id}:{child_id}"
def _campaign_report_reference( def _campaign_report_reference(
resource_id: str, resource_id: str,
) -> tuple[str, str, str] | None: ) -> tuple[str, str, str] | None:
@@ -73,6 +165,149 @@ def _campaign_report_reference(
return parts[0], parts[1], parts[2].strip().lower() return parts[0], parts[1], parts[2].strip().lower()
def _version_child_reference(resource_id: str) -> tuple[str, str] | None:
parts = resource_id.split(":", 1)
if len(parts) != 2 or not all(part.strip() for part in parts):
return None
return parts[0], parts[1]
def _iso_value(value: object | None) -> str | None:
isoformat = getattr(value, "isoformat", None)
return str(isoformat()) if callable(isoformat) else None
def _review_decision(
version: CampaignVersion,
job_id: str,
) -> Mapping[str, object] | None:
editor_state = version.editor_state if isinstance(version.editor_state, Mapping) else {}
review_state = editor_state.get("review_send")
decisions = review_state.get("issue_decisions") if isinstance(review_state, Mapping) else ()
for decision in decisions if isinstance(decisions, list) else ():
if isinstance(decision, Mapping) and str(decision.get("job_id") or "") == job_id:
return decision
return None
def _job_and_campaign(
session: object,
job_id: str,
) -> tuple[CampaignJob | None, Campaign | None]:
job = session.get(CampaignJob, job_id) # type: ignore[attr-defined]
if job is None:
return None, None
return job, session.get(Campaign, job.campaign_id) # type: ignore[attr-defined]
def _child_provenance(
principal: PrincipalRef,
*,
resource_id: str,
source: str,
label: str,
campaign: Campaign | None,
version_id: str | None,
details: Mapping[str, object],
required_actions: tuple[str, ...],
job_id: str | None = None,
) -> AccessDecisionProvenance:
campaign_id = campaign.id if campaign else None
return AccessDecisionProvenance(
kind="resource",
id=resource_id,
label=label,
tenant_id=campaign.tenant_id if campaign else principal.tenant_id,
source=source,
details={
"campaign_id": campaign_id,
"campaign_version_id": version_id,
"job_id": job_id,
"authorization_inherited_from": {
"resource_type": "campaign",
"resource_id": campaign_id,
},
"authorization_mode": "inherited_and_further_restricted",
"permission_actions": list(required_actions),
**details,
},
)
def _delivery_evidence_provenance(
principal: PrincipalRef,
*,
resource_id: str,
source: str,
label: str,
campaign: Campaign | None,
job: CampaignJob | None,
status: str,
details: Mapping[str, object],
attempt_number: int | None = None,
) -> AccessDecisionProvenance:
return _child_provenance(
principal,
resource_id=resource_id,
source=source,
label=label,
campaign=campaign,
version_id=job.campaign_version_id if job else None,
job_id=job.id if job else None,
details={
"status": status,
"attempt_number": attempt_number,
"permission_classes": {
"read": ["campaigns:campaign:read"],
"report": ["campaigns:report:read"],
"diagnostic": ["campaigns:diagnostic:read"],
"export": list(DELIVERY_EXPORT_ACCESS_REQUIREMENTS),
},
**details,
},
required_actions=DELIVERY_EVIDENCE_ACCESS_REQUIREMENTS,
)
def _job_reconciliation_recorded(
session: object,
job: CampaignJob,
) -> list[str]:
channels: list[str] = []
send_attempt = (
session.query(SendAttempt) # type: ignore[attr-defined]
.filter(
SendAttempt.job_id == job.id,
SendAttempt.status.like("reconciled%"),
)
.first()
)
if send_attempt is not None:
channels.append("smtp")
imap_attempt = (
session.query(ImapAppendAttempt) # type: ignore[attr-defined]
.filter(
ImapAppendAttempt.job_id == job.id,
ImapAppendAttempt.status.like("reconciled%"),
)
.first()
)
if imap_attempt is not None:
channels.append("imap")
postbox_attempts = (
session.query(PostboxDeliveryAttempt) # type: ignore[attr-defined]
.filter(PostboxDeliveryAttempt.job_id == job.id)
.all()
)
if any(
isinstance(attempt.evidence, Mapping)
and attempt.evidence.get("operator_reconciliation")
for attempt in postbox_attempts
):
channels.append("postbox")
return channels
class CampaignMailPolicyContextService(CampaignMailPolicyContextProvider): class CampaignMailPolicyContextService(CampaignMailPolicyContextProvider):
def get_campaign_mail_policy_context( def get_campaign_mail_policy_context(
self, self,
@@ -201,6 +436,203 @@ class CampaignAccessService(CampaignAccessProvider):
}, },
}, },
) )
elif normalized_type in CAMPAIGN_RECIPIENT_SNAPSHOT_RESOURCE_TYPES:
version = session.get(CampaignVersion, resource_id) # type: ignore[attr-defined]
if version is None:
return _missing_resource_provenance(
principal,
resource_type="campaign_recipient_source_snapshot",
resource_id=resource_id,
)
campaign = session.get(Campaign, version.campaign_id) # type: ignore[attr-defined]
if not version.execution_snapshot_hash:
return _missing_resource_provenance(
principal,
resource_type="campaign_recipient_source_snapshot",
resource_id=resource_id,
reason="snapshot_not_frozen",
)
child_item = _child_provenance(
principal,
resource_id=version.id,
source="campaigns.recipient_source_snapshot",
label="Frozen recipient source snapshot",
campaign=campaign,
version_id=version.id,
details={
"snapshot_hash": version.execution_snapshot_hash,
"frozen_at": _iso_value(version.execution_snapshot_at),
"source_rows_disclosed": False,
},
required_actions=RECIPIENT_ACCESS_REQUIREMENTS,
)
elif normalized_type in CAMPAIGN_ATTACHMENT_BINDING_RESOURCE_TYPES:
attachment = session.get(AttachmentInstance, resource_id) # type: ignore[attr-defined]
if attachment is None or not attachment.campaign_id:
return _missing_resource_provenance(
principal,
resource_type="campaign_attachment_binding",
resource_id=resource_id,
)
campaign = session.get(Campaign, attachment.campaign_id) # type: ignore[attr-defined]
child_item = _child_provenance(
principal,
resource_id=attachment.id,
source="campaigns.attachment_binding",
label="Campaign attachment binding",
campaign=campaign,
version_id=None,
details={
"binding_state": "active",
"file_data_disclosed": False,
"storage_locator_disclosed": False,
},
required_actions=REVIEW_EVIDENCE_ACCESS_REQUIREMENTS,
)
elif normalized_type in (
CAMPAIGN_RECIPIENT_RESOURCE_TYPES
| CAMPAIGN_ATTACHMENT_RESOLUTION_RESOURCE_TYPES
| CAMPAIGN_REVIEW_DECISION_RESOURCE_TYPES
| CAMPAIGN_ATTACHMENT_OVERRIDE_RESOURCE_TYPES
):
reference = _version_child_reference(resource_id)
if reference is None:
return _missing_resource_provenance(
principal,
resource_type=normalized_type,
resource_id=resource_id,
reason="invalid_reference",
)
version_id, job_id = reference
job = session.get(CampaignJob, job_id) # type: ignore[attr-defined]
if job is None:
return _missing_resource_provenance(
principal,
resource_type=normalized_type,
resource_id=resource_id,
)
if job.campaign_version_id != version_id:
return _missing_resource_provenance(
principal,
resource_type=normalized_type,
resource_id=resource_id,
reason="stale_version_reference",
)
version = session.get(CampaignVersion, version_id) # type: ignore[attr-defined]
campaign = session.get(Campaign, job.campaign_id) # type: ignore[attr-defined]
if version is None or version.campaign_id != job.campaign_id:
return _missing_resource_provenance(
principal,
resource_type=normalized_type,
resource_id=resource_id,
reason="stale_version_reference",
)
if normalized_type in CAMPAIGN_RECIPIENT_RESOURCE_TYPES:
child_item = _child_provenance(
principal,
resource_id=resource_id,
source="campaigns.recipient",
label="Campaign recipient row",
campaign=campaign,
version_id=version.id,
job_id=job.id,
details={
"entry_index": job.entry_index,
"recipient_data_disclosed": False,
},
required_actions=RECIPIENT_ACCESS_REQUIREMENTS,
)
elif normalized_type in CAMPAIGN_ATTACHMENT_RESOLUTION_RESOURCE_TYPES:
child_item = _child_provenance(
principal,
resource_id=resource_id,
source="campaigns.attachment_resolution",
label="Frozen attachment resolution",
campaign=campaign,
version_id=version.id,
job_id=job.id,
details={
"resolution_count": len(job.resolved_attachments or []),
"execution_input_hash": job.execution_input_sha256,
"file_data_disclosed": False,
},
required_actions=REVIEW_EVIDENCE_ACCESS_REQUIREMENTS,
)
else:
decision = _review_decision(version, job.id)
if decision is None:
return _missing_resource_provenance(
principal,
resource_type=normalized_type,
resource_id=resource_id,
reason="decision_not_recorded",
)
attachment_override = any(
isinstance(issue, Mapping)
and str(issue.get("source") or "").startswith("attachments")
for issue in (job.issues_snapshot or [])
)
if (
normalized_type in CAMPAIGN_ATTACHMENT_OVERRIDE_RESOURCE_TYPES
and not attachment_override
):
return _missing_resource_provenance(
principal,
resource_type="campaign_attachment_override",
resource_id=resource_id,
reason="override_not_recorded",
)
child_item = _child_provenance(
principal,
resource_id=resource_id,
source=(
"campaigns.attachment_override"
if normalized_type in CAMPAIGN_ATTACHMENT_OVERRIDE_RESOURCE_TYPES
else "campaigns.review_decision"
),
label=(
"Attachment policy override"
if normalized_type in CAMPAIGN_ATTACHMENT_OVERRIDE_RESOURCE_TYPES
else "Campaign review decision"
),
campaign=campaign,
version_id=version.id,
job_id=job.id,
details={
"decision": decision.get("decision"),
"decided_at": decision.get("decided_at"),
"issue_fingerprint": decision.get("issue_fingerprint"),
"attachment_override": attachment_override,
"reason_recorded": bool(decision.get("reason")),
"reason_disclosed": False,
},
required_actions=REVIEW_EVIDENCE_ACCESS_REQUIREMENTS,
)
elif normalized_type in CAMPAIGN_VALIDATION_ISSUE_RESOURCE_TYPES:
issue = session.get(CampaignIssue, resource_id) # type: ignore[attr-defined]
if issue is None:
return _missing_resource_provenance(
principal,
resource_type="campaign_validation_issue",
resource_id=resource_id,
)
campaign = session.get(Campaign, issue.campaign_id) # type: ignore[attr-defined]
child_item = _child_provenance(
principal,
resource_id=issue.id,
source="campaigns.validation_issue",
label="Campaign validation issue",
campaign=campaign,
version_id=issue.campaign_version_id,
job_id=issue.job_id,
details={
"severity": issue.severity,
"code": issue.code,
"behavior": issue.behavior,
"message_disclosed": False,
},
required_actions=REVIEW_EVIDENCE_ACCESS_REQUIREMENTS,
)
elif normalized_type in CAMPAIGN_DELIVERY_JOB_RESOURCE_TYPES: elif normalized_type in CAMPAIGN_DELIVERY_JOB_RESOURCE_TYPES:
job = session.get(CampaignJob, resource_id) # type: ignore[attr-defined] job = session.get(CampaignJob, resource_id) # type: ignore[attr-defined]
if job is None: if job is None:
@@ -228,6 +660,195 @@ class CampaignAccessService(CampaignAccessProvider):
}, },
}, },
) )
elif normalized_type in CAMPAIGN_SEND_ATTEMPT_RESOURCE_TYPES:
attempt = session.get(SendAttempt, resource_id) # type: ignore[attr-defined]
if attempt is None:
return _missing_resource_provenance(
principal,
resource_type="campaign_send_attempt",
resource_id=resource_id,
)
job, campaign = _job_and_campaign(session, attempt.job_id)
child_item = _delivery_evidence_provenance(
principal,
resource_id=attempt.id,
source="campaigns.send_attempt",
label="SMTP delivery attempt",
campaign=campaign,
job=job,
status=attempt.status,
attempt_number=attempt.attempt_number,
details={"transport_secrets_disclosed": False},
)
elif normalized_type in CAMPAIGN_IMAP_ATTEMPT_RESOURCE_TYPES:
attempt = session.get(ImapAppendAttempt, resource_id) # type: ignore[attr-defined]
if attempt is None:
return _missing_resource_provenance(
principal,
resource_type="campaign_imap_append_attempt",
resource_id=resource_id,
optional_module="mail",
)
job, campaign = _job_and_campaign(session, attempt.job_id)
child_item = _delivery_evidence_provenance(
principal,
resource_id=attempt.id,
source="campaigns.imap_append_attempt",
label="Sent-folder append attempt",
campaign=campaign,
job=job,
status=attempt.status,
attempt_number=attempt.attempt_number,
details={
"provider_module": "mail",
"evidence_availability": "persisted_locally",
"mailbox_details_disclosed": False,
},
)
elif normalized_type in CAMPAIGN_POSTBOX_ATTEMPT_RESOURCE_TYPES:
attempt = session.get(PostboxDeliveryAttempt, resource_id) # type: ignore[attr-defined]
if attempt is None:
return _missing_resource_provenance(
principal,
resource_type="campaign_postbox_attempt",
resource_id=resource_id,
optional_module="postbox",
)
job, campaign = _job_and_campaign(session, attempt.job_id)
child_item = _delivery_evidence_provenance(
principal,
resource_id=attempt.id,
source="campaigns.postbox_attempt",
label="Postbox delivery attempt",
campaign=campaign,
job=job,
status=attempt.status,
attempt_number=attempt.attempt_number,
details={
"provider_module": "postbox",
"evidence_availability": "persisted_locally",
"target_data_disclosed": False,
"operator_reconciliation_recorded": bool(
isinstance(attempt.evidence, Mapping)
and attempt.evidence.get("operator_reconciliation")
),
"reconciliation_note_disclosed": False,
},
)
elif normalized_type in CAMPAIGN_PRINT_ATTEMPT_RESOURCE_TYPES:
attempt = session.get(PrintOutputAttempt, resource_id) # type: ignore[attr-defined]
if attempt is None:
return _missing_resource_provenance(
principal,
resource_type="campaign_print_attempt",
resource_id=resource_id,
optional_module="templates",
)
job, campaign = _job_and_campaign(session, attempt.job_id)
child_item = _delivery_evidence_provenance(
principal,
resource_id=attempt.id,
source="campaigns.print_attempt",
label="Printable output attempt",
campaign=campaign,
job=job,
status=attempt.status,
attempt_number=attempt.attempt_number,
details={
"provider_module": "templates",
"evidence_availability": "persisted_locally",
"artifact_locator_disclosed": False,
},
)
elif normalized_type in CAMPAIGN_MESSAGE_ACTION_RESOURCE_TYPES:
message_action = session.get(CampaignMessageAction, resource_id) # type: ignore[attr-defined]
if message_action is None:
return _missing_resource_provenance(
principal,
resource_type="campaign_message_action",
resource_id=resource_id,
)
job, campaign = _job_and_campaign(session, message_action.job_id)
child_item = _delivery_evidence_provenance(
principal,
resource_id=message_action.id,
source="campaigns.message_action",
label="Campaign message action",
campaign=campaign,
job=job,
status=message_action.status,
details={
"action_kind": message_action.kind,
"reason_recorded": bool(message_action.reason),
"reason_disclosed": False,
},
)
elif normalized_type in CAMPAIGN_MESSAGE_ACTION_ATTEMPT_RESOURCE_TYPES:
attempt = session.get(CampaignMessageActionAttempt, resource_id) # type: ignore[attr-defined]
if attempt is None:
return _missing_resource_provenance(
principal,
resource_type="campaign_message_action_attempt",
resource_id=resource_id,
)
message_action = session.get(CampaignMessageAction, attempt.action_id) # type: ignore[attr-defined]
if message_action is None:
return _missing_resource_provenance(
principal,
resource_type="campaign_message_action_attempt",
resource_id=resource_id,
)
job, campaign = _job_and_campaign(session, message_action.job_id)
child_item = _delivery_evidence_provenance(
principal,
resource_id=attempt.id,
source="campaigns.message_action_attempt",
label="Campaign message action attempt",
campaign=campaign,
job=job,
status=attempt.status,
attempt_number=attempt.attempt_number,
details={
"action_id": message_action.id,
"action_kind": message_action.kind,
"diagnostic_text_disclosed": False,
},
)
elif normalized_type in CAMPAIGN_RECONCILIATION_RESOURCE_TYPES:
job = session.get(CampaignJob, resource_id) # type: ignore[attr-defined]
if job is None:
return _missing_resource_provenance(
principal,
resource_type="campaign_reconciliation_decision",
resource_id=resource_id,
)
campaign = session.get(Campaign, job.campaign_id) # type: ignore[attr-defined]
reconciliation_recorded = _job_reconciliation_recorded(session, job)
if not reconciliation_recorded:
return _missing_resource_provenance(
principal,
resource_type="campaign_reconciliation_decision",
resource_id=resource_id,
reason="decision_not_recorded",
)
child_item = _child_provenance(
principal,
resource_id=job.id,
source="campaigns.reconciliation_decision",
label="Delivery reconciliation decision",
campaign=campaign,
version_id=job.campaign_version_id,
job_id=job.id,
details={
"channels": reconciliation_recorded,
"send_status": job.send_status,
"postbox_status": job.postbox_status,
"imap_status": job.imap_status,
"evidence_note_recorded": bool(job.last_error),
"evidence_note_disclosed": False,
},
required_actions=RECONCILIATION_ACCESS_REQUIREMENTS,
)
elif normalized_type in CAMPAIGN_REPORT_RESOURCE_TYPES: elif normalized_type in CAMPAIGN_REPORT_RESOURCE_TYPES:
reference = _campaign_report_reference(resource_id) reference = _campaign_report_reference(resource_id)
if reference is None: if reference is None:
@@ -595,6 +1216,7 @@ def _missing_resource_provenance(
resource_type: str, resource_type: str,
resource_id: str, resource_id: str,
reason: str = "not_found", reason: str = "not_found",
optional_module: str | None = None,
) -> tuple[AccessDecisionProvenance, ...]: ) -> tuple[AccessDecisionProvenance, ...]:
return ( return (
AccessDecisionProvenance( AccessDecisionProvenance(
@@ -606,6 +1228,14 @@ def _missing_resource_provenance(
"resource_type": resource_type, "resource_type": resource_type,
"found": False, "found": False,
"reason": reason, "reason": reason,
**(
{
"optional_module": optional_module,
"evidence_availability": "unavailable_or_hidden",
}
if optional_module
else {}
),
}, },
), ),
) )
+58
View File
@@ -653,6 +653,64 @@ manifest = ModuleManifest(
), ),
documentation=( documentation=(
*CAMPAIGN_USER_DOCUMENTATION, *CAMPAIGN_USER_DOCUMENTATION,
DocumentationTopic(
id="campaigns.access.child-evidence",
title="Explain access to Campaign child evidence",
summary="Trace recipient, attachment, review, delivery, and reconciliation access without disclosing the protected payload.",
body=(
"Campaign child explanations first identify the parent Campaign and immutable version, then state whether owner, "
"group, share, or tenant administration provides the inherited boundary. Recipient evidence additionally requires "
"recipient-read authority; review and attachment overrides require review and diagnostic authority; delivery status, "
"diagnostics, exports, and reconciliation remain separately permissioned. Version-bound children use a version UUID "
"and random job UUID. Missing, cross-tenant, and stale references return non-disclosing provenance. Explanations expose "
"bounded state and hashes only, never recipient addresses, source rows, filenames, storage locators, transport responses, "
"worker claims, provider targets, or operator notes. Persisted Mail, Postbox, and printable attempt evidence remains "
"explainable after an optional provider is disabled; an absent child reports only unavailable-or-hidden."
),
layer="available",
documentation_types=("admin",),
audience=("administrator", "security_reviewer", "campaign_operator"),
order=43,
conditions=(
DocumentationCondition(
required_modules=("campaigns",),
any_scopes=(
"campaigns:diagnostic:read",
"campaigns:report:read",
"campaigns:recipient:read",
"admin:users:read",
),
),
),
links=(
DocumentationLink(
label="Campaign access-explanation coverage",
href="govoplan-campaign/docs/ACCESS_EXPLANATION_COVERAGE.md",
kind="repository",
),
),
related_modules=("access", "mail", "postbox", "templates"),
translations={
"de": {
"title": "Zugriff auf untergeordnete Campaign-Nachweise erklaeren",
"summary": "Zugriff auf Empfaenger-, Anlagen-, Pruef-, Zustell- und Abgleichnachweise ohne Offenlegung der geschuetzten Inhalte nachvollziehen.",
"body": (
"Zugriffserklaerungen fuer untergeordnete Campaign-Nachweise nennen zuerst die uebergeordnete Campaign und "
"die unveraenderliche Version. Danach zeigen sie, ob Eigentum, Gruppe, Freigabe oder Mandantenadministration "
"die geerbte Grenze begruendet. Empfaengernachweise erfordern zusaetzlich Leserecht fuer Empfaenger; Pruef- und "
"Anlagenausnahmen erfordern Pruef- und Diagnoserecht. Zustellstatus, Diagnostik, Export und Abgleich bleiben getrennt "
"berechtigt. Versionsgebundene Nachweise verwenden Versions-UUID und zufaellige Auftrags-UUID. Fehlende, mandantenfremde "
"oder veraltete Verweise liefern keine geschuetzten Daten. Adressen, Quellzeilen, Dateinamen, Speicherorte, Transportantworten, "
"Worker-Claims, Anbieterziele und Bediennotizen werden nie offengelegt. Dauerhafte Mail-, Postbox- und Drucknachweise bleiben "
"auch nach Deaktivierung eines optionalen Anbieters erklaerbar; ein fehlender Nachweis meldet nur nicht verfuegbar oder verborgen."
),
}
},
metadata={
"kind": "reference",
"help_contexts": ["campaign.access", "campaign.report", "campaign.operator-queue"],
},
),
DocumentationTopic( DocumentationTopic(
id="campaigns.search.campaigns", id="campaigns.search.campaigns",
title="Search authorized campaigns", title="Search authorized campaigns",
+403 -3
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime
import json
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -11,12 +13,22 @@ from govoplan_campaign.backend.capabilities import (
CampaignAccessService, CampaignAccessService,
CampaignOwnershipService, CampaignOwnershipService,
campaign_report_resource_id, campaign_report_resource_id,
campaign_version_child_resource_id,
) )
from govoplan_campaign.backend.db.models import ( from govoplan_campaign.backend.db.models import (
AttachmentBlob,
AttachmentInstance,
Campaign, Campaign,
CampaignIssue,
CampaignJob, CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
CampaignShare, CampaignShare,
CampaignVersion, CampaignVersion,
ImapAppendAttempt,
PostboxDeliveryAttempt,
PrintOutputAttempt,
SendAttempt,
) )
from govoplan_core.core.access import PrincipalRef from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.core.change_sequence import ChangeSequenceEntry
@@ -181,6 +193,378 @@ class CampaignAccessProviderTests(unittest.TestCase):
self.assertEqual(report.details["report_kind"], "delivery") self.assertEqual(report.details["report_kind"], "delivery")
self.assertFalse(report.details["persisted"]) self.assertFalse(report.details["persisted"])
def test_recipient_attachment_and_review_children_are_bounded_and_versioned(self) -> None:
session = _session()
self.addCleanup(_close_session, session)
_seed_access_subjects(session)
campaign = Campaign(
id="campaign-sensitive",
tenant_id=TENANT_ID,
owner_user_id=OTHER_USER_ID,
external_id="sensitive",
name="Sensitive child evidence",
)
version = CampaignVersion(
id="version-sensitive",
campaign_id=campaign.id,
version_number=4,
raw_json={},
execution_snapshot={"secret_source_rows": ["do-not-disclose"]},
execution_snapshot_hash="a" * 64,
)
job = CampaignJob(
id="job-sensitive",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
campaign_version_id=version.id,
entry_index=7,
recipient_email="hidden-recipient@example.test",
execution_input_sha256="b" * 64,
resolved_attachments=[{"filename": "hidden-file.pdf", "storage_key": "secret/key"}],
issues_snapshot=[{
"code": "attachment_override_required",
"source": "attachments.policy",
"behavior": "ask",
"message": "hidden validation detail",
}],
)
version.editor_state = {
"review_send": {
"issue_decisions": [{
"job_id": job.id,
"review_key": "opaque-review-key",
"decision": "accept",
"reason": "hidden operator reason",
"decided_at": "2026-08-19T10:00:00+00:00",
"issue_fingerprint": "c" * 64,
}]
}
}
issue = CampaignIssue(
id="issue-sensitive",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
campaign_version_id=version.id,
job_id=job.id,
severity="warning",
code="attachment_override_required",
message="hidden validation detail",
source="hidden/source/path",
behavior="ask",
)
blob = AttachmentBlob(
id="blob-sensitive",
tenant_id=TENANT_ID,
sha256="d" * 64,
size_bytes=123,
storage_bucket="secret-bucket",
storage_key="secret/object",
)
attachment = AttachmentInstance(
id="attachment-sensitive",
tenant_id=TENANT_ID,
owner_user_id=OTHER_USER_ID,
campaign_id=campaign.id,
blob_id=blob.id,
filename="hidden-file.pdf",
)
session.add_all([
campaign,
version,
job,
issue,
blob,
attachment,
CampaignShare(
id="share-sensitive",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
target_type="group",
target_id=GROUP_ID,
permission="read",
),
])
session.commit()
service = CampaignAccessService()
principal = _principal(
scopes={
"campaigns:campaign:read",
"campaigns:recipient:read",
"campaigns:campaign:review",
"campaigns:diagnostic:read",
},
group_ids={GROUP_ID},
)
version_child_id = campaign_version_child_resource_id(
version_id=version.id,
child_id=job.id,
)
cases = (
("campaign_recipient_source_snapshot", version.id, "campaigns.recipient_source_snapshot"),
("campaign_recipient", version_child_id, "campaigns.recipient"),
("campaign_attachment_binding", attachment.id, "campaigns.attachment_binding"),
("campaign_attachment_resolution", version_child_id, "campaigns.attachment_resolution"),
("campaign_validation_issue", issue.id, "campaigns.validation_issue"),
("campaign_review_decision", version_child_id, "campaigns.review_decision"),
("campaign_attachment_override", version_child_id, "campaigns.attachment_override"),
)
for resource_type, resource_id, source in cases:
items = service.explain_resource_provenance(
session,
principal,
resource_type=resource_type,
resource_id=resource_id,
action="campaigns:diagnostic:read",
)
child = next(item for item in items if item.source == source)
self.assertEqual(campaign.id, child.details["campaign_id"])
self.assertEqual(
{"resource_type": "campaign", "resource_id": campaign.id},
child.details["authorization_inherited_from"],
)
self.assertEqual("inherited_and_further_restricted", child.details["authorization_mode"])
self.assertTrue(any(item.id == "share-sensitive" for item in items))
serialized = json.dumps(child.details, sort_keys=True)
for hidden in (
"hidden-recipient@example.test",
"hidden-file.pdf",
"secret/object",
"secret/key",
"hidden validation detail",
"hidden operator reason",
"hidden/source/path",
"do-not-disclose",
):
self.assertNotIn(hidden, serialized)
denied_items = service.explain_resource_provenance(
session,
_principal(scopes=set()),
resource_type="campaign_recipient",
resource_id=version_child_id,
action="campaigns:recipient:read",
)
denied_child = next(item for item in denied_items if item.source == "campaigns.recipient")
self.assertIn("campaigns:recipient:read", denied_child.details["permission_actions"])
self.assertFalse(any(item.kind in {"owner", "share", "policy"} for item in denied_items))
missing = service.explain_resource_provenance(
session,
principal,
resource_type="campaign_recipient",
resource_id=f"{version.id}:missing-job",
action="campaigns:recipient:read",
)
self.assertEqual("campaigns.not_found", missing[0].source)
self.assertNotIn("hidden-recipient", json.dumps(missing[0].details))
stale = service.explain_resource_provenance(
session,
principal,
resource_type="campaign_review_decision",
resource_id=f"stale-version:{job.id}",
action="campaigns:diagnostic:read",
)
self.assertEqual("stale_version_reference", stale[0].details["reason"])
hidden = service.explain_resource_provenance(
session,
_principal(tenant_id="tenant-2"),
resource_type="campaign_validation_issue",
resource_id=issue.id,
action="campaigns:diagnostic:read",
)
self.assertEqual("campaigns.not_found", hidden[0].source)
self.assertNotIn(issue.code, json.dumps(hidden[0].details))
def test_delivery_evidence_explanations_separate_permissions_and_hide_transport_data(self) -> None:
session = _session()
self.addCleanup(_close_session, session)
_seed_access_subjects(session)
campaign = Campaign(
id="campaign-delivery-evidence",
tenant_id=TENANT_ID,
owner_user_id=USER_ID,
external_id="delivery-evidence",
name="Delivery evidence",
)
version = CampaignVersion(
id="version-delivery-evidence",
campaign_id=campaign.id,
version_number=1,
raw_json={},
)
job = CampaignJob(
id="job-delivery-evidence",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
campaign_version_id=version.id,
entry_index=1,
recipient_email="hidden-delivery-recipient@example.test",
send_status="sent",
postbox_status="accepted",
imap_status="appended",
last_error="hidden reconciliation note",
)
send_attempt = SendAttempt(
id="send-attempt-evidence",
job_id=job.id,
attempt_number=1,
status="reconciled_not_sent",
claim_token="hidden-claim-token",
smtp_response="hidden-smtp-response",
)
imap_attempt = ImapAppendAttempt(
id="imap-attempt-evidence",
job_id=job.id,
attempt_number=1,
status="reconciled_imap_appended",
folder="Hidden/Sent",
error_message="hidden imap note",
)
postbox_attempt = PostboxDeliveryAttempt(
id="postbox-attempt-evidence",
tenant_id=TENANT_ID,
job_id=job.id,
target_key="opaque-target",
target_index=0,
attempt_number=1,
idempotency_key="opaque-postbox-key",
status="accepted",
target_snapshot={"address": "hidden postbox address"},
evidence={
"operator_reconciliation": {
"decision": "accepted",
"note": "hidden postbox note",
}
},
)
print_attempt = PrintOutputAttempt(
id="print-attempt-evidence",
tenant_id=TENANT_ID,
job_id=job.id,
attempt_number=1,
idempotency_key="opaque-print-key",
status="accepted",
render_id="hidden-render-id",
evidence={"storage_key": "hidden print locator"},
)
message_action = CampaignMessageAction(
id="message-action-evidence",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
campaign_version_id=version.id,
job_id=job.id,
kind="retry",
idempotency_key="opaque-action-key",
canonical_request_hash="e" * 64,
reason="hidden action reason",
message_sha256="f" * 64,
recipient_manifest_sha256="1" * 64,
recipient_count=1,
prior_send_status="failed",
prior_attempt_count=1,
status="completed",
)
action_attempt = CampaignMessageActionAttempt(
id="message-action-attempt-evidence",
action_id=message_action.id,
attempt_number=1,
status="completed",
started_at=datetime.now(UTC),
diagnostic_summary="hidden diagnostic summary",
)
session.add_all([
campaign,
version,
job,
send_attempt,
imap_attempt,
postbox_attempt,
print_attempt,
message_action,
action_attempt,
])
session.commit()
service = CampaignAccessService()
principal = _principal(scopes={
"campaigns:campaign:read",
"campaigns:report:read",
"campaigns:report:export",
"campaigns:diagnostic:read",
"campaigns:campaign:reconcile",
})
cases = (
("campaign_send_attempt", send_attempt.id, "campaigns.send_attempt"),
("campaign_imap_append_attempt", imap_attempt.id, "campaigns.imap_append_attempt"),
("campaign_postbox_attempt", postbox_attempt.id, "campaigns.postbox_attempt"),
("campaign_print_attempt", print_attempt.id, "campaigns.print_attempt"),
("campaign_message_action", message_action.id, "campaigns.message_action"),
("campaign_message_action_attempt", action_attempt.id, "campaigns.message_action_attempt"),
)
for resource_type, resource_id, source in cases:
items = service.explain_resource_provenance(
session,
principal,
resource_type=resource_type,
resource_id=resource_id,
action="campaigns:diagnostic:read",
)
child = next(item for item in items if item.source == source)
self.assertEqual(campaign.id, child.details["campaign_id"])
self.assertEqual(version.id, child.details["campaign_version_id"])
self.assertEqual(job.id, child.details["job_id"])
self.assertEqual(
{
"read": ["campaigns:campaign:read"],
"report": ["campaigns:report:read"],
"diagnostic": ["campaigns:diagnostic:read"],
"export": ["campaigns:report:export"],
},
child.details["permission_classes"],
)
serialized = json.dumps(child.details, sort_keys=True)
for hidden_value in (
"hidden-delivery-recipient@example.test",
"hidden-claim-token",
"hidden-smtp-response",
"Hidden/Sent",
"hidden imap note",
"hidden postbox address",
"hidden postbox note",
"hidden-render-id",
"hidden print locator",
"hidden action reason",
"hidden diagnostic summary",
"hidden reconciliation note",
):
self.assertNotIn(hidden_value, serialized)
reconciliation = service.explain_resource_provenance(
session,
principal,
resource_type="campaign_reconciliation_decision",
resource_id=job.id,
action="campaigns:campaign:reconcile",
)
decision = next(item for item in reconciliation if item.source == "campaigns.reconciliation_decision")
self.assertEqual(["smtp", "imap", "postbox"], decision.details["channels"])
self.assertTrue(decision.details["evidence_note_recorded"])
self.assertFalse(decision.details["evidence_note_disclosed"])
unavailable = service.explain_resource_provenance(
session,
principal,
resource_type="campaign_postbox_attempt",
resource_id="missing-postbox-attempt",
action="campaigns:diagnostic:read",
)
self.assertEqual("postbox", unavailable[0].details["optional_module"])
self.assertEqual("unavailable_or_hidden", unavailable[0].details["evidence_availability"])
self.assertNotIn("hidden postbox address", json.dumps(unavailable[0].details))
def test_campaign_ownership_provider_requires_group_acceptance_authority(self) -> None: def test_campaign_ownership_provider_requires_group_acceptance_authority(self) -> None:
session = _session() session = _session()
self.addCleanup(_close_session, session) self.addCleanup(_close_session, session)
@@ -398,6 +782,15 @@ def _session():
CampaignShare.__table__, CampaignShare.__table__,
CampaignVersion.__table__, CampaignVersion.__table__,
CampaignJob.__table__, CampaignJob.__table__,
CampaignIssue.__table__,
AttachmentBlob.__table__,
AttachmentInstance.__table__,
SendAttempt.__table__,
CampaignMessageAction.__table__,
CampaignMessageActionAttempt.__table__,
ImapAppendAttempt.__table__,
PostboxDeliveryAttempt.__table__,
PrintOutputAttempt.__table__,
ChangeSequenceEntry.__table__, ChangeSequenceEntry.__table__,
], ],
) )
@@ -421,11 +814,18 @@ def _seed_access_subjects(session) -> None:
session.commit() session.commit()
def _principal(*, scopes: set[str] | None = None, group_ids: set[str] | None = None) -> PrincipalRef: def _principal(
*,
scopes: set[str] | None = None,
group_ids: set[str] | None = None,
tenant_id: str = TENANT_ID,
) -> PrincipalRef:
return PrincipalRef( return PrincipalRef(
account_id="account-1", account_id="account-1",
membership_id=USER_ID, membership_id=USER_ID,
tenant_id=TENANT_ID, tenant_id=tenant_id,
scopes=frozenset(scopes or {"campaigns:campaign:read"}), scopes=frozenset(
{"campaigns:campaign:read"} if scopes is None else scopes
),
group_ids=frozenset(group_ids or set()), group_ids=frozenset(group_ids or set()),
) )