feat(security): explain Campaign child evidence
This commit is contained in:
@@ -10,19 +10,45 @@ and they must identify that inheritance explicitly.
|
||||
- Campaign version
|
||||
- Campaign delivery job / built message
|
||||
- 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
|
||||
2. Attachment bindings and frozen attachment resolutions
|
||||
3. Validation issues, review decisions, and attachment-policy overrides
|
||||
4. Delivery attempts, IMAP append attempts, Postbox attempts, and
|
||||
reconciliation decisions
|
||||
5. Campaign shares and ownership-transfer records
|
||||
6. Import mapping profiles and import executions
|
||||
7. Reusable Campaign templates and template revisions when the template
|
||||
## Permission matrix
|
||||
|
||||
| Evidence | Parent boundary | Further restriction |
|
||||
| --- | --- | --- |
|
||||
| Recipient row or source snapshot | Campaign read/owner/share | `campaigns:recipient:read` |
|
||||
| Attachment binding/resolution, validation, review, override | Campaign read/owner/share | Campaign review and `campaigns:diagnostic:read` |
|
||||
| Delivery status | Campaign read/owner/share | `campaigns:report:read` |
|
||||
| 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
|
||||
8. Export packages and protocol/report artifacts
|
||||
4. Export packages and protocol/report artifacts
|
||||
|
||||
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_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",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -11,12 +13,22 @@ from govoplan_campaign.backend.capabilities import (
|
||||
CampaignAccessService,
|
||||
CampaignOwnershipService,
|
||||
campaign_report_resource_id,
|
||||
campaign_version_child_resource_id,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
AttachmentBlob,
|
||||
AttachmentInstance,
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignMessageAction,
|
||||
CampaignMessageActionAttempt,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
PrintOutputAttempt,
|
||||
SendAttempt,
|
||||
)
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
@@ -181,6 +193,378 @@ class CampaignAccessProviderTests(unittest.TestCase):
|
||||
self.assertEqual(report.details["report_kind"], "delivery")
|
||||
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:
|
||||
session = _session()
|
||||
self.addCleanup(_close_session, session)
|
||||
@@ -398,6 +782,15 @@ def _session():
|
||||
CampaignShare.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignJob.__table__,
|
||||
CampaignIssue.__table__,
|
||||
AttachmentBlob.__table__,
|
||||
AttachmentInstance.__table__,
|
||||
SendAttempt.__table__,
|
||||
CampaignMessageAction.__table__,
|
||||
CampaignMessageActionAttempt.__table__,
|
||||
ImapAppendAttempt.__table__,
|
||||
PostboxDeliveryAttempt.__table__,
|
||||
PrintOutputAttempt.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
@@ -421,11 +814,18 @@ def _seed_access_subjects(session) -> None:
|
||||
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(
|
||||
account_id="account-1",
|
||||
membership_id=USER_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
scopes=frozenset(scopes or {"campaigns:campaign:read"}),
|
||||
tenant_id=tenant_id,
|
||||
scopes=frozenset(
|
||||
{"campaigns:campaign:read"} if scopes is None else scopes
|
||||
),
|
||||
group_ids=frozenset(group_ids or set()),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user