Files
govoplan-campaign/tests/test_access_provider.py

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()),
)