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.auth.dependencies import ( AccessAutomationPrincipalProvider, ) from govoplan_access.backend.db.base import AccessBase from govoplan_access.backend.db.models import ( Account, ServiceAccount, User, ) from govoplan_access.backend.manifest import manifest from govoplan_access.backend.security.sessions import ( UserAuthorizationContext, ) from govoplan_core.auth import ApiPrincipal from govoplan_core.core.access import ( CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER, ) from govoplan_core.core.automation import AutomationPrincipalRequest from govoplan_core.tenancy.scope import ( Tenant, create_scope_tables, scope_registry, ) class AutomationPrincipalTests(unittest.TestCase): def setUp(self) -> None: self.engine = create_engine("sqlite:///:memory:") create_scope_tables(self.engine) AccessBase.metadata.create_all(bind=self.engine) self.Session = sessionmaker(bind=self.engine) self.session = self.Session() self.account = Account( id="account-1", email="owner@example.test", normalized_email="owner@example.test", ) self.tenant = Tenant( id="tenant-1", slug="tenant-1", name="Tenant 1", ) self.user = User( id="user-1", tenant_id=self.tenant.id, account_id=self.account.id, email=self.account.email, ) self.session.add_all([self.tenant, self.account, self.user]) self.session.commit() self.provider = AccessAutomationPrincipalProvider() def tearDown(self) -> None: self.session.close() AccessBase.metadata.drop_all(bind=self.engine) scope_registry.metadata.drop_all(bind=self.engine) self.engine.dispose() def _request(self) -> AutomationPrincipalRequest: return AutomationPrincipalRequest( tenant_id=self.tenant.id, account_id=self.account.id, membership_id=self.user.id, authorization_ref="dataflow-trigger:1", grant_scopes=( "dataflow:pipeline:run", "datasources:catalogue:read", ), context={ "trigger_ref": "dataflow-trigger:1", "delivery_ref": "dataflow-delivery:1", "event_actor": { "type": "user", "id": "event-user-1", }, "operator_override": { "type": "user", "id": "operator-1", "reason": "approved replay", }, }, ) def test_resolution_intersects_trigger_grant_with_current_scopes(self) -> None: context = UserAuthorizationContext( tenant_roles=[], system_roles=[], groups=[], function_assignment_ids=(), function_delegation_ids=(), scopes=[ "dataflow:pipeline:run", "datasources:catalogue:read", "system:settings:write", ], ) with patch( "govoplan_access.backend.auth.dependencies." "collect_user_authorization_context", return_value=context, ): result = self.provider.resolve_automation_principal( self.session, request=self._request(), ) self.assertTrue(result.allowed) self.assertIsInstance(result.principal, ApiPrincipal) self.assertEqual( frozenset( { "dataflow:pipeline:run", "datasources:catalogue:read", } ), result.principal.scopes, ) self.assertIsNone( result.principal.principal.service_account_id ) self.assertEqual( self.account.id, result.principal.principal.acting_for_account_id, ) self.assertNotIn( "system:settings:write", result.principal.scopes, ) self.assertEqual( "delegated_user", result.provenance["trigger_owner"]["kind"], ) self.assertEqual( "event-user-1", result.provenance["event_actor"]["id"], ) self.assertEqual( "operator-1", result.provenance["operator_override"]["id"], ) self.assertEqual( self.account.id, result.provenance[ "current_automation_principal" ]["account_id"], ) def test_revoked_scope_and_suspended_owner_fail_closed(self) -> None: context = UserAuthorizationContext( tenant_roles=[], system_roles=[], groups=[], function_assignment_ids=(), function_delegation_ids=(), scopes=["dataflow:pipeline:run"], ) with patch( "govoplan_access.backend.auth.dependencies." "collect_user_authorization_context", return_value=context, ): result = self.provider.resolve_automation_principal( self.session, request=self._request(), ) self.assertFalse(result.allowed) self.assertEqual( ("datasources:catalogue:read",), result.missing_scopes, ) self.account.is_active = False self.session.flush() suspended = self.provider.resolve_automation_principal( self.session, request=self._request(), ) self.assertFalse(suspended.allowed) self.assertEqual( "inactive_or_inconsistent", suspended.provenance["status"], ) def test_service_account_resolution_uses_current_scope_ceiling(self) -> None: account = Account( id="service-account-backing", email="service@example.invalid", normalized_email="service@example.invalid", display_name="Import worker", auth_provider="service_account", ) membership = User( id="service-membership", tenant_id=self.tenant.id, account_id=account.id, email=account.email, display_name=account.display_name, auth_provider="service_account", ) service_account = ServiceAccount( id="service-1", tenant_id=self.tenant.id, account_id=account.id, membership_id=membership.id, name="Import worker", normalized_name="import worker", scope_ceiling=[ "dataflow:pipeline:run", "datasources:catalogue:read", "system:settings:write", ], is_active=True, revision=1, settings={}, ) self.session.add_all( (account, membership, service_account) ) self.session.flush() request = AutomationPrincipalRequest.service_account( tenant_id=self.tenant.id, service_account_id=service_account.id, authorization_ref="dataflow-trigger:service", grant_scopes=( "dataflow:pipeline:run", "datasources:catalogue:read", ), ) result = self.provider.resolve_automation_principal( self.session, request=request, ) self.assertTrue(result.allowed) self.assertEqual( service_account.id, result.principal.principal.service_account_id, ) self.assertEqual( frozenset(request.grant_scopes), result.principal.scopes, ) self.assertNotIn( "system:settings:write", result.principal.scopes, ) self.assertEqual( "service_account", result.provenance["trigger_owner"]["kind"], ) service_account.scope_ceiling = [ "dataflow:pipeline:run" ] self.session.flush() reduced = self.provider.resolve_automation_principal( self.session, request=request, ) self.assertFalse(reduced.allowed) self.assertEqual( ("datasources:catalogue:read",), reduced.missing_scopes, ) service_account.is_active = False self.session.flush() inactive = self.provider.resolve_automation_principal( self.session, request=request, ) self.assertFalse(inactive.allowed) self.assertEqual( "inactive_or_inconsistent", inactive.provenance["status"], ) def test_manifest_registers_automation_resolution(self) -> None: self.assertIn( CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER, manifest.capability_factories, ) if __name__ == "__main__": unittest.main()