feat: govern attachment exceptions and ownership transfers
This commit is contained in:
@@ -1,15 +1,26 @@
|
||||
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
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
|
||||
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
|
||||
|
||||
|
||||
@@ -70,6 +81,310 @@ class CampaignAccessProviderTests(unittest.TestCase):
|
||||
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)
|
||||
@@ -81,6 +396,8 @@ def _session():
|
||||
Group.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignShare.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignJob.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user