feat(campaign): explain governed child access
This commit is contained in:
@@ -16,6 +16,11 @@ and they must identify that inheritance explicitly.
|
|||||||
- Persisted validation issue, version-bound review decision, and attachment-policy override
|
- Persisted validation issue, version-bound review decision, and attachment-policy override
|
||||||
- SMTP, IMAP append, Postbox, and printable-output attempts
|
- SMTP, IMAP append, Postbox, and printable-output attempts
|
||||||
- Message action, message-action attempt, and job reconciliation decision
|
- Message action, message-action attempt, and job reconciliation decision
|
||||||
|
- Campaign share and Core-owned Campaign ownership-transfer record
|
||||||
|
- Independently user-owned recipient import mapping profile
|
||||||
|
- Saved recipient import execution, identified by `<version UUID>:<import UUID>`
|
||||||
|
- Persisted validation, build, execution-snapshot, and review evidence, identified
|
||||||
|
by `<version UUID>:<artifact kind>`
|
||||||
|
|
||||||
All persisted child IDs are random UUIDs. Embedded build/review children use a
|
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
|
version UUID plus a random job UUID, so callers cannot enumerate a recipient
|
||||||
@@ -35,6 +40,10 @@ snapshots, diagnostic text, or reconciliation notes.
|
|||||||
| Transport or worker diagnostics | Campaign read/owner/share | `campaigns:diagnostic:read` |
|
| Transport or worker diagnostics | Campaign read/owner/share | `campaigns:diagnostic:read` |
|
||||||
| Exported delivery evidence | Campaign read/owner/share | `campaigns:report:export` |
|
| Exported delivery evidence | Campaign read/owner/share | `campaigns:report:export` |
|
||||||
| Reconciliation decision | Campaign read/owner/share | Campaign reconcile and diagnostic read |
|
| Reconciliation decision | Campaign read/owner/share | Campaign reconcile and diagnostic read |
|
||||||
|
| Share or ownership transfer | Campaign governance | Campaign share, transfer-participant, group-acceptance, or recovery authority; content access remains a separate decision |
|
||||||
|
| Import mapping profile | Independent user owner | `campaigns:recipient:import`; no Campaign share is inherited |
|
||||||
|
| Import execution | Campaign read/owner/share | Recipient read and import authority |
|
||||||
|
| Persisted protocol artifact | Campaign read/owner/share | Recipient, review, report, diagnostic, or export authority appropriate to the artifact |
|
||||||
|
|
||||||
Postbox, Mail/IMAP, and printable attempts keep bounded Campaign-owned evidence
|
Postbox, Mail/IMAP, and printable attempts keep bounded Campaign-owned evidence
|
||||||
after provider acceptance. Their explanation therefore remains available when
|
after provider acceptance. Their explanation therefore remains available when
|
||||||
@@ -42,13 +51,19 @@ an optional provider module is later disabled. A missing attempt reports only
|
|||||||
the optional owner and `unavailable_or_hidden`; it does not distinguish absence
|
the optional owner and `unavailable_or_hidden`; it does not distinguish absence
|
||||||
from hidden data.
|
from hidden data.
|
||||||
|
|
||||||
## Remaining owner boundaries
|
## Optional and unsupported owner boundaries
|
||||||
|
|
||||||
1. Campaign shares and ownership-transfer records
|
Reusable templates and template revisions are independently governed by the
|
||||||
2. Import mapping profiles and import executions
|
optional Templates module; Campaign never treats a Campaign share as a template
|
||||||
3. Reusable Campaign templates and template revisions when the template
|
grant. Durable export packages are independently governed by the optional
|
||||||
library becomes persistent
|
Reporting module. Asking the Campaign provider to explain either class therefore
|
||||||
4. Export packages and protocol/report artifacts
|
fails closed with `independently_governed_by_optional_module` and
|
||||||
|
`unavailable_or_hidden`. The response does not reveal whether the optional
|
||||||
|
module is absent, the object does not exist, or the caller cannot see it.
|
||||||
|
|
||||||
|
Campaign reports generated on demand remain non-persisted, version-bound
|
||||||
|
resources. Their explanation names the report kind and its Campaign/version
|
||||||
|
parent, and keeps report read, export, and diagnostic permissions distinct.
|
||||||
|
|
||||||
Each child explanation must include:
|
Each child explanation must include:
|
||||||
|
|
||||||
@@ -64,3 +79,9 @@ Delivery attempts, review decisions, reports, and exports can contain more
|
|||||||
sensitive evidence than the Campaign summary. Their read and diagnostic/export
|
sensitive evidence than the Campaign summary. Their read and diagnostic/export
|
||||||
permissions therefore remain independently enforceable even when the parent
|
permissions therefore remain independently enforceable even when the parent
|
||||||
Campaign is readable.
|
Campaign is readable.
|
||||||
|
|
||||||
|
Import explanations include only the stable import identity, source type,
|
||||||
|
opaque source identity, source revision, and whether additional provenance was
|
||||||
|
recorded. They never return imported rows, filenames, column mappings, or source
|
||||||
|
metadata. Mapping-profile explanations expose only non-reversible header
|
||||||
|
fingerprints and shape information; headers and mappings remain hidden.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from govoplan_core.core.campaigns import (
|
|||||||
from govoplan_core.core.ownership import (
|
from govoplan_core.core.ownership import (
|
||||||
OwnershipActionDecision,
|
OwnershipActionDecision,
|
||||||
OwnershipSubjectRef,
|
OwnershipSubjectRef,
|
||||||
|
OwnershipTransfer,
|
||||||
OwnershipTransferError,
|
OwnershipTransferError,
|
||||||
)
|
)
|
||||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||||
@@ -35,6 +36,7 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
ImapAppendAttempt,
|
ImapAppendAttempt,
|
||||||
PostboxDeliveryAttempt,
|
PostboxDeliveryAttempt,
|
||||||
PrintOutputAttempt,
|
PrintOutputAttempt,
|
||||||
|
RecipientImportMappingProfile,
|
||||||
SendAttempt,
|
SendAttempt,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,6 +116,36 @@ CAMPAIGN_RECONCILIATION_RESOURCE_TYPES = {
|
|||||||
"campaign_reconciliation_decision",
|
"campaign_reconciliation_decision",
|
||||||
"campaigns: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 = (
|
RECIPIENT_ACCESS_REQUIREMENTS = (
|
||||||
"campaigns:campaign:read",
|
"campaigns:campaign:read",
|
||||||
@@ -136,6 +168,8 @@ RECONCILIATION_ACCESS_REQUIREMENTS = (
|
|||||||
"campaigns:campaign:reconcile",
|
"campaigns:campaign:reconcile",
|
||||||
"campaigns:diagnostic:read",
|
"campaigns:diagnostic:read",
|
||||||
)
|
)
|
||||||
|
GOVERNANCE_ACCESS_REQUIREMENTS = ("campaigns:campaign:share",)
|
||||||
|
IMPORT_PROFILE_ACCESS_REQUIREMENTS = ("campaigns:recipient:import",)
|
||||||
|
|
||||||
|
|
||||||
def campaign_report_resource_id(
|
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}"
|
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(
|
def _campaign_report_reference(
|
||||||
resource_id: str,
|
resource_id: str,
|
||||||
) -> tuple[str, str, str] | None:
|
) -> 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]
|
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:
|
def _iso_value(value: object | None) -> str | None:
|
||||||
isoformat = getattr(value, "isoformat", None)
|
isoformat = getattr(value, "isoformat", None)
|
||||||
return str(isoformat()) if callable(isoformat) else None
|
return str(isoformat()) if callable(isoformat) else None
|
||||||
@@ -408,8 +500,130 @@ class CampaignAccessService(CampaignAccessProvider):
|
|||||||
normalized_type = resource_type.lower().strip()
|
normalized_type = resource_type.lower().strip()
|
||||||
child_item: AccessDecisionProvenance | None = None
|
child_item: AccessDecisionProvenance | None = None
|
||||||
campaign: Campaign | 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:
|
if normalized_type in CAMPAIGN_RESOURCE_TYPES:
|
||||||
campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined]
|
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:
|
elif normalized_type in CAMPAIGN_VERSION_RESOURCE_TYPES:
|
||||||
version = session.get(CampaignVersion, resource_id) # type: ignore[attr-defined]
|
version = session.get(CampaignVersion, resource_id) # type: ignore[attr-defined]
|
||||||
if version is None:
|
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:
|
elif normalized_type in CAMPAIGN_RECIPIENT_SNAPSHOT_RESOURCE_TYPES:
|
||||||
version = session.get(CampaignVersion, resource_id) # type: ignore[attr-defined]
|
version = session.get(CampaignVersion, resource_id) # type: ignore[attr-defined]
|
||||||
if version is None:
|
if version is None:
|
||||||
@@ -883,6 +1202,12 @@ class CampaignAccessService(CampaignAccessProvider):
|
|||||||
"campaign_version_id": version.id,
|
"campaign_version_id": version.id,
|
||||||
"report_kind": report_kind,
|
"report_kind": report_kind,
|
||||||
"persisted": False,
|
"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": {
|
"authorization_inherited_from": {
|
||||||
"resource_type": "campaign",
|
"resource_type": "campaign",
|
||||||
"resource_id": campaign.id,
|
"resource_id": campaign.id,
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ manifest = ModuleManifest(
|
|||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="campaigns.access.child-evidence",
|
id="campaigns.access.child-evidence",
|
||||||
title="Explain access to Campaign 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=(
|
body=(
|
||||||
"Campaign child explanations first identify the parent Campaign and immutable version, then state whether owner, "
|
"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 "
|
"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 "
|
"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, "
|
"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 "
|
"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",
|
layer="available",
|
||||||
documentation_types=("admin",),
|
documentation_types=("admin",),
|
||||||
@@ -693,7 +697,7 @@ manifest = ModuleManifest(
|
|||||||
translations={
|
translations={
|
||||||
"de": {
|
"de": {
|
||||||
"title": "Zugriff auf untergeordnete Campaign-Nachweise erklaeren",
|
"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": (
|
"body": (
|
||||||
"Zugriffserklaerungen fuer untergeordnete Campaign-Nachweise nennen zuerst die uebergeordnete Campaign und "
|
"Zugriffserklaerungen fuer untergeordnete Campaign-Nachweise nennen zuerst die uebergeordnete Campaign und "
|
||||||
"die unveraenderliche Version. Danach zeigen sie, ob Eigentum, Gruppe, Freigabe oder Mandantenadministration "
|
"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, "
|
"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 "
|
"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."
|
"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={
|
metadata={
|
||||||
"kind": "reference",
|
"kind": "reference",
|
||||||
"help_contexts": ["campaign.access", "campaign.report", "campaign.operator-queue"],
|
"help_contexts": [
|
||||||
|
"campaign.access",
|
||||||
|
"campaign.import",
|
||||||
|
"campaign.report",
|
||||||
|
"campaign.operator-queue",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
import json
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -12,6 +12,8 @@ from govoplan_access.backend.db.models import Account, Group, User
|
|||||||
from govoplan_campaign.backend.capabilities import (
|
from govoplan_campaign.backend.capabilities import (
|
||||||
CampaignAccessService,
|
CampaignAccessService,
|
||||||
CampaignOwnershipService,
|
CampaignOwnershipService,
|
||||||
|
campaign_import_execution_resource_id,
|
||||||
|
campaign_protocol_artifact_resource_id,
|
||||||
campaign_report_resource_id,
|
campaign_report_resource_id,
|
||||||
campaign_version_child_resource_id,
|
campaign_version_child_resource_id,
|
||||||
)
|
)
|
||||||
@@ -28,11 +30,12 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
ImapAppendAttempt,
|
ImapAppendAttempt,
|
||||||
PostboxDeliveryAttempt,
|
PostboxDeliveryAttempt,
|
||||||
PrintOutputAttempt,
|
PrintOutputAttempt,
|
||||||
|
RecipientImportMappingProfile,
|
||||||
SendAttempt,
|
SendAttempt,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.access import PrincipalRef
|
from govoplan_core.core.access import PrincipalRef
|
||||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
from govoplan_core.core.ownership import OwnershipSubjectRef, OwnershipTransferError
|
from govoplan_core.core.ownership import OwnershipSubjectRef, OwnershipTransfer, OwnershipTransferError
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -565,6 +568,141 @@ class CampaignAccessProviderTests(unittest.TestCase):
|
|||||||
self.assertEqual("unavailable_or_hidden", unavailable[0].details["evidence_availability"])
|
self.assertEqual("unavailable_or_hidden", unavailable[0].details["evidence_availability"])
|
||||||
self.assertNotIn("hidden postbox address", json.dumps(unavailable[0].details))
|
self.assertNotIn("hidden postbox address", json.dumps(unavailable[0].details))
|
||||||
|
|
||||||
|
def test_governance_import_and_protocol_explanation_matrix(self) -> None:
|
||||||
|
session = _session()
|
||||||
|
self.addCleanup(_close_session, session)
|
||||||
|
_seed_access_subjects(session)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
campaign = Campaign(
|
||||||
|
id="campaign-governed-children", tenant_id=TENANT_ID,
|
||||||
|
owner_user_id=USER_ID, external_id="governed-children", name="Governed children",
|
||||||
|
)
|
||||||
|
share = CampaignShare(
|
||||||
|
id="share-governed-child", tenant_id=TENANT_ID, campaign_id=campaign.id,
|
||||||
|
target_type="user", target_id=OTHER_USER_ID, permission="read",
|
||||||
|
)
|
||||||
|
version = CampaignVersion(
|
||||||
|
id="version-governed-children", campaign_id=campaign.id, version_number=2,
|
||||||
|
raw_json={"entries": {"inline": [], "imports": [{
|
||||||
|
"id": "import-stable-id", "imported_at": "2026-08-20T08:00:00+00:00",
|
||||||
|
"mode": "replace", "source_type": "addresses", "source_id": "address-source-42",
|
||||||
|
"source_revision": "revision-7",
|
||||||
|
"source_provenance": {"secret_source_row": "do-not-disclose-row"},
|
||||||
|
"filename": "hidden-import.csv",
|
||||||
|
"mapping": [{"header": "Secret column", "field_name": "secret"}],
|
||||||
|
}]}},
|
||||||
|
validation_summary={"secret_issue": "do-not-disclose-validation"},
|
||||||
|
build_summary={"secret_recipient": "do-not-disclose-build"},
|
||||||
|
execution_snapshot={"rows": ["do-not-disclose-snapshot"]},
|
||||||
|
execution_snapshot_hash="a" * 64, execution_snapshot_at=now,
|
||||||
|
editor_state={"review_send": {"issue_decisions": [
|
||||||
|
{"reason": "do-not-disclose-review-reason"}
|
||||||
|
]}},
|
||||||
|
)
|
||||||
|
profile = RecipientImportMappingProfile(
|
||||||
|
id="mapping-profile-governed-child", tenant_id=TENANT_ID, owner_user_id=USER_ID,
|
||||||
|
name="Hidden profile name", column_count=2,
|
||||||
|
headers=["Secret A", "Secret B"], normalized_headers=["secret a", "secret b"],
|
||||||
|
ordered_header_fingerprint="b" * 64, unordered_header_fingerprint="c" * 64,
|
||||||
|
delimiter=";", header_rows=1, quoted=True, value_separators=",;|",
|
||||||
|
mappings=[{"header": "Secret A", "field": "hidden"}],
|
||||||
|
)
|
||||||
|
transfer = OwnershipTransfer(
|
||||||
|
id="transfer-governed-child", tenant_id=TENANT_ID,
|
||||||
|
resource_module="campaigns", resource_type="campaign", resource_id=campaign.id,
|
||||||
|
kind="transfer", status="pending_target",
|
||||||
|
current_owner_type="user", current_owner_id=USER_ID,
|
||||||
|
target_owner_type="user", target_owner_id=OTHER_USER_ID,
|
||||||
|
initiated_by_type="user", initiated_by_id=USER_ID,
|
||||||
|
reason="do-not-disclose-transfer-reason", required_approvals=1,
|
||||||
|
approvals=[], decisions=[], idempotency_key="transfer-idempotency",
|
||||||
|
canonical_request_hash="d" * 64, expires_at=now + timedelta(days=1),
|
||||||
|
revision=3, metadata_={"secret": "do-not-disclose-transfer-metadata"},
|
||||||
|
)
|
||||||
|
session.add_all([campaign, share, version, profile, transfer])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
service = CampaignAccessService()
|
||||||
|
principal = _principal(scopes={
|
||||||
|
"campaigns:campaign:read", "campaigns:campaign:share",
|
||||||
|
"campaigns:recipient:read", "campaigns:recipient:import",
|
||||||
|
"campaigns:campaign:review", "campaigns:diagnostic:read",
|
||||||
|
"campaigns:report:read",
|
||||||
|
})
|
||||||
|
import_id = campaign_import_execution_resource_id(
|
||||||
|
version_id=version.id, import_id="import-stable-id",
|
||||||
|
)
|
||||||
|
protocol_ids = [
|
||||||
|
campaign_protocol_artifact_resource_id(version_id=version.id, artifact_kind=kind)
|
||||||
|
for kind in ("validation", "build", "execution_snapshot", "review")
|
||||||
|
]
|
||||||
|
cases = [
|
||||||
|
("campaign_share", share.id, "campaigns.share_record"),
|
||||||
|
("campaign_ownership_transfer", transfer.id, "campaigns.ownership_transfer"),
|
||||||
|
("campaign_import_execution", import_id, "campaigns.import_execution"),
|
||||||
|
*(("campaign_protocol_artifact", item, "campaigns.protocol_artifact") for item in protocol_ids),
|
||||||
|
]
|
||||||
|
hidden_values = (
|
||||||
|
OTHER_USER_ID, "do-not-disclose-row", "hidden-import.csv", "Secret column",
|
||||||
|
"do-not-disclose-validation", "do-not-disclose-build", "do-not-disclose-snapshot",
|
||||||
|
"do-not-disclose-review-reason", "do-not-disclose-transfer-reason",
|
||||||
|
"do-not-disclose-transfer-metadata",
|
||||||
|
)
|
||||||
|
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"])
|
||||||
|
serialized = json.dumps(child.details, sort_keys=True)
|
||||||
|
for hidden_value in hidden_values:
|
||||||
|
self.assertNotIn(hidden_value, serialized)
|
||||||
|
|
||||||
|
import_item = next(item for item in service.explain_resource_provenance(
|
||||||
|
session, principal, resource_type="campaign_import_execution",
|
||||||
|
resource_id=import_id, action="campaigns:recipient:import",
|
||||||
|
) if item.source == "campaigns.import_execution")
|
||||||
|
self.assertEqual("address-source-42", import_item.details["source_id"])
|
||||||
|
self.assertEqual("revision-7", import_item.details["source_revision"])
|
||||||
|
self.assertFalse(import_item.details["source_rows_disclosed"])
|
||||||
|
|
||||||
|
profile_items = service.explain_resource_provenance(
|
||||||
|
session, principal, resource_type="campaign_import_mapping_profile",
|
||||||
|
resource_id=profile.id, action="campaigns:recipient:import",
|
||||||
|
)
|
||||||
|
profile_item = next(item for item in profile_items if item.source == "campaigns.import_mapping_profile")
|
||||||
|
self.assertEqual("independently_owned", profile_item.details["authorization_mode"])
|
||||||
|
self.assertTrue(any(item.kind == "owner" for item in profile_items))
|
||||||
|
self.assertNotIn("Secret A", json.dumps(profile_item.details))
|
||||||
|
self.assertNotIn("Hidden profile name", json.dumps(profile_item.to_dict()))
|
||||||
|
|
||||||
|
for resource_type, optional_module in (
|
||||||
|
("campaign_template", "templates"),
|
||||||
|
("campaign_template_revision", "templates"),
|
||||||
|
("campaign_export_package", "reporting"),
|
||||||
|
):
|
||||||
|
unavailable = service.explain_resource_provenance(
|
||||||
|
session, principal, resource_type=resource_type,
|
||||||
|
resource_id="opaque-optional-resource", action="campaigns:diagnostic:read",
|
||||||
|
)[0]
|
||||||
|
self.assertEqual("campaigns.not_found", unavailable.source)
|
||||||
|
self.assertEqual(optional_module, unavailable.details["optional_module"])
|
||||||
|
self.assertEqual("independently_governed_by_optional_module", unavailable.details["reason"])
|
||||||
|
self.assertEqual("unavailable_or_hidden", unavailable.details["evidence_availability"])
|
||||||
|
|
||||||
|
unsupported = service.explain_resource_provenance(
|
||||||
|
session, principal, resource_type="campaign_protocol_artifact",
|
||||||
|
resource_id=f"{version.id}:unsupported", action="campaigns:diagnostic:read",
|
||||||
|
)
|
||||||
|
self.assertEqual("unsupported_artifact_kind", unsupported[0].details["reason"])
|
||||||
|
cross_tenant = service.explain_resource_provenance(
|
||||||
|
session, _principal(tenant_id="tenant-2"),
|
||||||
|
resource_type="campaign_import_mapping_profile", resource_id=profile.id,
|
||||||
|
action="campaigns:recipient:import",
|
||||||
|
)
|
||||||
|
self.assertEqual("campaigns.not_found", cross_tenant[0].source)
|
||||||
|
|
||||||
def test_campaign_ownership_provider_requires_group_acceptance_authority(self) -> None:
|
def test_campaign_ownership_provider_requires_group_acceptance_authority(self) -> None:
|
||||||
session = _session()
|
session = _session()
|
||||||
self.addCleanup(_close_session, session)
|
self.addCleanup(_close_session, session)
|
||||||
@@ -791,6 +929,8 @@ def _session():
|
|||||||
ImapAppendAttempt.__table__,
|
ImapAppendAttempt.__table__,
|
||||||
PostboxDeliveryAttempt.__table__,
|
PostboxDeliveryAttempt.__table__,
|
||||||
PrintOutputAttempt.__table__,
|
PrintOutputAttempt.__table__,
|
||||||
|
RecipientImportMappingProfile.__table__,
|
||||||
|
OwnershipTransfer.__table__,
|
||||||
ChangeSequenceEntry.__table__,
|
ChangeSequenceEntry.__table__,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -105,6 +105,26 @@ def test_attachment_reuse_workflow_documents_policy_and_evidence() -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_evidence_reference_documents_independent_owner_boundaries() -> None:
|
||||||
|
from govoplan_campaign.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
topic = next(
|
||||||
|
item for item in get_manifest().documentation
|
||||||
|
if item.id == "campaigns.access.child-evidence"
|
||||||
|
)
|
||||||
|
rendered = f"{topic.summary}\n{topic.body}".lower()
|
||||||
|
for expected in (
|
||||||
|
"governance visibility",
|
||||||
|
"mapping profiles",
|
||||||
|
"validation",
|
||||||
|
"reusable templates",
|
||||||
|
"export packages",
|
||||||
|
"fail closed",
|
||||||
|
):
|
||||||
|
assert expected in rendered
|
||||||
|
assert "campaign.import" in topic.metadata["help_contexts"]
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None:
|
def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None:
|
||||||
assert _topics({"docs:documentation:read"}) == ()
|
assert _topics({"docs:documentation:read"}) == ()
|
||||||
assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == ()
|
assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == ()
|
||||||
|
|||||||
Reference in New Issue
Block a user