diff --git a/docs/ACCESS_EXPLANATION_COVERAGE.md b/docs/ACCESS_EXPLANATION_COVERAGE.md index 8dc7f37..6125e09 100644 --- a/docs/ACCESS_EXPLANATION_COVERAGE.md +++ b/docs/ACCESS_EXPLANATION_COVERAGE.md @@ -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 `:` +- 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: diff --git a/src/govoplan_campaign/backend/capabilities.py b/src/govoplan_campaign/backend/capabilities.py index 74ddb4e..f462c86 100644 --- a/src/govoplan_campaign/backend/capabilities.py +++ b/src/govoplan_campaign/backend/capabilities.py @@ -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 {} + ), }, ), ) diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 0c1062d..9bc879b 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -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", diff --git a/tests/test_access_provider.py b/tests/test_access_provider.py index 37e9a9d..28b2c24 100644 --- a/tests/test_access_provider.py +++ b/tests/test_access_provider.py @@ -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()), )