Release v0.1.6
This commit is contained in:
577
tests/test_access_contracts.py
Normal file
577
tests/test_access_contracts.py
Normal file
@@ -0,0 +1,577 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.access import (
|
||||
ACCESS_CAPABILITY_NAMES,
|
||||
ACCESS_MODULE_ID,
|
||||
CAPABILITY_ACCESS_ADMINISTRATION,
|
||||
CAPABILITY_ACCESS_DIRECTORY,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_ACCESS_RESOURCE_ACCESS,
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER,
|
||||
CAPABILITY_AUDIT_SINK,
|
||||
CAPABILITY_SECURITY_SECRET_PROVIDER,
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
||||
AccessDecision,
|
||||
AccessAdministration,
|
||||
AccessDirectory,
|
||||
AccessGovernanceMaterializer,
|
||||
AccessSubjectRef,
|
||||
AccountRef,
|
||||
AuditEvent,
|
||||
AuditSink,
|
||||
GovernanceTemplateMaterialization,
|
||||
GroupRef,
|
||||
PermissionEvaluator,
|
||||
PrincipalRef,
|
||||
PrincipalResolver,
|
||||
ResourceAccessService,
|
||||
RoleRef,
|
||||
SecretProvider,
|
||||
TenantRef,
|
||||
TenantAccessProvisioner,
|
||||
TenantOwnerCandidateRef,
|
||||
TenantResolver,
|
||||
UserRef,
|
||||
)
|
||||
from govoplan_core.core.campaigns import (
|
||||
CAPABILITY_CAMPAIGNS_ACCESS,
|
||||
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS,
|
||||
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
|
||||
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
|
||||
CAPABILITY_CAMPAIGNS_RETENTION,
|
||||
CampaignAccessProvider,
|
||||
CampaignDeliveryTaskProvider,
|
||||
CampaignMailPolicyContext,
|
||||
CampaignMailPolicyContextProvider,
|
||||
CampaignPolicyContext,
|
||||
CampaignPolicyContextProvider,
|
||||
CampaignRetentionProvider,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, get_database
|
||||
from govoplan_core.server.app import create_app
|
||||
from govoplan_core.server.config import GovoplanServerConfig
|
||||
from govoplan_access.backend.db.models import Account, Group, User, UserGroupMembership
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
|
||||
|
||||
class _FakeAccessDirectory:
|
||||
account = AccountRef(id="account-1", email="demo@example.test", display_name="Demo")
|
||||
user = UserRef(id="user-1", account_id=account.id, tenant_id="tenant-1", email=account.email)
|
||||
group = GroupRef(id="group-1", tenant_id="tenant-1", name="Team")
|
||||
|
||||
def get_account(self, account_id: str) -> AccountRef | None:
|
||||
return self.account if account_id == self.account.id else None
|
||||
|
||||
def get_user(self, user_id: str) -> UserRef | None:
|
||||
return self.user if user_id == self.user.id else None
|
||||
|
||||
def get_users(self, user_ids):
|
||||
return {self.user.id: self.user} if self.user.id in set(user_ids) else {}
|
||||
|
||||
def users_for_tenant(self, tenant_id: str):
|
||||
return (self.user,) if tenant_id == self.user.tenant_id else ()
|
||||
|
||||
def get_group(self, group_id: str) -> GroupRef | None:
|
||||
return self.group if group_id == self.group.id else None
|
||||
|
||||
def get_groups(self, group_ids):
|
||||
return {self.group.id: self.group} if self.group.id in set(group_ids) else {}
|
||||
|
||||
def groups_for_tenant(self, tenant_id: str):
|
||||
return (self.group,) if tenant_id == self.group.tenant_id else ()
|
||||
|
||||
def groups_for_user(self, user_id: str, *, tenant_id: str):
|
||||
if user_id == self.user.id and tenant_id == self.user.tenant_id:
|
||||
return (self.group,)
|
||||
return ()
|
||||
|
||||
def display_label(self, subject: AccessSubjectRef) -> str | None:
|
||||
if subject.kind == "user" and subject.id == self.user.id:
|
||||
return self.user.display_name or self.user.email
|
||||
if subject.kind == "group" and subject.id == self.group.id:
|
||||
return self.group.name
|
||||
return subject.label
|
||||
|
||||
|
||||
class _FakePrincipalResolver:
|
||||
def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef:
|
||||
del request, session
|
||||
return PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset({"files:file:read"}),
|
||||
group_ids=frozenset({"group-1"}),
|
||||
)
|
||||
|
||||
|
||||
class _FakeTenantResolver:
|
||||
tenant = TenantRef(id="tenant-1", name="Tenant")
|
||||
|
||||
def get_tenant(self, tenant_id: str) -> TenantRef | None:
|
||||
return self.tenant if tenant_id == self.tenant.id else None
|
||||
|
||||
def current_tenant(self, principal: PrincipalRef) -> TenantRef | None:
|
||||
return self.get_tenant(principal.tenant_id or "")
|
||||
|
||||
|
||||
class _FakePermissionEvaluator:
|
||||
def scopes_grant(self, scopes, required_scope: str) -> bool:
|
||||
return required_scope in set(scopes)
|
||||
|
||||
def has_scope(self, principal: PrincipalRef, required_scope: str) -> bool:
|
||||
return self.scopes_grant(principal.scopes, required_scope)
|
||||
|
||||
def explain_scope(self, principal: PrincipalRef, required_scope: str) -> AccessDecision:
|
||||
allowed = self.has_scope(principal, required_scope)
|
||||
return AccessDecision(allowed=allowed, reason=None if allowed else "missing scope", requirements=(required_scope,))
|
||||
|
||||
|
||||
class _FakeResourceAccessService:
|
||||
def explain(self, principal: PrincipalRef, *, resource_type: str, resource_id: str, action: str) -> AccessDecision:
|
||||
del principal, resource_type, resource_id, action
|
||||
return AccessDecision(allowed=True)
|
||||
|
||||
|
||||
class _FakeTenantAccessProvisioner:
|
||||
def ensure_default_roles(self, session: object, tenant: object | None = None):
|
||||
del session, tenant
|
||||
return {}
|
||||
|
||||
def tenant_owner_candidates(self, session: object):
|
||||
del session
|
||||
return (TenantOwnerCandidateRef(account_id="account-1", email="demo@example.test", display_name="Demo"),)
|
||||
|
||||
def ensure_tenant_owner_membership(self, session: object, *, tenant: object, owner_account_id: str) -> str:
|
||||
del session, tenant, owner_account_id
|
||||
return "user-1"
|
||||
|
||||
|
||||
class _FakeAccessAdministration:
|
||||
def system_account_count(self, session: object) -> int:
|
||||
del session
|
||||
return 1
|
||||
|
||||
def role_count_for_tenant(self, session: object, tenant_id: str) -> int:
|
||||
del session, tenant_id
|
||||
return 2
|
||||
|
||||
def active_api_key_count_for_tenant(self, session: object, tenant_id: str) -> int:
|
||||
del session, tenant_id
|
||||
return 3
|
||||
|
||||
def actor_email_by_user_id(self, session: object, user_ids):
|
||||
del session
|
||||
return {user_id: "demo@example.test" for user_id in user_ids}
|
||||
|
||||
def user_ids_for_actor_filter(self, session: object, *, operator: str, value: str):
|
||||
del session, operator, value
|
||||
return ("user-1",)
|
||||
|
||||
|
||||
class _FakeAccessGovernanceMaterializer:
|
||||
def __init__(self) -> None:
|
||||
self.synced: list[GovernanceTemplateMaterialization] = []
|
||||
self.removed: list[GovernanceTemplateMaterialization] = []
|
||||
|
||||
def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
||||
del session
|
||||
self.synced.append(template)
|
||||
|
||||
def remove_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
||||
del session
|
||||
self.removed.append(template)
|
||||
|
||||
|
||||
class _FakeCampaignMailPolicyContextProvider:
|
||||
context = CampaignMailPolicyContext(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
owner_user_id="user-1",
|
||||
owner_group_id="group-1",
|
||||
mail_profile_policy={"allow_campaign_profiles": False},
|
||||
)
|
||||
|
||||
def get_campaign_mail_policy_context(self, session: object, *, tenant_id: str, campaign_id: str):
|
||||
del session
|
||||
if tenant_id == self.context.tenant_id and campaign_id == self.context.id:
|
||||
return self.context
|
||||
return None
|
||||
|
||||
def set_campaign_mail_profile_policy(self, session: object, *, tenant_id: str, campaign_id: str, policy):
|
||||
del session
|
||||
if tenant_id != self.context.tenant_id or campaign_id != self.context.id:
|
||||
raise ValueError("Campaign policy not found")
|
||||
return dict(policy)
|
||||
|
||||
|
||||
class _FakeCampaignAccessProvider:
|
||||
def campaign_exists(self, session: object, *, tenant_id: str, campaign_id: str) -> bool:
|
||||
del session
|
||||
return tenant_id == "tenant-1" and campaign_id == "campaign-1"
|
||||
|
||||
def can_read_campaign(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
user_id: str,
|
||||
group_ids=(),
|
||||
tenant_admin: bool = False,
|
||||
) -> bool:
|
||||
del session
|
||||
return self.campaign_exists(object(), tenant_id=tenant_id, campaign_id=campaign_id) and (
|
||||
tenant_admin or user_id == "user-1" or "group-1" in set(group_ids)
|
||||
)
|
||||
|
||||
|
||||
class _FakeCampaignPolicyContextProvider:
|
||||
context = CampaignPolicyContext(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
owner_user_id="user-1",
|
||||
owner_group_id="group-1",
|
||||
settings={"privacy_retention_policy": {"audit_detail_level": "redacted"}},
|
||||
)
|
||||
|
||||
def get_campaign_policy_context(self, session: object, *, campaign_id: str, tenant_id: str | None = None):
|
||||
del session
|
||||
if campaign_id != self.context.id or (tenant_id is not None and tenant_id != self.context.tenant_id):
|
||||
return None
|
||||
return self.context
|
||||
|
||||
def set_campaign_settings(self, session: object, *, tenant_id: str, campaign_id: str, settings):
|
||||
del session
|
||||
if tenant_id != self.context.tenant_id or campaign_id != self.context.id:
|
||||
raise ValueError("Campaign privacy policy not found")
|
||||
return dict(settings)
|
||||
|
||||
|
||||
class _FakeCampaignDeliveryTaskProvider:
|
||||
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True):
|
||||
del session
|
||||
return {"job_id": job_id, "enqueue_imap_task": enqueue_imap_task}
|
||||
|
||||
def append_sent_for_job(self, session: object, *, job_id: str):
|
||||
del session
|
||||
return {"job_id": job_id, "status": "appended"}
|
||||
|
||||
|
||||
class _FakeCampaignRetentionProvider:
|
||||
def apply_retention(self, session: object, *, dry_run: bool, now, policy_for_campaign_id):
|
||||
del session, now
|
||||
policy_for_campaign_id("campaign-1")
|
||||
return {"raw_campaign_json": {"eligible": int(dry_run)}}
|
||||
|
||||
|
||||
class _FakeSecretProvider:
|
||||
def __init__(self) -> None:
|
||||
self._values: dict[str, str] = {}
|
||||
|
||||
def store_secret(self, *, scope: str, name: str, value: str) -> str:
|
||||
ref = f"{scope}/{name}"
|
||||
self._values[ref] = value
|
||||
return ref
|
||||
|
||||
def read_secret(self, secret_ref: str) -> str | None:
|
||||
return self._values.get(secret_ref)
|
||||
|
||||
def delete_secret(self, secret_ref: str) -> None:
|
||||
self._values.pop(secret_ref, None)
|
||||
|
||||
|
||||
class _FakeAuditSink:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[AuditEvent] = []
|
||||
|
||||
def record(self, event: AuditEvent) -> None:
|
||||
self.events.append(event)
|
||||
|
||||
|
||||
class AccessContractTests(unittest.TestCase):
|
||||
def test_access_capability_names_are_stable(self) -> None:
|
||||
self.assertEqual("access", ACCESS_MODULE_ID)
|
||||
self.assertEqual("access.principalResolver", CAPABILITY_ACCESS_PRINCIPAL_RESOLVER)
|
||||
self.assertEqual("access.directory", CAPABILITY_ACCESS_DIRECTORY)
|
||||
self.assertEqual("access.permissionEvaluator", CAPABILITY_ACCESS_PERMISSION_EVALUATOR)
|
||||
self.assertEqual("access.resourceAccess", CAPABILITY_ACCESS_RESOURCE_ACCESS)
|
||||
self.assertEqual("access.tenantProvisioner", CAPABILITY_ACCESS_TENANT_PROVISIONER)
|
||||
self.assertEqual("access.administration", CAPABILITY_ACCESS_ADMINISTRATION)
|
||||
self.assertEqual("access.governanceMaterializer", CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER)
|
||||
self.assertEqual("campaigns.access", CAPABILITY_CAMPAIGNS_ACCESS)
|
||||
self.assertEqual("campaigns.deliveryTasks", CAPABILITY_CAMPAIGNS_DELIVERY_TASKS)
|
||||
self.assertEqual("campaigns.mailPolicyContext", CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT)
|
||||
self.assertEqual("campaigns.policyContext", CAPABILITY_CAMPAIGNS_POLICY_CONTEXT)
|
||||
self.assertEqual("campaigns.retention", CAPABILITY_CAMPAIGNS_RETENTION)
|
||||
self.assertEqual("tenancy.tenantResolver", CAPABILITY_TENANCY_TENANT_RESOLVER)
|
||||
self.assertEqual("security.secretProvider", CAPABILITY_SECURITY_SECRET_PROVIDER)
|
||||
self.assertEqual("audit.sink", CAPABILITY_AUDIT_SINK)
|
||||
self.assertEqual(len(ACCESS_CAPABILITY_NAMES), len(set(ACCESS_CAPABILITY_NAMES)))
|
||||
|
||||
def test_access_refs_are_small_immutable_dtos(self) -> None:
|
||||
principal = PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset({"campaigns:campaign:read"}),
|
||||
group_ids=frozenset({"group-1"}),
|
||||
email="demo@example.test",
|
||||
)
|
||||
role = RoleRef(id="role-1", name="Reviewer", level="tenant", tenant_id="tenant-1")
|
||||
|
||||
self.assertEqual("account-1", principal.account_id)
|
||||
self.assertEqual(frozenset({"campaigns:campaign:read"}), principal.scopes)
|
||||
self.assertEqual("Reviewer", role.name)
|
||||
with self.assertRaises(AttributeError):
|
||||
principal.account_id = "changed" # type: ignore[misc]
|
||||
|
||||
def test_protocol_shapes_match_reference_implementations(self) -> None:
|
||||
self.assertIsInstance(_FakePrincipalResolver(), PrincipalResolver)
|
||||
self.assertIsInstance(_FakeTenantResolver(), TenantResolver)
|
||||
self.assertIsInstance(_FakeAccessDirectory(), AccessDirectory)
|
||||
self.assertIsInstance(_FakePermissionEvaluator(), PermissionEvaluator)
|
||||
self.assertIsInstance(_FakeResourceAccessService(), ResourceAccessService)
|
||||
self.assertIsInstance(_FakeTenantAccessProvisioner(), TenantAccessProvisioner)
|
||||
self.assertIsInstance(_FakeAccessAdministration(), AccessAdministration)
|
||||
self.assertIsInstance(_FakeAccessGovernanceMaterializer(), AccessGovernanceMaterializer)
|
||||
self.assertIsInstance(_FakeCampaignAccessProvider(), CampaignAccessProvider)
|
||||
self.assertIsInstance(_FakeCampaignDeliveryTaskProvider(), CampaignDeliveryTaskProvider)
|
||||
self.assertIsInstance(_FakeCampaignMailPolicyContextProvider(), CampaignMailPolicyContextProvider)
|
||||
self.assertIsInstance(_FakeCampaignPolicyContextProvider(), CampaignPolicyContextProvider)
|
||||
self.assertIsInstance(_FakeCampaignRetentionProvider(), CampaignRetentionProvider)
|
||||
self.assertIsInstance(_FakeSecretProvider(), SecretProvider)
|
||||
self.assertIsInstance(_FakeAuditSink(), AuditSink)
|
||||
|
||||
def test_campaign_mail_policy_context_capability_shape(self) -> None:
|
||||
provider = _FakeCampaignMailPolicyContextProvider()
|
||||
context = provider.get_campaign_mail_policy_context(object(), tenant_id="tenant-1", campaign_id="campaign-1")
|
||||
|
||||
self.assertEqual("campaign-1", context.id) # type: ignore[union-attr]
|
||||
self.assertEqual("user-1", context.owner_user_id) # type: ignore[union-attr]
|
||||
self.assertEqual({"allow_campaign_profiles": False}, context.mail_profile_policy) # type: ignore[union-attr]
|
||||
self.assertEqual({"allow_campaign_profiles": True}, provider.set_campaign_mail_profile_policy(object(), tenant_id="tenant-1", campaign_id="campaign-1", policy={"allow_campaign_profiles": True}))
|
||||
|
||||
def test_campaign_access_capability_shape(self) -> None:
|
||||
provider = _FakeCampaignAccessProvider()
|
||||
|
||||
self.assertTrue(provider.campaign_exists(object(), tenant_id="tenant-1", campaign_id="campaign-1"))
|
||||
self.assertTrue(provider.can_read_campaign(object(), tenant_id="tenant-1", campaign_id="campaign-1", user_id="other", group_ids=("group-1",)))
|
||||
self.assertFalse(provider.can_read_campaign(object(), tenant_id="tenant-1", campaign_id="missing", user_id="user-1"))
|
||||
|
||||
def test_campaign_policy_delivery_and_retention_capability_shapes(self) -> None:
|
||||
policy_provider = _FakeCampaignPolicyContextProvider()
|
||||
delivery_provider = _FakeCampaignDeliveryTaskProvider()
|
||||
retention_provider = _FakeCampaignRetentionProvider()
|
||||
|
||||
context = policy_provider.get_campaign_policy_context(object(), tenant_id="tenant-1", campaign_id="campaign-1")
|
||||
self.assertEqual("tenant-1", context.tenant_id) # type: ignore[union-attr]
|
||||
self.assertEqual({"privacy_retention_policy": {"audit_detail_level": "redacted"}}, context.settings) # type: ignore[union-attr]
|
||||
self.assertEqual({"demo": True}, policy_provider.set_campaign_settings(object(), tenant_id="tenant-1", campaign_id="campaign-1", settings={"demo": True}))
|
||||
self.assertEqual({"job_id": "job-1", "enqueue_imap_task": False}, delivery_provider.send_campaign_job(object(), job_id="job-1", enqueue_imap_task=False))
|
||||
self.assertEqual({"job_id": "job-1", "status": "appended"}, delivery_provider.append_sent_for_job(object(), job_id="job-1"))
|
||||
self.assertEqual({"raw_campaign_json": {"eligible": 1}}, retention_provider.apply_retention(object(), dry_run=True, now=object(), policy_for_campaign_id=lambda campaign_id: object()))
|
||||
|
||||
def test_access_capabilities_register_and_resolve_through_platform_registry(self) -> None:
|
||||
directory = _FakeAccessDirectory()
|
||||
resolver = _FakePrincipalResolver()
|
||||
evaluator = _FakePermissionEvaluator()
|
||||
tenant_resolver = _FakeTenantResolver()
|
||||
administration = _FakeAccessAdministration()
|
||||
materializer = _FakeAccessGovernanceMaterializer()
|
||||
manifest = ModuleManifest(
|
||||
id=ACCESS_MODULE_ID,
|
||||
name="Access",
|
||||
version="test",
|
||||
capability_factories={
|
||||
CAPABILITY_ACCESS_DIRECTORY: lambda context: directory,
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: lambda context: resolver,
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: lambda context: evaluator,
|
||||
CAPABILITY_ACCESS_ADMINISTRATION: lambda context: administration,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: lambda context: materializer,
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER: lambda context: tenant_resolver,
|
||||
},
|
||||
)
|
||||
|
||||
registry = PlatformRegistry()
|
||||
registry.register(manifest)
|
||||
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||
|
||||
self.assertIs(directory, registry.require_capability(CAPABILITY_ACCESS_DIRECTORY))
|
||||
self.assertIs(resolver, registry.require_capability(CAPABILITY_ACCESS_PRINCIPAL_RESOLVER))
|
||||
self.assertIs(evaluator, registry.require_capability(CAPABILITY_ACCESS_PERMISSION_EVALUATOR))
|
||||
self.assertIs(administration, registry.require_capability(CAPABILITY_ACCESS_ADMINISTRATION))
|
||||
self.assertIs(materializer, registry.require_capability(CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER))
|
||||
self.assertIs(tenant_resolver, registry.require_capability(CAPABILITY_TENANCY_TENANT_RESOLVER))
|
||||
|
||||
def test_sql_directory_and_tenant_resolver_return_access_dtos(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-access-directory-"))
|
||||
try:
|
||||
configure_database(f"sqlite:///{root / 'directory.db'}")
|
||||
database = get_database()
|
||||
Base.metadata.create_all(bind=database.engine)
|
||||
with database.session() as session:
|
||||
tenant = Tenant(id="tenant-directory", slug="directory", name="Directory Tenant", settings={})
|
||||
account = Account(
|
||||
id="account-directory",
|
||||
email="directory@example.test",
|
||||
normalized_email="directory@example.test",
|
||||
display_name="Directory User",
|
||||
)
|
||||
user = User(
|
||||
id="user-directory",
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=None,
|
||||
settings={},
|
||||
mail_profile_policy={},
|
||||
)
|
||||
group = Group(id="group-directory", tenant_id=tenant.id, slug="review", name="Review", description=None)
|
||||
membership = UserGroupMembership(tenant_id=tenant.id, user_id=user.id, group_id=group.id)
|
||||
session.add_all([tenant, account, user, group, membership])
|
||||
session.commit()
|
||||
|
||||
from govoplan_access.backend.directory import SqlAccessDirectory
|
||||
from govoplan_tenancy.backend.capabilities import SqlTenantResolver
|
||||
|
||||
directory = SqlAccessDirectory()
|
||||
tenant_resolver = SqlTenantResolver()
|
||||
|
||||
self.assertEqual("directory@example.test", directory.get_account("account-directory").email) # type: ignore[union-attr]
|
||||
self.assertEqual("Directory User", directory.get_user("user-directory").display_name) # type: ignore[union-attr]
|
||||
self.assertEqual(["user-directory"], [user.id for user in directory.users_for_tenant("tenant-directory")])
|
||||
self.assertEqual(["group-directory"], [group.id for group in directory.groups_for_tenant("tenant-directory")])
|
||||
self.assertEqual(["group-directory"], [group.id for group in directory.groups_for_user("user-directory", tenant_id="tenant-directory")])
|
||||
self.assertEqual("Review", directory.display_label(AccessSubjectRef(kind="group", id="group-directory", tenant_id="tenant-directory")))
|
||||
self.assertEqual("directory", tenant_resolver.get_tenant("tenant-directory").slug) # type: ignore[union-attr]
|
||||
self.assertEqual(
|
||||
"tenant-directory",
|
||||
tenant_resolver.current_tenant(
|
||||
PrincipalRef(account_id="account-directory", membership_id="user-directory", tenant_id="tenant-directory")
|
||||
).id, # type: ignore[union-attr]
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
def test_api_principal_dependency_uses_access_resolver_capability(self) -> None:
|
||||
root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-"))
|
||||
try:
|
||||
account_id = "account-capability"
|
||||
tenant_id = "tenant-capability"
|
||||
user_id = "user-capability"
|
||||
|
||||
class CapabilityPrincipalResolver:
|
||||
def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef:
|
||||
del request, session
|
||||
return PrincipalRef(
|
||||
account_id=account_id,
|
||||
membership_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
scopes=frozenset(),
|
||||
)
|
||||
|
||||
class CapabilityPermissionEvaluator:
|
||||
def scopes_grant(self, scopes, required_scope: str) -> bool:
|
||||
del scopes
|
||||
return required_scope == "demo:scope:read"
|
||||
|
||||
def has_scope(self, principal: PrincipalRef, required_scope: str) -> bool:
|
||||
del principal
|
||||
return required_scope == "demo:scope:read"
|
||||
|
||||
def explain_scope(self, principal: PrincipalRef, required_scope: str) -> AccessDecision:
|
||||
return AccessDecision(allowed=self.has_scope(principal, required_scope), requirements=(required_scope,))
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/capability-principal")
|
||||
def capability_principal(principal: ApiPrincipal = Depends(get_api_principal)) -> dict[str, object]:
|
||||
return {
|
||||
"account_id": principal.account_id,
|
||||
"tenant_id": principal.tenant_id,
|
||||
"user_id": principal.user.id,
|
||||
"has_demo_scope": principal.has("demo:scope:read"),
|
||||
}
|
||||
|
||||
def access_manifest() -> ModuleManifest:
|
||||
return ModuleManifest(
|
||||
id=ACCESS_MODULE_ID,
|
||||
name="Access",
|
||||
version="test",
|
||||
capability_factories={
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: lambda context: CapabilityPrincipalResolver(),
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: lambda context: CapabilityPermissionEvaluator(),
|
||||
},
|
||||
)
|
||||
|
||||
config = GovoplanServerConfig(
|
||||
title="access contract test",
|
||||
version="test",
|
||||
settings=SimpleNamespace(database_url=f"sqlite:///{root / 'test.db'}"),
|
||||
enabled_modules=(),
|
||||
manifest_factories=(access_manifest,),
|
||||
base_routers=(router,),
|
||||
post_module_routers=(),
|
||||
extra_routers=(),
|
||||
lifespan=None,
|
||||
app_configurators=(),
|
||||
)
|
||||
app = create_app(config)
|
||||
|
||||
database = get_database()
|
||||
Base.metadata.create_all(bind=database.engine)
|
||||
with database.session() as session:
|
||||
tenant = Tenant(id=tenant_id, slug="capability", name="Capability Tenant", settings={})
|
||||
account = Account(
|
||||
id=account_id,
|
||||
email="capability@example.test",
|
||||
normalized_email="capability@example.test",
|
||||
display_name="Capability User",
|
||||
)
|
||||
user = User(
|
||||
id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
settings={},
|
||||
mail_profile_policy={},
|
||||
)
|
||||
session.add_all([tenant, account, user])
|
||||
session.commit()
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/capability-principal")
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(
|
||||
{
|
||||
"account_id": account_id,
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": user_id,
|
||||
"has_demo_scope": True,
|
||||
},
|
||||
response.json(),
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from alembic.script import ScriptDirectory
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from govoplan_core.db.base import Base
|
||||
@@ -18,6 +19,14 @@ from govoplan_core.db.migrations import (
|
||||
)
|
||||
|
||||
|
||||
def configured_migration_heads(database_url: str) -> set[str]:
|
||||
return set(ScriptDirectory.from_config(alembic_config(database_url=database_url)).get_heads())
|
||||
|
||||
|
||||
def database_migration_heads(connection) -> set[str]:
|
||||
return set(MigrationContext.configure(connection).get_current_heads())
|
||||
|
||||
|
||||
class DatabaseMigrationTests(unittest.TestCase):
|
||||
def test_repairs_create_all_schema_drift_and_upgrades_to_head(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="msm-migration-test-") as directory:
|
||||
@@ -52,7 +61,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
current = MigrationContext.configure(connection).get_current_revision()
|
||||
current = database_migration_heads(connection)
|
||||
inspector = inspect(connection)
|
||||
columns = {
|
||||
column["name"]
|
||||
@@ -93,7 +102,8 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
WHERE role.slug = 'system_owner' AND account.is_active = 1
|
||||
"""
|
||||
)).scalar_one()
|
||||
self.assertEqual(current, result.current_revision)
|
||||
self.assertEqual(current, configured_migration_heads(url))
|
||||
self.assertEqual(result.current_revision, ",".join(sorted(current)))
|
||||
self.assertIn("user_lock_state", columns)
|
||||
self.assertIn("user_locked_at", columns)
|
||||
self.assertIn("user_locked_by_user_id", columns)
|
||||
@@ -212,10 +222,9 @@ class DatabaseMigrationTests(unittest.TestCase):
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
self.assertEqual(
|
||||
MigrationContext.configure(connection).get_current_revision(),
|
||||
result.current_revision,
|
||||
)
|
||||
current = database_migration_heads(connection)
|
||||
self.assertEqual(current, configured_migration_heads(url))
|
||||
self.assertEqual(result.current_revision, ",".join(sorted(current)))
|
||||
profile_columns = {
|
||||
column["name"]
|
||||
for column in inspect(connection).get_columns("mail_server_profiles")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user