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
@@ -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 {}
),
},
),
)
+58
View File
@@ -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",