feat(campaign): explain governed child access

This commit is contained in:
2026-08-20 05:38:00 +02:00
parent 112ef9dc31
commit 5c27527725
5 changed files with 531 additions and 12 deletions
@@ -19,6 +19,7 @@ from govoplan_core.core.campaigns import (
from govoplan_core.core.ownership import (
OwnershipActionDecision,
OwnershipSubjectRef,
OwnershipTransfer,
OwnershipTransferError,
)
from govoplan_core.security.module_permissions import scopes_grant_compatible
@@ -35,6 +36,7 @@ from govoplan_campaign.backend.db.models import (
ImapAppendAttempt,
PostboxDeliveryAttempt,
PrintOutputAttempt,
RecipientImportMappingProfile,
SendAttempt,
)
@@ -114,6 +116,36 @@ 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",
@@ -136,6 +168,8 @@ 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(
@@ -156,6 +190,19 @@ def campaign_version_child_resource_id(*, version_id: str, child_id: str) -> str
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:
@@ -172,6 +219,51 @@ def _version_child_reference(resource_id: str) -> tuple[str, str] | 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
@@ -408,8 +500,130 @@ class CampaignAccessService(CampaignAccessProvider):
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_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:
@@ -436,6 +650,111 @@ class CampaignAccessService(CampaignAccessProvider):
},
},
)
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:
@@ -883,6 +1202,12 @@ class CampaignAccessService(CampaignAccessProvider):
"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,
+17 -4
View File
@@ -656,7 +656,7 @@ manifest = ModuleManifest(
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.",
summary="Trace governance, import, recipient, attachment, protocol, 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 "
@@ -665,7 +665,11 @@ manifest = ModuleManifest(
"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."
"explainable after an optional provider is disabled; an absent child reports only unavailable-or-hidden. Campaign share "
"and ownership-transfer records describe governance visibility separately from the content grant. Saved imports expose "
"only their stable source identity and revision, while independently user-owned mapping profiles never inherit a Campaign "
"share. Persisted validation, build, snapshot, and review artifacts use typed version references. Reusable templates and "
"durable export packages stay with their optional owning modules and fail closed when Campaign is asked to explain them."
),
layer="available",
documentation_types=("admin",),
@@ -693,7 +697,7 @@ manifest = ModuleManifest(
translations={
"de": {
"title": "Zugriff auf untergeordnete Campaign-Nachweise erklaeren",
"summary": "Zugriff auf Empfaenger-, Anlagen-, Pruef-, Zustell- und Abgleichnachweise ohne Offenlegung der geschuetzten Inhalte nachvollziehen.",
"summary": "Zugriff auf Governance-, Import-, Empfaenger-, Anlagen-, Protokoll-, 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 "
@@ -703,12 +707,21 @@ manifest = ModuleManifest(
"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."
" Campaign-Freigaben und Eigentumsuebertragungen trennen Governance-Sichtbarkeit vom Inhaltszugriff. Gespeicherte Importe "
"nennen nur stabile Quellidentitaet und Revision; benutzereigene Zuordnungsprofile erben keine Campaign-Freigabe. "
"Validierungs-, Build-, Snapshot- und Pruefnachweise verwenden typisierte Versionsverweise. Wiederverwendbare Vorlagen "
"und dauerhafte Exportpakete bleiben bei ihren optionalen Eigentuemer-Modulen und werden sonst geschlossen behandelt."
),
}
},
metadata={
"kind": "reference",
"help_contexts": ["campaign.access", "campaign.report", "campaign.operator-queue"],
"help_contexts": [
"campaign.access",
"campaign.import",
"campaign.report",
"campaign.operator-queue",
],
},
),
DocumentationTopic(