432 lines
16 KiB
Python
432 lines
16 KiB
Python
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()),
|
|
)
|