feat(campaign): explain governed child access
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user