feat(security): explain Campaign child evidence
This commit is contained in:
@@ -24,10 +24,18 @@ from govoplan_core.core.ownership import (
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
AttachmentInstance,
|
||||
CampaignIssue,
|
||||
Campaign,
|
||||
CampaignJob,
|
||||
CampaignMessageAction,
|
||||
CampaignMessageActionAttempt,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
PrintOutputAttempt,
|
||||
SendAttempt,
|
||||
)
|
||||
|
||||
|
||||
@@ -50,6 +58,84 @@ CAMPAIGN_REPORT_RESOURCE_TYPES = {
|
||||
"campaign_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(
|
||||
@@ -64,6 +150,12 @@ def campaign_report_resource_id(
|
||||
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(
|
||||
resource_id: str,
|
||||
) -> tuple[str, str, str] | None:
|
||||
@@ -73,6 +165,149 @@ def _campaign_report_reference(
|
||||
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):
|
||||
def get_campaign_mail_policy_context(
|
||||
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:
|
||||
job = session.get(CampaignJob, resource_id) # type: ignore[attr-defined]
|
||||
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:
|
||||
reference = _campaign_report_reference(resource_id)
|
||||
if reference is None:
|
||||
@@ -595,6 +1216,7 @@ def _missing_resource_provenance(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
reason: str = "not_found",
|
||||
optional_module: str | None = None,
|
||||
) -> tuple[AccessDecisionProvenance, ...]:
|
||||
return (
|
||||
AccessDecisionProvenance(
|
||||
@@ -606,6 +1228,14 @@ def _missing_resource_provenance(
|
||||
"resource_type": resource_type,
|
||||
"found": False,
|
||||
"reason": reason,
|
||||
**(
|
||||
{
|
||||
"optional_module": optional_module,
|
||||
"evidence_availability": "unavailable_or_hidden",
|
||||
}
|
||||
if optional_module
|
||||
else {}
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -653,6 +653,64 @@ manifest = ModuleManifest(
|
||||
),
|
||||
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(
|
||||
id="campaigns.search.campaigns",
|
||||
title="Search authorized campaigns",
|
||||
|
||||
Reference in New Issue
Block a user