296 lines
9.1 KiB
Python
296 lines
9.1 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.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()
|