from __future__ import annotations from collections.abc import Iterable, Mapping from fastapi import HTTPException from sqlalchemy import and_, or_ from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef from govoplan_core.core.campaigns import ( CampaignAccessProvider, CampaignDeliveryTaskProvider, CampaignMailPolicyContext, CampaignMailPolicyContextProvider, CampaignPolicyContext, CampaignPolicyContextProvider, CampaignRetentionProvider, CampaignScheduleProvider, ) from govoplan_core.core.ownership import ( OwnershipActionDecision, OwnershipSubjectRef, OwnershipTransfer, OwnershipTransferError, ) from govoplan_core.security.module_permissions import scopes_grant_compatible from govoplan_campaign.backend.db.models import ( AttachmentInstance, CampaignIssue, Campaign, CampaignCollaborationEntry, CampaignJob, CampaignMessageAction, CampaignMessageActionAttempt, CampaignShare, CampaignVersion, CampaignWorkAssignment, CampaignWorkAssignmentEvent, ImapAppendAttempt, PostboxDeliveryAttempt, PrintOutputAttempt, RecipientImportMappingProfile, SendAttempt, ) READ_ACTIONS = { "campaigns:campaign:read", "campaign:read", "campaigns:discussion:read", "campaigns:discussion:post", "campaigns:discussion:moderate", "campaigns:assignment:read", "campaigns:assignment:manage", "campaigns:assignment:complete", } CAMPAIGN_RESOURCE_TYPES = { "campaign", "campaign_object", "campaigns:campaign", } CAMPAIGN_VERSION_RESOURCE_TYPES = { "campaign_version", "campaigns:version", } CAMPAIGN_COLLABORATION_RESOURCE_TYPES = { "campaign_collaboration_entry", "campaigns:collaboration_entry", } CAMPAIGN_WORK_ASSIGNMENT_RESOURCE_TYPES = { "campaign_work_assignment", "campaigns:work_assignment", } CAMPAIGN_WORK_ASSIGNMENT_EVENT_RESOURCE_TYPES = { "campaign_work_assignment_event", "campaigns:work_assignment_event", } CAMPAIGN_DELIVERY_JOB_RESOURCE_TYPES = { "campaign_delivery_job", "campaign_job", "campaigns:delivery_job", } 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", } CAMPAIGN_SHARE_RESOURCE_TYPES = {"campaign_share", "campaigns:share"} CAMPAIGN_OWNERSHIP_TRANSFER_RESOURCE_TYPES = { "campaign_ownership_transfer", "campaigns:ownership_transfer", } CAMPAIGN_IMPORT_MAPPING_PROFILE_RESOURCE_TYPES = { "campaign_import_mapping_profile", "campaigns:import_mapping_profile", } CAMPAIGN_IMPORT_EXECUTION_RESOURCE_TYPES = { "campaign_import_execution", "campaigns:import_execution", } CAMPAIGN_PROTOCOL_ARTIFACT_RESOURCE_TYPES = { "campaign_protocol_artifact", "campaigns:protocol_artifact", } CAMPAIGN_TEMPLATE_RESOURCE_TYPES = { "campaign_template", "campaigns:template", "campaign_template_revision", "campaigns:template_revision", } CAMPAIGN_EXPORT_PACKAGE_RESOURCE_TYPES = { "campaign_export_package", "campaigns:export_package", } CAMPAIGN_PROTOCOL_ARTIFACT_KINDS = frozenset( {"validation", "build", "execution_snapshot", "review"} ) 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", ) GOVERNANCE_ACCESS_REQUIREMENTS = ("campaigns:campaign:share",) IMPORT_PROFILE_ACCESS_REQUIREMENTS = ("campaigns:recipient:import",) def campaign_report_resource_id( *, campaign_id: str, version_id: str, report_kind: str, ) -> str: kind = report_kind.strip().lower() if not campaign_id or not version_id or not kind or ":" in kind: raise ValueError("Campaign report references require campaign, version, and a bounded kind") return f"{campaign_id}:{version_id}:{kind}" def campaign_version_child_resource_id(*, version_id: str, child_id: str) -> str: if not version_id or not child_id or ":" in version_id or ":" in child_id: raise ValueError("Campaign child references require bounded version and child ids") return f"{version_id}:{child_id}" def campaign_import_execution_resource_id(*, version_id: str, import_id: str) -> str: """Return the stable typed reference for one saved recipient import.""" return campaign_version_child_resource_id(version_id=version_id, child_id=import_id) def campaign_protocol_artifact_resource_id(*, version_id: str, artifact_kind: str) -> str: """Return the stable reference for one persisted version evidence class.""" kind = artifact_kind.strip().lower() if kind not in CAMPAIGN_PROTOCOL_ARTIFACT_KINDS: raise ValueError("Campaign protocol artifact kind is not supported") return campaign_version_child_resource_id(version_id=version_id, child_id=kind) def _campaign_report_reference( resource_id: str, ) -> tuple[str, str, str] | None: parts = resource_id.split(":", 2) if len(parts) != 3 or not all(part.strip() for part in parts): return None 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 _saved_import(version: CampaignVersion, import_id: str) -> Mapping[str, object] | None: raw_json = version.raw_json if isinstance(version.raw_json, Mapping) else {} entries = raw_json.get("entries") imports = entries.get("imports") if isinstance(entries, Mapping) else () for item in imports if isinstance(imports, list) else (): if isinstance(item, Mapping) and str(item.get("id") or "") == import_id: return item return None def _protocol_artifact_details(version: CampaignVersion, artifact_kind: str) -> Mapping[str, object] | None: if artifact_kind == "validation": if not isinstance(version.validation_summary, Mapping): return None return {"artifact_kind": artifact_kind, "persisted": True, "artifact_content_disclosed": False} if artifact_kind == "build": if not isinstance(version.build_summary, Mapping): return None return {"artifact_kind": artifact_kind, "persisted": True, "artifact_content_disclosed": False} if artifact_kind == "execution_snapshot": if not version.execution_snapshot_hash: return None return { "artifact_kind": artifact_kind, "persisted": True, "snapshot_hash": version.execution_snapshot_hash, "recorded_at": _iso_value(version.execution_snapshot_at), "artifact_content_disclosed": False, } if artifact_kind == "review": editor_state = version.editor_state if isinstance(version.editor_state, Mapping) else {} review_state = editor_state.get("review_send") if not isinstance(review_state, Mapping): return None decisions = review_state.get("issue_decisions") return { "artifact_kind": artifact_kind, "persisted": True, "decision_count": len(decisions) if isinstance(decisions, list) else 0, "reason_content_disclosed": False, "artifact_content_disclosed": False, } return None 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, session: object, *, tenant_id: str, campaign_id: str, ) -> CampaignMailPolicyContext | None: campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined] if campaign is None or campaign.tenant_id != tenant_id: return None return CampaignMailPolicyContext( id=campaign.id, tenant_id=campaign.tenant_id, owner_user_id=campaign.owner_user_id, owner_group_id=campaign.owner_group_id, mail_profile_policy=dict(campaign.mail_profile_policy or {}), ) def set_campaign_mail_profile_policy( self, session: object, *, tenant_id: str, campaign_id: str, policy: Mapping[str, object], ) -> Mapping[str, object]: campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined] if campaign is None or campaign.tenant_id != tenant_id: raise ValueError("Campaign policy not found") payload = dict(policy) campaign.mail_profile_policy = payload session.add(campaign) # type: ignore[attr-defined] return payload def mail_policy_context_capability(context: object) -> CampaignMailPolicyContextService: return CampaignMailPolicyContextService() class CampaignAccessService(CampaignAccessProvider): def campaign_exists(self, session: object, *, tenant_id: str, campaign_id: str) -> bool: campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined] return bool(campaign and campaign.tenant_id == tenant_id) def can_read_campaign( self, session: object, *, tenant_id: str, campaign_id: str, user_id: str, group_ids: Iterable[str] = (), tenant_admin: bool = False, ) -> bool: campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined] if campaign is None or campaign.tenant_id != tenant_id: return False if tenant_admin or campaign.owner_user_id == user_id: return True group_id_set = {str(group_id) for group_id in group_ids} if campaign.owner_group_id and campaign.owner_group_id in group_id_set: return True share_clauses = [ and_( CampaignShare.target_type == "user", CampaignShare.target_id == user_id, ) ] if group_id_set: share_clauses.append( and_( CampaignShare.target_type == "group", CampaignShare.target_id.in_(group_id_set), ) ) share = ( session.query(CampaignShare) # type: ignore[attr-defined] .filter( CampaignShare.tenant_id == tenant_id, CampaignShare.campaign_id == campaign.id, CampaignShare.revoked_at.is_(None), or_(*share_clauses), ) .first() ) return share is not None def explain_resource_provenance( self, session: object, principal: PrincipalRef, *, resource_type: str, resource_id: str, action: str, ) -> tuple[AccessDecisionProvenance, ...]: normalized_type = resource_type.lower().strip() child_item: AccessDecisionProvenance | None = None campaign: Campaign | None if normalized_type in CAMPAIGN_TEMPLATE_RESOURCE_TYPES: return _missing_resource_provenance( principal, resource_type=normalized_type, resource_id=resource_id, reason="independently_governed_by_optional_module", optional_module="templates", ) if normalized_type in CAMPAIGN_EXPORT_PACKAGE_RESOURCE_TYPES: return _missing_resource_provenance( principal, resource_type=normalized_type, resource_id=resource_id, reason="independently_governed_by_optional_module", optional_module="reporting", ) if normalized_type in CAMPAIGN_IMPORT_MAPPING_PROFILE_RESOURCE_TYPES: profile = session.get(RecipientImportMappingProfile, resource_id) # type: ignore[attr-defined] if profile is None or (principal.tenant_id and profile.tenant_id != principal.tenant_id): return _missing_resource_provenance( principal, resource_type="campaign_import_mapping_profile", resource_id=resource_id, ) items = [ AccessDecisionProvenance( kind="resource", id=profile.id, label="Recipient import mapping profile", tenant_id=profile.tenant_id, source="campaigns.import_mapping_profile", details={ "resource_type": "campaign_import_mapping_profile", "authorization_mode": "independently_owned", "permission_actions": list(IMPORT_PROFILE_ACCESS_REQUIREMENTS), "column_count": profile.column_count, "ordered_header_fingerprint": profile.ordered_header_fingerprint, "unordered_header_fingerprint": profile.unordered_header_fingerprint, "headers_disclosed": False, "mapping_disclosed": False, "owner_identity_disclosed": False, }, ) ] if profile.owner_user_id == principal.membership_id: items.append( AccessDecisionProvenance( kind="owner", id=profile.owner_user_id, tenant_id=profile.tenant_id, source="campaigns.import_mapping_profile_owner", details={"owner_type": "user"}, ) ) items.extend(_tenant_admin_provenance(principal)) return tuple(items) if normalized_type in CAMPAIGN_RESOURCE_TYPES: campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined] elif normalized_type in CAMPAIGN_COLLABORATION_RESOURCE_TYPES: entry = session.get(CampaignCollaborationEntry, resource_id) # type: ignore[attr-defined] if entry is None: return _missing_resource_provenance( principal, resource_type="campaign_collaboration_entry", resource_id=resource_id, ) campaign = session.get(Campaign, entry.campaign_id) # type: ignore[attr-defined] normalized_action = action.strip().lower() required_actions = ( ("campaigns:discussion:moderate",) if normalized_action.endswith(":moderate") else ( ("campaigns:discussion:post",) if normalized_action.endswith(":post") else ("campaigns:discussion:read",) ) ) child_item = _child_provenance( principal, resource_id=entry.id, source="campaigns.collaboration_entry", label="Campaign collaboration entry", campaign=campaign, version_id=entry.campaign_version_id, details={ "resource_type": "campaign_collaboration_entry", "visibility": entry.visibility, "reference_kind": entry.reference_kind, "reference_id": entry.reference_id, "state": ( "redacted" if entry.redacted_at else "withdrawn" if entry.withdrawn_at else "posted" ), "content_disclosed": False, "actor_identity_disclosed": False, "permission_classes": { "read": ["campaigns:discussion:read"], "post": ["campaigns:discussion:post"], "moderate": ["campaigns:discussion:moderate"], }, }, required_actions=required_actions, ) elif normalized_type in CAMPAIGN_WORK_ASSIGNMENT_RESOURCE_TYPES: assignment = session.get(CampaignWorkAssignment, resource_id) # type: ignore[attr-defined] if assignment is None: return _missing_resource_provenance( principal, resource_type="campaign_work_assignment", resource_id=resource_id, ) campaign = session.get(Campaign, assignment.campaign_id) # type: ignore[attr-defined] normalized_action = action.strip().lower() required_actions = ( ("campaigns:assignment:manage",) if normalized_action.endswith(":manage") else ( ("campaigns:assignment:complete",) if normalized_action.endswith(":complete") else ("campaigns:assignment:read",) ) ) child_item = _child_provenance( principal, resource_id=assignment.id, source="campaigns.work_assignment", label="Campaign work assignment", campaign=campaign, version_id=assignment.campaign_version_id, details={ "resource_type": "campaign_work_assignment", "status": assignment.status, "due_at": _iso_value(assignment.due_at), "assignee_type": assignment.assignee_type, "assignee_resolution_state": assignment.assignee_resolution_state, "reference_kind": assignment.reference_kind, "reference_id": assignment.reference_id, "revision": assignment.resource_revision, "purpose_disclosed": False, "assignee_identity_disclosed": False, "authorization_mode": "accountability_does_not_grant_access", "assignment_policy_provenance": dict( assignment.resolution_provenance or {} ), "permission_classes": { "read": ["campaigns:assignment:read"], "complete": ["campaigns:assignment:complete"], "manage": ["campaigns:assignment:manage"], }, }, required_actions=required_actions, ) elif normalized_type in CAMPAIGN_WORK_ASSIGNMENT_EVENT_RESOURCE_TYPES: event = session.get(CampaignWorkAssignmentEvent, resource_id) # type: ignore[attr-defined] if event is None: return _missing_resource_provenance( principal, resource_type="campaign_work_assignment_event", resource_id=resource_id, ) assignment = session.get(CampaignWorkAssignment, event.assignment_id) # type: ignore[attr-defined] campaign = session.get(Campaign, event.campaign_id) # type: ignore[attr-defined] child_item = _child_provenance( principal, resource_id=event.id, source="campaigns.work_assignment_event", label="Campaign work assignment event", campaign=campaign, version_id=assignment.campaign_version_id if assignment else None, details={ "resource_type": "campaign_work_assignment_event", "assignment_id": event.assignment_id, "event_kind": event.event_kind, "status": event.status_snapshot, "assignee_type": event.assignee_type_snapshot, "resolution_state": event.resolution_state_snapshot, "event_details_disclosed": False, "assignee_identity_disclosed": False, "authorization_mode": "immutable_accountability_evidence", }, required_actions=("campaigns:assignment:read",), ) elif normalized_type in CAMPAIGN_SHARE_RESOURCE_TYPES: share = session.get(CampaignShare, resource_id) # type: ignore[attr-defined] if share is None: return _missing_resource_provenance(principal, resource_type="campaign_share", resource_id=resource_id) campaign = session.get(Campaign, share.campaign_id) # type: ignore[attr-defined] child_item = _child_provenance( principal, resource_id=share.id, source="campaigns.share_record", label="Campaign share", campaign=campaign, version_id=None, details={ "resource_type": "campaign_share", "state": "revoked" if share.revoked_at else "active", "permission": share.permission, "target_type": share.target_type, "target_identity_disclosed": False, "authorization_mode": "governance_visibility_separate_from_content_access", "content_access_effect": "none" if share.revoked_at else share.permission, }, required_actions=GOVERNANCE_ACCESS_REQUIREMENTS, ) elif normalized_type in CAMPAIGN_OWNERSHIP_TRANSFER_RESOURCE_TYPES: transfer = session.get(OwnershipTransfer, resource_id) # type: ignore[attr-defined] if transfer is None or transfer.resource_module != "campaigns" or transfer.resource_type != "campaign": return _missing_resource_provenance( principal, resource_type="campaign_ownership_transfer", resource_id=resource_id, ) campaign = session.get(Campaign, transfer.resource_id) # type: ignore[attr-defined] if campaign is None or transfer.tenant_id != campaign.tenant_id: return _missing_resource_provenance( principal, resource_type="campaign_ownership_transfer", resource_id=resource_id, ) child_item = _child_provenance( principal, resource_id=transfer.id, source="campaigns.ownership_transfer", label="Campaign ownership transfer", campaign=campaign, version_id=None, details={ "resource_type": "campaign_ownership_transfer", "kind": transfer.kind, "status": transfer.status, "revision": transfer.revision, "current_owner_type": transfer.current_owner_type, "target_owner_type": transfer.target_owner_type, "expires_at": _iso_value(transfer.expires_at), "completed_at": _iso_value(transfer.completed_at), "participant_identities_disclosed": False, "reason_recorded": bool(transfer.reason), "reason_disclosed": False, "authorization_mode": "governance_visibility_separate_from_content_access", "permission_classes": { "governance": ["campaigns:campaign:share"], "group_acceptance": ["campaigns:ownership:accept_group"], "recovery": ["campaigns:ownership:recover"], }, }, required_actions=GOVERNANCE_ACCESS_REQUIREMENTS, ) elif normalized_type in CAMPAIGN_VERSION_RESOURCE_TYPES: version = session.get(CampaignVersion, resource_id) # type: ignore[attr-defined] if version is None: return _missing_resource_provenance( principal, resource_type="campaign_version", resource_id=resource_id, ) campaign = session.get(Campaign, version.campaign_id) # type: ignore[attr-defined] child_item = AccessDecisionProvenance( kind="resource", id=version.id, label=f"Version {version.version_number}", tenant_id=campaign.tenant_id if campaign else principal.tenant_id, source="campaigns.version", details={ "resource_type": "campaign_version", "campaign_id": version.campaign_id, "version_number": version.version_number, "workflow_state": version.workflow_state, "authorization_inherited_from": { "resource_type": "campaign", "resource_id": version.campaign_id, }, }, ) elif normalized_type in CAMPAIGN_IMPORT_EXECUTION_RESOURCE_TYPES: reference = _version_child_reference(resource_id) if reference is None: return _missing_resource_provenance( principal, resource_type="campaign_import_execution", resource_id=resource_id, reason="invalid_reference", ) version_id, import_id = reference version = session.get(CampaignVersion, version_id) # type: ignore[attr-defined] if version is None: return _missing_resource_provenance( principal, resource_type="campaign_import_execution", resource_id=resource_id, ) campaign = session.get(Campaign, version.campaign_id) # type: ignore[attr-defined] saved_import = _saved_import(version, import_id) if saved_import is None: return _missing_resource_provenance( principal, resource_type="campaign_import_execution", resource_id=resource_id, reason="import_not_recorded", ) child_item = _child_provenance( principal, resource_id=resource_id, source="campaigns.import_execution", label="Saved recipient import", campaign=campaign, version_id=version.id, details={ "resource_type": "campaign_import_execution", "import_id": import_id, "imported_at": saved_import.get("imported_at"), "mode": saved_import.get("mode"), "source_type": saved_import.get("source_type"), "source_id": saved_import.get("source_id"), "source_revision": saved_import.get("source_revision"), "source_provenance_recorded": bool(saved_import.get("source_provenance")), "source_rows_disclosed": False, "filename_disclosed": False, "mapping_disclosed": False, }, required_actions=(*RECIPIENT_ACCESS_REQUIREMENTS, "campaigns:recipient:import"), ) elif normalized_type in CAMPAIGN_PROTOCOL_ARTIFACT_RESOURCE_TYPES: reference = _version_child_reference(resource_id) if reference is None: return _missing_resource_provenance( principal, resource_type="campaign_protocol_artifact", resource_id=resource_id, reason="invalid_reference", ) version_id, artifact_kind = reference if artifact_kind not in CAMPAIGN_PROTOCOL_ARTIFACT_KINDS: return _missing_resource_provenance( principal, resource_type="campaign_protocol_artifact", resource_id=resource_id, reason="unsupported_artifact_kind", ) version = session.get(CampaignVersion, version_id) # type: ignore[attr-defined] if version is None: return _missing_resource_provenance( principal, resource_type="campaign_protocol_artifact", resource_id=resource_id, ) campaign = session.get(Campaign, version.campaign_id) # type: ignore[attr-defined] artifact_details = _protocol_artifact_details(version, artifact_kind) if artifact_details is None: return _missing_resource_provenance( principal, resource_type="campaign_protocol_artifact", resource_id=resource_id, reason="artifact_not_recorded", ) required_actions = ( RECIPIENT_ACCESS_REQUIREMENTS if artifact_kind == "execution_snapshot" else REVIEW_EVIDENCE_ACCESS_REQUIREMENTS ) child_item = _child_provenance( principal, resource_id=resource_id, source="campaigns.protocol_artifact", label=f"Campaign {artifact_kind.replace('_', ' ')} evidence", campaign=campaign, version_id=version.id, details={ "resource_type": "campaign_protocol_artifact", "permission_classes": { "read": ["campaigns:campaign:read"], "report": ["campaigns:report:read"], "diagnostic": ["campaigns:diagnostic:read"], "export": ["campaigns:report:export"], }, **artifact_details, }, required_actions=required_actions, ) 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: return _missing_resource_provenance( principal, resource_type="campaign_delivery_job", resource_id=resource_id, ) campaign = session.get(Campaign, job.campaign_id) # type: ignore[attr-defined] child_item = AccessDecisionProvenance( kind="resource", id=job.id, label=job.recipient_email or f"Recipient {job.entry_index}", tenant_id=job.tenant_id, source="campaigns.delivery_job", details={ "resource_type": "campaign_delivery_job", "campaign_id": job.campaign_id, "campaign_version_id": job.campaign_version_id, "queue_status": job.queue_status, "send_status": job.send_status, "authorization_inherited_from": { "resource_type": "campaign", "resource_id": job.campaign_id, }, }, ) 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: return _missing_resource_provenance( principal, resource_type="campaign_report", resource_id=resource_id, reason="invalid_reference", ) campaign_id, version_id, report_kind = reference campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined] version = session.get(CampaignVersion, version_id) # type: ignore[attr-defined] if ( campaign is None or version is None or version.campaign_id != campaign.id ): return _missing_resource_provenance( principal, resource_type="campaign_report", resource_id=resource_id, ) child_item = AccessDecisionProvenance( kind="resource", id=resource_id, label=report_kind, tenant_id=campaign.tenant_id, source="campaigns.report", details={ "resource_type": "campaign_report", "campaign_id": campaign.id, "campaign_version_id": version.id, "report_kind": report_kind, "persisted": False, "authorization_mode": "inherited_and_further_restricted", "permission_classes": { "read": ["campaigns:report:read"], "export": ["campaigns:report:export"], "diagnostic": ["campaigns:diagnostic:read"], }, "authorization_inherited_from": { "resource_type": "campaign", "resource_id": campaign.id, }, }, ) else: return () if campaign is None or (principal.tenant_id and campaign.tenant_id != principal.tenant_id): return _missing_resource_provenance( principal, resource_type=normalized_type, resource_id=resource_id, ) items = ([child_item] if child_item is not None else []) + [ AccessDecisionProvenance( kind="resource", id=campaign.id, label=campaign.name, tenant_id=campaign.tenant_id, source="campaigns.campaign", details={ "resource_type": "campaign", "external_id": campaign.external_id, "status": campaign.status, }, ) ] items.extend(_owner_provenance(campaign, principal)) items.extend(_tenant_admin_provenance(principal)) permission_values = ( {"read", "write"} if action in READ_ACTIONS or action.strip().lower().endswith(":read") else {"write"} ) shares = ( session.query(CampaignShare) # type: ignore[attr-defined] .filter( CampaignShare.tenant_id == campaign.tenant_id, CampaignShare.campaign_id == campaign.id, CampaignShare.revoked_at.is_(None), CampaignShare.permission.in_(sorted(permission_values)), or_( (CampaignShare.target_type == "user") & (CampaignShare.target_id == principal.membership_id), (CampaignShare.target_type == "group") & (CampaignShare.target_id.in_(sorted(principal.group_ids))), ), ) .order_by(CampaignShare.target_type.asc(), CampaignShare.target_id.asc()) .all() ) for share in shares: items.append( AccessDecisionProvenance( kind="share", id=share.id, label=share.permission, tenant_id=share.tenant_id, source="campaigns.share", details={ "target_type": share.target_type, "target_id": share.target_id, "permission": share.permission, }, ) ) return tuple(items) def access_capability(context: object) -> CampaignAccessService: return CampaignAccessService() class CampaignOwnershipService: def current_owner( self, session: object, *, tenant_id: str, resource_id: str, ) -> OwnershipSubjectRef | None: campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined] if campaign is None or campaign.tenant_id != tenant_id: return None if campaign.owner_user_id: return OwnershipSubjectRef(type="user", id=campaign.owner_user_id) if campaign.owner_group_id: return OwnershipSubjectRef(type="group", id=campaign.owner_group_id) return None def authorize_ownership_action( self, session: object, *, tenant_id: str, resource_id: str, action: str, actor: OwnershipSubjectRef, current_owner: OwnershipSubjectRef, target_owner: OwnershipSubjectRef, ) -> OwnershipActionDecision: campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined] if campaign is None or campaign.tenant_id != tenant_id: return OwnershipActionDecision(False, "Campaign was not found") if target_owner.type not in {"user", "group"}: return OwnershipActionDecision( False, "Campaign ownership can be assigned only to a user or group", ) actor_is_current_owner = _actor_controls_owner(actor, current_owner) actor_is_target = _actor_controls_owner(actor, target_owner) actor_can_share = scopes_grant_compatible( actor.scopes, "campaigns:campaign:share", ) actor_can_accept_for_group = scopes_grant_compatible( actor.scopes, "campaigns:ownership:accept_group", ) actor_can_recover = scopes_grant_compatible( actor.scopes, "campaigns:ownership:recover", ) actor_can_read = ( scopes_grant_compatible( actor.scopes, "campaigns:campaign:read", ) and CampaignAccessService().can_read_campaign( session, tenant_id=tenant_id, campaign_id=resource_id, user_id=actor.id, group_ids=actor.group_ids, tenant_admin=scopes_grant_compatible( actor.scopes, "tenant:*", ), ) ) if action in { "propose_transfer", "request_ownership", "request_recovery", }: target_decision = _valid_campaign_owner_target( tenant_id=tenant_id, target_owner=target_owner, ) if target_decision is not None: return target_decision if action == "view_transfer": allowed = ( _actor_is_owner_member(actor, current_owner) or _actor_is_owner_member(actor, target_owner) or actor_can_recover ) elif action in { "propose_transfer", "approve_requested_transfer", }: allowed = actor_is_current_owner and actor_can_share elif action == "cancel_transfer": allowed = ( (actor_is_current_owner and actor_can_share) or actor_is_target ) elif action == "request_ownership": allowed = actor_can_read and actor_is_target and ( target_owner.type != "group" or actor_can_accept_for_group ) elif action == "accept_transfer": allowed = target_owner.type == "user" and actor_is_target elif action == "accept_group_transfer": allowed = ( target_owner.type == "group" and actor_is_target and actor_can_accept_for_group ) elif action == "decline_transfer": allowed = ( actor_is_target and ( target_owner.type != "group" or actor_can_accept_for_group ) ) or ( actor_is_current_owner and actor_can_share ) elif action in { "request_recovery", "approve_recovery", "execute_recovery", }: allowed = actor_can_recover else: allowed = False return OwnershipActionDecision( allowed=allowed, reason=None if allowed else f"Campaign ownership action is not allowed: {action}", ) def apply_owner( self, session: object, *, tenant_id: str, resource_id: str, expected_owner: OwnershipSubjectRef, target_owner: OwnershipSubjectRef, actor: OwnershipSubjectRef, reason: str | None, ) -> None: del actor, reason campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined] if campaign is None or campaign.tenant_id != tenant_id: raise OwnershipTransferError("Campaign was not found") if target_owner.type not in {"user", "group"}: raise OwnershipTransferError( "Campaign ownership can be assigned only to a user or group" ) from govoplan_campaign.backend.route_support import ( _clear_current_version_mail_profile_for_owner_transfer, ) try: _clear_current_version_mail_profile_for_owner_transfer( session, # type: ignore[arg-type] campaign, ) except HTTPException as exc: raise OwnershipTransferError( str( exc.detail or "Campaign owner cannot be changed in its current state" ) ) from exc expected_filter = ( ( Campaign.owner_user_id == expected_owner.id, Campaign.owner_group_id.is_(None), ) if expected_owner.type == "user" else ( Campaign.owner_user_id.is_(None), Campaign.owner_group_id == expected_owner.id, ) ) updated = ( session.query(Campaign) # type: ignore[attr-defined] .filter( Campaign.id == resource_id, Campaign.tenant_id == tenant_id, *expected_filter, ) .update( { Campaign.owner_user_id: ( target_owner.id if target_owner.type == "user" else None ), Campaign.owner_group_id: ( target_owner.id if target_owner.type == "group" else None ), }, synchronize_session=False, ) ) if updated != 1: raise OwnershipTransferError( "Campaign owner changed while the transfer was pending" ) session.expire(campaign) # type: ignore[attr-defined] def _valid_campaign_owner_target( *, tenant_id: str, target_owner: OwnershipSubjectRef, ) -> OwnershipActionDecision | None: from govoplan_campaign.backend.route_support import _access_directory directory = _access_directory() if target_owner.type == "user": item = directory.get_user(target_owner.id) elif target_owner.type == "group": item = directory.get_group(target_owner.id) else: return OwnershipActionDecision( False, "Campaign ownership can be assigned only to a user or group", ) if ( item is None or item.tenant_id != tenant_id or item.status != "active" ): return OwnershipActionDecision( False, f"Target {target_owner.type} is not active in this tenant", ) return None def _actor_is_owner_member( actor: OwnershipSubjectRef, owner: OwnershipSubjectRef, ) -> bool: if owner.type == "group": return owner.id in actor.group_ids return actor.type == owner.type and actor.id == owner.id def _actor_controls_owner( actor: OwnershipSubjectRef, owner: OwnershipSubjectRef, ) -> bool: return _actor_is_owner_member(actor, owner) def _missing_resource_provenance( principal: PrincipalRef, *, resource_type: str, resource_id: str, reason: str = "not_found", optional_module: str | None = None, ) -> tuple[AccessDecisionProvenance, ...]: return ( AccessDecisionProvenance( kind="resource", id=resource_id, tenant_id=principal.tenant_id, source="campaigns.not_found", details={ "resource_type": resource_type, "found": False, "reason": reason, **( { "optional_module": optional_module, "evidence_availability": "unavailable_or_hidden", } if optional_module else {} ), }, ), ) def _owner_provenance(campaign: Campaign, principal: PrincipalRef) -> tuple[AccessDecisionProvenance, ...]: if campaign.owner_user_id and campaign.owner_user_id == principal.membership_id: return ( AccessDecisionProvenance( kind="owner", id=campaign.owner_user_id, tenant_id=campaign.tenant_id, source="campaigns.owner", details={"owner_type": "user"}, ), ) if campaign.owner_group_id and campaign.owner_group_id in principal.group_ids: return ( AccessDecisionProvenance( kind="owner", id=campaign.owner_group_id, tenant_id=campaign.tenant_id, source="campaigns.owner", details={"owner_type": "group"}, ), ) return () def _tenant_admin_provenance(principal: PrincipalRef) -> tuple[AccessDecisionProvenance, ...]: if not scopes_grant_compatible(principal.scopes, "tenant:*"): return () return ( AccessDecisionProvenance( kind="policy", id="tenant:*", label="tenant:*", tenant_id=principal.tenant_id, source="campaigns.tenant_admin", details={"grant": "tenant_admin"}, ), ) class CampaignPolicyContextService(CampaignPolicyContextProvider): def get_campaign_policy_context( self, session: object, *, tenant_id: str | None = None, campaign_id: str, ) -> CampaignPolicyContext | None: campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined] if campaign is None or (tenant_id is not None and campaign.tenant_id != tenant_id): return None return CampaignPolicyContext( id=campaign.id, tenant_id=campaign.tenant_id, owner_user_id=campaign.owner_user_id, owner_group_id=campaign.owner_group_id, settings=dict(campaign.settings or {}), ) def set_campaign_settings( self, session: object, *, tenant_id: str, campaign_id: str, settings: Mapping[str, object], ) -> Mapping[str, object]: campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined] if campaign is None or campaign.tenant_id != tenant_id: raise ValueError("Campaign privacy policy not found") payload = dict(settings) campaign.settings = payload session.add(campaign) # type: ignore[attr-defined] return payload def policy_context_capability(context: object) -> CampaignPolicyContextService: return CampaignPolicyContextService() class CampaignDeliveryTaskService(CampaignDeliveryTaskProvider): def tenant_id_for_job(self, session: object, *, job_id: str) -> str | None: job = session.get(CampaignJob, job_id) # type: ignore[attr-defined] return job.tenant_id if job is not None else None def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]: from govoplan_campaign.backend.sending.jobs import send_campaign_job return send_campaign_job(session, job_id=job_id, enqueue_imap_task=enqueue_imap_task).as_dict() # type: ignore[arg-type] def append_sent_for_job(self, session: object, *, job_id: str) -> Mapping[str, object]: from govoplan_campaign.backend.sending.jobs import append_sent_for_job return append_sent_for_job(session, job_id=job_id).as_dict() # type: ignore[arg-type] def delivery_tasks_capability(context: object) -> CampaignDeliveryTaskService: from govoplan_campaign.backend.runtime import configure_runtime configure_runtime( registry=getattr(context, "registry", None), settings=getattr(context, "settings", None), ) return CampaignDeliveryTaskService() class CampaignScheduleService(CampaignScheduleProvider): def dispatch_due( self, session: object, *, tenant_id: str | None = None, now=None, limit: int = 50, ) -> Mapping[str, object]: from govoplan_campaign.backend.campaign.scheduling import ( dispatch_due_campaign_schedules, ) return dispatch_due_campaign_schedules( session, # type: ignore[arg-type] tenant_id=tenant_id, now=now, limit=limit, ) def schedules_capability(context: object) -> CampaignScheduleService: from govoplan_campaign.backend.runtime import configure_runtime configure_runtime( registry=getattr(context, "registry", None), settings=getattr(context, "settings", None), ) return CampaignScheduleService() class CampaignRetentionService(CampaignRetentionProvider): def apply_retention(self, session: object, *, dry_run, now, policy_for_campaign_id): from govoplan_campaign.backend.retention import apply_campaign_retention return apply_campaign_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id) # type: ignore[arg-type] def retention_capability(context: object) -> CampaignRetentionService: return CampaignRetentionService()