from __future__ import annotations 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_report_resource_id, ) from govoplan_campaign.backend.db.models import ( Campaign, CampaignJob, CampaignShare, CampaignVersion, ) from govoplan_core.core.access import PrincipalRef from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.core.ownership import OwnershipSubjectRef, 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_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_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__, CampaignVersion.__table__, CampaignJob.__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) -> PrincipalRef: return PrincipalRef( account_id="account-1", membership_id=USER_ID, tenant_id=TENANT_ID, scopes=frozenset(scopes or {"campaigns:campaign:read"}), group_ids=frozenset(group_ids or set()), )