1029 lines
41 KiB
Python
1029 lines
41 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
import json
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from govoplan_access.backend.db.models import Account, Group, User
|
|
from govoplan_campaign.backend.capabilities import (
|
|
CampaignAccessService,
|
|
CampaignOwnershipService,
|
|
campaign_import_execution_resource_id,
|
|
campaign_protocol_artifact_resource_id,
|
|
campaign_report_resource_id,
|
|
campaign_version_child_resource_id,
|
|
)
|
|
from govoplan_campaign.backend.db.models import (
|
|
AttachmentBlob,
|
|
AttachmentInstance,
|
|
Campaign,
|
|
CampaignCollaborationEntry,
|
|
CampaignIssue,
|
|
CampaignJob,
|
|
CampaignMessageAction,
|
|
CampaignMessageActionAttempt,
|
|
CampaignShare,
|
|
CampaignVersion,
|
|
ImapAppendAttempt,
|
|
PostboxDeliveryAttempt,
|
|
PrintOutputAttempt,
|
|
RecipientImportMappingProfile,
|
|
SendAttempt,
|
|
)
|
|
from govoplan_core.core.access import PrincipalRef
|
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
|
from govoplan_core.core.ownership import OwnershipSubjectRef, OwnershipTransfer, OwnershipTransferError
|
|
from govoplan_core.db.base import Base
|
|
|
|
|
|
TENANT_ID = "tenant-1"
|
|
USER_ID = "user-1"
|
|
OTHER_USER_ID = "user-2"
|
|
GROUP_ID = "group-1"
|
|
|
|
|
|
class CampaignAccessProviderTests(unittest.TestCase):
|
|
def test_collaboration_access_is_explained_independently_from_campaign_edit(self) -> None:
|
|
session = _session()
|
|
self.addCleanup(_close_session, session)
|
|
_seed_access_subjects(session)
|
|
campaign = Campaign(
|
|
id="campaign-collaboration",
|
|
tenant_id=TENANT_ID,
|
|
owner_user_id=OTHER_USER_ID,
|
|
external_id="collaboration",
|
|
name="Collaboration campaign",
|
|
)
|
|
entry = CampaignCollaborationEntry(
|
|
id="discussion-1",
|
|
tenant_id=TENANT_ID,
|
|
campaign_id=campaign.id,
|
|
actor_user_id=OTHER_USER_ID,
|
|
actor_label_snapshot="Other user",
|
|
visibility="collaborators",
|
|
content="Not disclosed by provenance",
|
|
content_sha256="a" * 64,
|
|
)
|
|
session.add_all(
|
|
[
|
|
campaign,
|
|
entry,
|
|
CampaignShare(
|
|
id="share-collaboration",
|
|
tenant_id=TENANT_ID,
|
|
campaign_id=campaign.id,
|
|
target_type="group",
|
|
target_id=GROUP_ID,
|
|
permission="read",
|
|
),
|
|
]
|
|
)
|
|
session.commit()
|
|
|
|
items = CampaignAccessService().explain_resource_provenance(
|
|
session,
|
|
_principal(
|
|
scopes={"campaigns:campaign:read", "campaigns:discussion:post"},
|
|
group_ids={GROUP_ID},
|
|
),
|
|
resource_type="campaign_collaboration_entry",
|
|
resource_id=entry.id,
|
|
action="campaigns:discussion:post",
|
|
)
|
|
|
|
discussion = next(item for item in items if item.source == "campaigns.collaboration_entry")
|
|
self.assertEqual(["campaigns:discussion:post"], discussion.details["permission_actions"])
|
|
self.assertFalse(discussion.details["content_disclosed"])
|
|
self.assertTrue(any(item.id == "share-collaboration" for item in items))
|
|
self.assertNotIn("campaigns:campaign:update", repr(discussion.details))
|
|
self.assertNotIn("Not disclosed", repr(items))
|
|
|
|
def test_campaign_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None:
|
|
session = _session()
|
|
self.addCleanup(_close_session, session)
|
|
_seed_access_subjects(session)
|
|
owned = Campaign(id="campaign-owned", tenant_id=TENANT_ID, owner_user_id=USER_ID, external_id="owned", name="Owned campaign")
|
|
shared = Campaign(id="campaign-shared", tenant_id=TENANT_ID, owner_user_id=OTHER_USER_ID, external_id="shared", name="Shared campaign")
|
|
session.add_all([
|
|
owned,
|
|
shared,
|
|
CampaignShare(id="share-group", tenant_id=TENANT_ID, campaign_id=shared.id, target_type="group", target_id=GROUP_ID, permission="read"),
|
|
])
|
|
session.commit()
|
|
|
|
service = CampaignAccessService()
|
|
owned_items = service.explain_resource_provenance(session, _principal(), resource_type="campaign", resource_id=owned.id, action="campaigns:campaign:read")
|
|
shared_items = service.explain_resource_provenance(session, _principal(group_ids={GROUP_ID}), resource_type="campaign", resource_id=shared.id, action="campaigns:campaign:read")
|
|
admin_items = service.explain_resource_provenance(session, _principal(scopes={"tenant:*"}), resource_type="campaign", resource_id=shared.id, action="campaigns:campaign:read")
|
|
missing_items = service.explain_resource_provenance(session, _principal(), resource_type="campaign", resource_id="missing-campaign", action="campaigns:campaign:read")
|
|
|
|
self.assertTrue(any(item.kind == "resource" and item.source == "campaigns.campaign" and item.id == owned.id for item in owned_items))
|
|
self.assertTrue(any(item.kind == "owner" and item.id == USER_ID for item in owned_items))
|
|
self.assertTrue(any(item.kind == "share" and item.id == "share-group" for item in shared_items))
|
|
self.assertTrue(any(item.kind == "policy" and item.id == "tenant:*" for item in admin_items))
|
|
self.assertEqual("campaigns.not_found", missing_items[0].source)
|
|
self.assertIs(missing_items[0].details["found"], False)
|
|
|
|
def test_campaign_access_provider_requires_write_share_for_write_actions(self) -> None:
|
|
session = _session()
|
|
self.addCleanup(_close_session, session)
|
|
_seed_access_subjects(session)
|
|
campaign = Campaign(id="campaign-write", tenant_id=TENANT_ID, owner_user_id=OTHER_USER_ID, external_id="write", name="Write campaign")
|
|
session.add_all([
|
|
campaign,
|
|
CampaignShare(id="share-read", tenant_id=TENANT_ID, campaign_id=campaign.id, target_type="group", target_id=GROUP_ID, permission="read"),
|
|
CampaignShare(id="share-write", tenant_id=TENANT_ID, campaign_id=campaign.id, target_type="user", target_id=USER_ID, permission="write"),
|
|
])
|
|
session.commit()
|
|
|
|
service = CampaignAccessService()
|
|
items = service.explain_resource_provenance(
|
|
session,
|
|
_principal(group_ids={GROUP_ID}),
|
|
resource_type="campaign",
|
|
resource_id=campaign.id,
|
|
action="campaigns:campaign:update",
|
|
)
|
|
|
|
self.assertTrue(any(item.kind == "share" and item.id == "share-write" for item in items))
|
|
self.assertFalse(any(item.kind == "share" and item.id == "share-read" for item in items))
|
|
|
|
def test_campaign_children_explain_their_parent_campaign_access(self) -> None:
|
|
session = _session()
|
|
self.addCleanup(_close_session, session)
|
|
_seed_access_subjects(session)
|
|
campaign = Campaign(
|
|
id="campaign-child",
|
|
tenant_id=TENANT_ID,
|
|
owner_user_id=OTHER_USER_ID,
|
|
external_id="child",
|
|
name="Child resources",
|
|
)
|
|
version = CampaignVersion(
|
|
id="version-child",
|
|
campaign_id=campaign.id,
|
|
version_number=1,
|
|
raw_json={},
|
|
)
|
|
job = CampaignJob(
|
|
id="job-child",
|
|
tenant_id=TENANT_ID,
|
|
campaign_id=campaign.id,
|
|
campaign_version_id=version.id,
|
|
entry_index=1,
|
|
recipient_email="recipient@example.test",
|
|
)
|
|
session.add_all(
|
|
[
|
|
campaign,
|
|
version,
|
|
job,
|
|
CampaignShare(
|
|
id="share-child",
|
|
tenant_id=TENANT_ID,
|
|
campaign_id=campaign.id,
|
|
target_type="group",
|
|
target_id=GROUP_ID,
|
|
permission="read",
|
|
),
|
|
]
|
|
)
|
|
session.commit()
|
|
|
|
service = CampaignAccessService()
|
|
principal = _principal(group_ids={GROUP_ID})
|
|
version_items = service.explain_resource_provenance(
|
|
session,
|
|
principal,
|
|
resource_type="campaign_version",
|
|
resource_id=version.id,
|
|
action="campaigns:version:read",
|
|
)
|
|
job_items = service.explain_resource_provenance(
|
|
session,
|
|
principal,
|
|
resource_type="campaign_delivery_job",
|
|
resource_id=job.id,
|
|
action="campaigns:delivery_job:read",
|
|
)
|
|
report_id = campaign_report_resource_id(
|
|
campaign_id=campaign.id,
|
|
version_id=version.id,
|
|
report_kind="delivery",
|
|
)
|
|
report_items = service.explain_resource_provenance(
|
|
session,
|
|
principal,
|
|
resource_type="campaign_report",
|
|
resource_id=report_id,
|
|
action="campaigns:report:read",
|
|
)
|
|
|
|
for items, source in (
|
|
(version_items, "campaigns.version"),
|
|
(job_items, "campaigns.delivery_job"),
|
|
(report_items, "campaigns.report"),
|
|
):
|
|
child = next(item for item in items if item.source == source)
|
|
self.assertEqual(
|
|
child.details["authorization_inherited_from"],
|
|
{
|
|
"resource_type": "campaign",
|
|
"resource_id": campaign.id,
|
|
},
|
|
)
|
|
self.assertTrue(
|
|
any(
|
|
item.source == "campaigns.campaign"
|
|
and item.id == campaign.id
|
|
for item in items
|
|
)
|
|
)
|
|
self.assertTrue(
|
|
any(item.kind == "share" and item.id == "share-child" for item in items)
|
|
)
|
|
|
|
report = next(item for item in report_items if item.source == "campaigns.report")
|
|
self.assertEqual(report.details["campaign_version_id"], version.id)
|
|
self.assertEqual(report.details["report_kind"], "delivery")
|
|
self.assertFalse(report.details["persisted"])
|
|
|
|
def test_recipient_attachment_and_review_children_are_bounded_and_versioned(self) -> None:
|
|
session = _session()
|
|
self.addCleanup(_close_session, session)
|
|
_seed_access_subjects(session)
|
|
campaign = Campaign(
|
|
id="campaign-sensitive",
|
|
tenant_id=TENANT_ID,
|
|
owner_user_id=OTHER_USER_ID,
|
|
external_id="sensitive",
|
|
name="Sensitive child evidence",
|
|
)
|
|
version = CampaignVersion(
|
|
id="version-sensitive",
|
|
campaign_id=campaign.id,
|
|
version_number=4,
|
|
raw_json={},
|
|
execution_snapshot={"secret_source_rows": ["do-not-disclose"]},
|
|
execution_snapshot_hash="a" * 64,
|
|
)
|
|
job = CampaignJob(
|
|
id="job-sensitive",
|
|
tenant_id=TENANT_ID,
|
|
campaign_id=campaign.id,
|
|
campaign_version_id=version.id,
|
|
entry_index=7,
|
|
recipient_email="hidden-recipient@example.test",
|
|
execution_input_sha256="b" * 64,
|
|
resolved_attachments=[{"filename": "hidden-file.pdf", "storage_key": "secret/key"}],
|
|
issues_snapshot=[{
|
|
"code": "attachment_override_required",
|
|
"source": "attachments.policy",
|
|
"behavior": "ask",
|
|
"message": "hidden validation detail",
|
|
}],
|
|
)
|
|
version.editor_state = {
|
|
"review_send": {
|
|
"issue_decisions": [{
|
|
"job_id": job.id,
|
|
"review_key": "opaque-review-key",
|
|
"decision": "accept",
|
|
"reason": "hidden operator reason",
|
|
"decided_at": "2026-08-19T10:00:00+00:00",
|
|
"issue_fingerprint": "c" * 64,
|
|
}]
|
|
}
|
|
}
|
|
issue = CampaignIssue(
|
|
id="issue-sensitive",
|
|
tenant_id=TENANT_ID,
|
|
campaign_id=campaign.id,
|
|
campaign_version_id=version.id,
|
|
job_id=job.id,
|
|
severity="warning",
|
|
code="attachment_override_required",
|
|
message="hidden validation detail",
|
|
source="hidden/source/path",
|
|
behavior="ask",
|
|
)
|
|
blob = AttachmentBlob(
|
|
id="blob-sensitive",
|
|
tenant_id=TENANT_ID,
|
|
sha256="d" * 64,
|
|
size_bytes=123,
|
|
storage_bucket="secret-bucket",
|
|
storage_key="secret/object",
|
|
)
|
|
attachment = AttachmentInstance(
|
|
id="attachment-sensitive",
|
|
tenant_id=TENANT_ID,
|
|
owner_user_id=OTHER_USER_ID,
|
|
campaign_id=campaign.id,
|
|
blob_id=blob.id,
|
|
filename="hidden-file.pdf",
|
|
)
|
|
session.add_all([
|
|
campaign,
|
|
version,
|
|
job,
|
|
issue,
|
|
blob,
|
|
attachment,
|
|
CampaignShare(
|
|
id="share-sensitive",
|
|
tenant_id=TENANT_ID,
|
|
campaign_id=campaign.id,
|
|
target_type="group",
|
|
target_id=GROUP_ID,
|
|
permission="read",
|
|
),
|
|
])
|
|
session.commit()
|
|
|
|
service = CampaignAccessService()
|
|
principal = _principal(
|
|
scopes={
|
|
"campaigns:campaign:read",
|
|
"campaigns:recipient:read",
|
|
"campaigns:campaign:review",
|
|
"campaigns:diagnostic:read",
|
|
},
|
|
group_ids={GROUP_ID},
|
|
)
|
|
version_child_id = campaign_version_child_resource_id(
|
|
version_id=version.id,
|
|
child_id=job.id,
|
|
)
|
|
cases = (
|
|
("campaign_recipient_source_snapshot", version.id, "campaigns.recipient_source_snapshot"),
|
|
("campaign_recipient", version_child_id, "campaigns.recipient"),
|
|
("campaign_attachment_binding", attachment.id, "campaigns.attachment_binding"),
|
|
("campaign_attachment_resolution", version_child_id, "campaigns.attachment_resolution"),
|
|
("campaign_validation_issue", issue.id, "campaigns.validation_issue"),
|
|
("campaign_review_decision", version_child_id, "campaigns.review_decision"),
|
|
("campaign_attachment_override", version_child_id, "campaigns.attachment_override"),
|
|
)
|
|
for resource_type, resource_id, source in cases:
|
|
items = service.explain_resource_provenance(
|
|
session,
|
|
principal,
|
|
resource_type=resource_type,
|
|
resource_id=resource_id,
|
|
action="campaigns:diagnostic:read",
|
|
)
|
|
child = next(item for item in items if item.source == source)
|
|
self.assertEqual(campaign.id, child.details["campaign_id"])
|
|
self.assertEqual(
|
|
{"resource_type": "campaign", "resource_id": campaign.id},
|
|
child.details["authorization_inherited_from"],
|
|
)
|
|
self.assertEqual("inherited_and_further_restricted", child.details["authorization_mode"])
|
|
self.assertTrue(any(item.id == "share-sensitive" for item in items))
|
|
serialized = json.dumps(child.details, sort_keys=True)
|
|
for hidden in (
|
|
"hidden-recipient@example.test",
|
|
"hidden-file.pdf",
|
|
"secret/object",
|
|
"secret/key",
|
|
"hidden validation detail",
|
|
"hidden operator reason",
|
|
"hidden/source/path",
|
|
"do-not-disclose",
|
|
):
|
|
self.assertNotIn(hidden, serialized)
|
|
|
|
denied_items = service.explain_resource_provenance(
|
|
session,
|
|
_principal(scopes=set()),
|
|
resource_type="campaign_recipient",
|
|
resource_id=version_child_id,
|
|
action="campaigns:recipient:read",
|
|
)
|
|
denied_child = next(item for item in denied_items if item.source == "campaigns.recipient")
|
|
self.assertIn("campaigns:recipient:read", denied_child.details["permission_actions"])
|
|
self.assertFalse(any(item.kind in {"owner", "share", "policy"} for item in denied_items))
|
|
|
|
missing = service.explain_resource_provenance(
|
|
session,
|
|
principal,
|
|
resource_type="campaign_recipient",
|
|
resource_id=f"{version.id}:missing-job",
|
|
action="campaigns:recipient:read",
|
|
)
|
|
self.assertEqual("campaigns.not_found", missing[0].source)
|
|
self.assertNotIn("hidden-recipient", json.dumps(missing[0].details))
|
|
|
|
stale = service.explain_resource_provenance(
|
|
session,
|
|
principal,
|
|
resource_type="campaign_review_decision",
|
|
resource_id=f"stale-version:{job.id}",
|
|
action="campaigns:diagnostic:read",
|
|
)
|
|
self.assertEqual("stale_version_reference", stale[0].details["reason"])
|
|
|
|
hidden = service.explain_resource_provenance(
|
|
session,
|
|
_principal(tenant_id="tenant-2"),
|
|
resource_type="campaign_validation_issue",
|
|
resource_id=issue.id,
|
|
action="campaigns:diagnostic:read",
|
|
)
|
|
self.assertEqual("campaigns.not_found", hidden[0].source)
|
|
self.assertNotIn(issue.code, json.dumps(hidden[0].details))
|
|
|
|
def test_delivery_evidence_explanations_separate_permissions_and_hide_transport_data(self) -> None:
|
|
session = _session()
|
|
self.addCleanup(_close_session, session)
|
|
_seed_access_subjects(session)
|
|
campaign = Campaign(
|
|
id="campaign-delivery-evidence",
|
|
tenant_id=TENANT_ID,
|
|
owner_user_id=USER_ID,
|
|
external_id="delivery-evidence",
|
|
name="Delivery evidence",
|
|
)
|
|
version = CampaignVersion(
|
|
id="version-delivery-evidence",
|
|
campaign_id=campaign.id,
|
|
version_number=1,
|
|
raw_json={},
|
|
)
|
|
job = CampaignJob(
|
|
id="job-delivery-evidence",
|
|
tenant_id=TENANT_ID,
|
|
campaign_id=campaign.id,
|
|
campaign_version_id=version.id,
|
|
entry_index=1,
|
|
recipient_email="hidden-delivery-recipient@example.test",
|
|
send_status="sent",
|
|
postbox_status="accepted",
|
|
imap_status="appended",
|
|
last_error="hidden reconciliation note",
|
|
)
|
|
send_attempt = SendAttempt(
|
|
id="send-attempt-evidence",
|
|
job_id=job.id,
|
|
attempt_number=1,
|
|
status="reconciled_not_sent",
|
|
claim_token="hidden-claim-token",
|
|
smtp_response="hidden-smtp-response",
|
|
)
|
|
imap_attempt = ImapAppendAttempt(
|
|
id="imap-attempt-evidence",
|
|
job_id=job.id,
|
|
attempt_number=1,
|
|
status="reconciled_imap_appended",
|
|
folder="Hidden/Sent",
|
|
error_message="hidden imap note",
|
|
)
|
|
postbox_attempt = PostboxDeliveryAttempt(
|
|
id="postbox-attempt-evidence",
|
|
tenant_id=TENANT_ID,
|
|
job_id=job.id,
|
|
target_key="opaque-target",
|
|
target_index=0,
|
|
attempt_number=1,
|
|
idempotency_key="opaque-postbox-key",
|
|
status="accepted",
|
|
target_snapshot={"address": "hidden postbox address"},
|
|
evidence={
|
|
"operator_reconciliation": {
|
|
"decision": "accepted",
|
|
"note": "hidden postbox note",
|
|
}
|
|
},
|
|
)
|
|
print_attempt = PrintOutputAttempt(
|
|
id="print-attempt-evidence",
|
|
tenant_id=TENANT_ID,
|
|
job_id=job.id,
|
|
attempt_number=1,
|
|
idempotency_key="opaque-print-key",
|
|
status="accepted",
|
|
render_id="hidden-render-id",
|
|
evidence={"storage_key": "hidden print locator"},
|
|
)
|
|
message_action = CampaignMessageAction(
|
|
id="message-action-evidence",
|
|
tenant_id=TENANT_ID,
|
|
campaign_id=campaign.id,
|
|
campaign_version_id=version.id,
|
|
job_id=job.id,
|
|
kind="retry",
|
|
idempotency_key="opaque-action-key",
|
|
canonical_request_hash="e" * 64,
|
|
reason="hidden action reason",
|
|
message_sha256="f" * 64,
|
|
recipient_manifest_sha256="1" * 64,
|
|
recipient_count=1,
|
|
prior_send_status="failed",
|
|
prior_attempt_count=1,
|
|
status="completed",
|
|
)
|
|
action_attempt = CampaignMessageActionAttempt(
|
|
id="message-action-attempt-evidence",
|
|
action_id=message_action.id,
|
|
attempt_number=1,
|
|
status="completed",
|
|
started_at=datetime.now(UTC),
|
|
diagnostic_summary="hidden diagnostic summary",
|
|
)
|
|
session.add_all([
|
|
campaign,
|
|
version,
|
|
job,
|
|
send_attempt,
|
|
imap_attempt,
|
|
postbox_attempt,
|
|
print_attempt,
|
|
message_action,
|
|
action_attempt,
|
|
])
|
|
session.commit()
|
|
|
|
service = CampaignAccessService()
|
|
principal = _principal(scopes={
|
|
"campaigns:campaign:read",
|
|
"campaigns:report:read",
|
|
"campaigns:report:export",
|
|
"campaigns:diagnostic:read",
|
|
"campaigns:campaign:reconcile",
|
|
})
|
|
cases = (
|
|
("campaign_send_attempt", send_attempt.id, "campaigns.send_attempt"),
|
|
("campaign_imap_append_attempt", imap_attempt.id, "campaigns.imap_append_attempt"),
|
|
("campaign_postbox_attempt", postbox_attempt.id, "campaigns.postbox_attempt"),
|
|
("campaign_print_attempt", print_attempt.id, "campaigns.print_attempt"),
|
|
("campaign_message_action", message_action.id, "campaigns.message_action"),
|
|
("campaign_message_action_attempt", action_attempt.id, "campaigns.message_action_attempt"),
|
|
)
|
|
for resource_type, resource_id, source in cases:
|
|
items = service.explain_resource_provenance(
|
|
session,
|
|
principal,
|
|
resource_type=resource_type,
|
|
resource_id=resource_id,
|
|
action="campaigns:diagnostic:read",
|
|
)
|
|
child = next(item for item in items if item.source == source)
|
|
self.assertEqual(campaign.id, child.details["campaign_id"])
|
|
self.assertEqual(version.id, child.details["campaign_version_id"])
|
|
self.assertEqual(job.id, child.details["job_id"])
|
|
self.assertEqual(
|
|
{
|
|
"read": ["campaigns:campaign:read"],
|
|
"report": ["campaigns:report:read"],
|
|
"diagnostic": ["campaigns:diagnostic:read"],
|
|
"export": ["campaigns:report:export"],
|
|
},
|
|
child.details["permission_classes"],
|
|
)
|
|
serialized = json.dumps(child.details, sort_keys=True)
|
|
for hidden_value in (
|
|
"hidden-delivery-recipient@example.test",
|
|
"hidden-claim-token",
|
|
"hidden-smtp-response",
|
|
"Hidden/Sent",
|
|
"hidden imap note",
|
|
"hidden postbox address",
|
|
"hidden postbox note",
|
|
"hidden-render-id",
|
|
"hidden print locator",
|
|
"hidden action reason",
|
|
"hidden diagnostic summary",
|
|
"hidden reconciliation note",
|
|
):
|
|
self.assertNotIn(hidden_value, serialized)
|
|
|
|
reconciliation = service.explain_resource_provenance(
|
|
session,
|
|
principal,
|
|
resource_type="campaign_reconciliation_decision",
|
|
resource_id=job.id,
|
|
action="campaigns:campaign:reconcile",
|
|
)
|
|
decision = next(item for item in reconciliation if item.source == "campaigns.reconciliation_decision")
|
|
self.assertEqual(["smtp", "imap", "postbox"], decision.details["channels"])
|
|
self.assertTrue(decision.details["evidence_note_recorded"])
|
|
self.assertFalse(decision.details["evidence_note_disclosed"])
|
|
|
|
unavailable = service.explain_resource_provenance(
|
|
session,
|
|
principal,
|
|
resource_type="campaign_postbox_attempt",
|
|
resource_id="missing-postbox-attempt",
|
|
action="campaigns:diagnostic:read",
|
|
)
|
|
self.assertEqual("postbox", unavailable[0].details["optional_module"])
|
|
self.assertEqual("unavailable_or_hidden", unavailable[0].details["evidence_availability"])
|
|
self.assertNotIn("hidden postbox address", json.dumps(unavailable[0].details))
|
|
|
|
def test_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:
|
|
session = _session()
|
|
self.addCleanup(_close_session, session)
|
|
_seed_access_subjects(session)
|
|
campaign = Campaign(
|
|
id="campaign-ownership",
|
|
tenant_id=TENANT_ID,
|
|
owner_user_id=USER_ID,
|
|
external_id="ownership",
|
|
name="Ownership",
|
|
)
|
|
session.add(campaign)
|
|
session.commit()
|
|
|
|
service = CampaignOwnershipService()
|
|
current_owner = OwnershipSubjectRef(type="user", id=USER_ID)
|
|
target_group = OwnershipSubjectRef(type="group", id=GROUP_ID)
|
|
ordinary_member = OwnershipSubjectRef(
|
|
type="user",
|
|
id=OTHER_USER_ID,
|
|
group_ids=frozenset({GROUP_ID}),
|
|
)
|
|
group_manager = OwnershipSubjectRef(
|
|
type="user",
|
|
id=OTHER_USER_ID,
|
|
group_ids=frozenset({GROUP_ID}),
|
|
scopes=frozenset({"campaigns:ownership:accept_group"}),
|
|
)
|
|
|
|
denied = service.authorize_ownership_action(
|
|
session,
|
|
tenant_id=TENANT_ID,
|
|
resource_id=campaign.id,
|
|
action="accept_group_transfer",
|
|
actor=ordinary_member,
|
|
current_owner=current_owner,
|
|
target_owner=target_group,
|
|
)
|
|
allowed = service.authorize_ownership_action(
|
|
session,
|
|
tenant_id=TENANT_ID,
|
|
resource_id=campaign.id,
|
|
action="accept_group_transfer",
|
|
actor=group_manager,
|
|
current_owner=current_owner,
|
|
target_owner=target_group,
|
|
)
|
|
|
|
self.assertFalse(denied.allowed)
|
|
self.assertTrue(allowed.allowed)
|
|
|
|
@patch(
|
|
"govoplan_campaign.backend.capabilities._valid_campaign_owner_target",
|
|
return_value=None,
|
|
)
|
|
def test_campaign_ownership_request_requires_existing_read_access(
|
|
self,
|
|
_target_validation,
|
|
) -> None:
|
|
session = _session()
|
|
self.addCleanup(_close_session, session)
|
|
_seed_access_subjects(session)
|
|
campaign = Campaign(
|
|
id="campaign-request",
|
|
tenant_id=TENANT_ID,
|
|
owner_user_id=USER_ID,
|
|
external_id="request",
|
|
name="Request",
|
|
)
|
|
session.add(campaign)
|
|
session.commit()
|
|
service = CampaignOwnershipService()
|
|
requester = OwnershipSubjectRef(
|
|
type="user",
|
|
id=OTHER_USER_ID,
|
|
scopes=frozenset({"campaigns:campaign:read"}),
|
|
)
|
|
current_owner = OwnershipSubjectRef(type="user", id=USER_ID)
|
|
|
|
denied = service.authorize_ownership_action(
|
|
session,
|
|
tenant_id=TENANT_ID,
|
|
resource_id=campaign.id,
|
|
action="request_ownership",
|
|
actor=requester,
|
|
current_owner=current_owner,
|
|
target_owner=requester,
|
|
)
|
|
session.add(
|
|
CampaignShare(
|
|
id="share-requester",
|
|
tenant_id=TENANT_ID,
|
|
campaign_id=campaign.id,
|
|
target_type="user",
|
|
target_id=OTHER_USER_ID,
|
|
permission="read",
|
|
)
|
|
)
|
|
session.commit()
|
|
allowed = service.authorize_ownership_action(
|
|
session,
|
|
tenant_id=TENANT_ID,
|
|
resource_id=campaign.id,
|
|
action="request_ownership",
|
|
actor=requester,
|
|
current_owner=current_owner,
|
|
target_owner=requester,
|
|
)
|
|
|
|
self.assertFalse(denied.allowed)
|
|
self.assertTrue(allowed.allowed)
|
|
|
|
@patch(
|
|
"govoplan_campaign.backend.capabilities._valid_campaign_owner_target",
|
|
return_value=None,
|
|
)
|
|
def test_campaign_owner_proposal_requires_share_authority(
|
|
self,
|
|
_target_validation,
|
|
) -> None:
|
|
session = _session()
|
|
self.addCleanup(_close_session, session)
|
|
_seed_access_subjects(session)
|
|
campaign = Campaign(
|
|
id="campaign-proposal",
|
|
tenant_id=TENANT_ID,
|
|
owner_user_id=USER_ID,
|
|
external_id="proposal",
|
|
name="Proposal",
|
|
)
|
|
session.add(campaign)
|
|
session.commit()
|
|
service = CampaignOwnershipService()
|
|
current_owner = OwnershipSubjectRef(type="user", id=USER_ID)
|
|
target_owner = OwnershipSubjectRef(type="user", id=OTHER_USER_ID)
|
|
|
|
denied = service.authorize_ownership_action(
|
|
session,
|
|
tenant_id=TENANT_ID,
|
|
resource_id=campaign.id,
|
|
action="propose_transfer",
|
|
actor=current_owner,
|
|
current_owner=current_owner,
|
|
target_owner=target_owner,
|
|
)
|
|
allowed = service.authorize_ownership_action(
|
|
session,
|
|
tenant_id=TENANT_ID,
|
|
resource_id=campaign.id,
|
|
action="propose_transfer",
|
|
actor=OwnershipSubjectRef(
|
|
type="user",
|
|
id=USER_ID,
|
|
scopes=frozenset({"campaigns:campaign:share"}),
|
|
),
|
|
current_owner=current_owner,
|
|
target_owner=target_owner,
|
|
)
|
|
|
|
self.assertFalse(denied.allowed)
|
|
self.assertTrue(allowed.allowed)
|
|
|
|
def test_campaign_ownership_provider_applies_only_against_expected_owner(self) -> None:
|
|
session = _session()
|
|
self.addCleanup(_close_session, session)
|
|
_seed_access_subjects(session)
|
|
campaign = Campaign(
|
|
id="campaign-owner-apply",
|
|
tenant_id=TENANT_ID,
|
|
owner_user_id=USER_ID,
|
|
external_id="owner-apply",
|
|
name="Owner apply",
|
|
)
|
|
session.add(campaign)
|
|
session.commit()
|
|
|
|
service = CampaignOwnershipService()
|
|
service.apply_owner(
|
|
session,
|
|
tenant_id=TENANT_ID,
|
|
resource_id=campaign.id,
|
|
expected_owner=OwnershipSubjectRef(type="user", id=USER_ID),
|
|
target_owner=OwnershipSubjectRef(type="group", id=GROUP_ID),
|
|
actor=OwnershipSubjectRef(type="user", id=OTHER_USER_ID),
|
|
reason=None,
|
|
)
|
|
session.flush()
|
|
self.assertIsNone(campaign.owner_user_id)
|
|
self.assertEqual(campaign.owner_group_id, GROUP_ID)
|
|
|
|
with self.assertRaisesRegex(
|
|
OwnershipTransferError,
|
|
"changed while the transfer was pending",
|
|
):
|
|
service.apply_owner(
|
|
session,
|
|
tenant_id=TENANT_ID,
|
|
resource_id=campaign.id,
|
|
expected_owner=OwnershipSubjectRef(type="user", id=USER_ID),
|
|
target_owner=OwnershipSubjectRef(type="user", id=OTHER_USER_ID),
|
|
actor=OwnershipSubjectRef(type="user", id=USER_ID),
|
|
reason=None,
|
|
)
|
|
|
|
|
|
def _session():
|
|
engine = create_engine("sqlite:///:memory:", future=True)
|
|
Base.metadata.create_all(
|
|
bind=engine,
|
|
tables=[
|
|
Account.__table__,
|
|
User.__table__,
|
|
Group.__table__,
|
|
Campaign.__table__,
|
|
CampaignShare.__table__,
|
|
CampaignCollaborationEntry.__table__,
|
|
CampaignVersion.__table__,
|
|
CampaignJob.__table__,
|
|
CampaignIssue.__table__,
|
|
AttachmentBlob.__table__,
|
|
AttachmentInstance.__table__,
|
|
SendAttempt.__table__,
|
|
CampaignMessageAction.__table__,
|
|
CampaignMessageActionAttempt.__table__,
|
|
ImapAppendAttempt.__table__,
|
|
PostboxDeliveryAttempt.__table__,
|
|
PrintOutputAttempt.__table__,
|
|
RecipientImportMappingProfile.__table__,
|
|
OwnershipTransfer.__table__,
|
|
ChangeSequenceEntry.__table__,
|
|
],
|
|
)
|
|
return sessionmaker(bind=engine, future=True)()
|
|
|
|
|
|
def _close_session(session) -> None:
|
|
engine = session.get_bind()
|
|
session.close()
|
|
engine.dispose()
|
|
|
|
|
|
def _seed_access_subjects(session) -> None:
|
|
session.add_all([
|
|
Account(id="account-1", email="one@example.test", normalized_email="one@example.test"),
|
|
Account(id="account-2", email="two@example.test", normalized_email="two@example.test"),
|
|
User(id=USER_ID, tenant_id=TENANT_ID, account_id="account-1", email="one@example.test"),
|
|
User(id=OTHER_USER_ID, tenant_id=TENANT_ID, account_id="account-2", email="two@example.test"),
|
|
Group(id=GROUP_ID, tenant_id=TENANT_ID, slug="group", name="Group"),
|
|
])
|
|
session.commit()
|
|
|
|
|
|
def _principal(
|
|
*,
|
|
scopes: set[str] | None = None,
|
|
group_ids: set[str] | None = None,
|
|
tenant_id: str = TENANT_ID,
|
|
) -> PrincipalRef:
|
|
return PrincipalRef(
|
|
account_id="account-1",
|
|
membership_id=USER_ID,
|
|
tenant_id=tenant_id,
|
|
scopes=frozenset(
|
|
{"campaigns:campaign:read"} if scopes is None else scopes
|
|
),
|
|
group_ids=frozenset(group_ids or set()),
|
|
)
|