diff --git a/README.md b/README.md index 048eced..55d8a42 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,14 @@ instead of importing access ORM models or backend dependency internals. The detailed module boundary and serialization fields are documented in [docs/ACCESS_MODULE_BOUNDARY.md](docs/ACCESS_MODULE_BOUNDARY.md). +For scheduled and event-driven work, Access provides +`auth.automationPrincipalProvider`. Automation records store only an owner +account/membership reference and an explicit least-privilege scope grant, not +a session or API token. At delivery time Access rebuilds the principal from +current roles, groups, functions, and delegations and intersects that +authorization with the stored grant. Missing, inactive, moved, or +under-authorized owners fail closed before module work starts. + ## WebUI Package The repository root and `webui/` directory both expose the package diff --git a/src/govoplan_access/backend/auth/dependencies.py b/src/govoplan_access/backend/auth/dependencies.py index d21f0c2..b51834c 100644 --- a/src/govoplan_access/backend/auth/dependencies.py +++ b/src/govoplan_access/backend/auth/dependencies.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from fastapi import Depends, Header, HTTPException, Request, status from sqlalchemy.orm import Session @@ -15,6 +15,10 @@ from govoplan_core.core.access import ( PrincipalRef, PrincipalResolver, ) +from govoplan_core.core.automation import ( + AutomationPrincipalRequest, + AutomationPrincipalResolution, +) from govoplan_core.core.identity import IdentityDirectory from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef from govoplan_core.core.organizations import OrganizationDirectory @@ -558,6 +562,149 @@ class AccessApiPrincipalProvider: ) +class AccessAutomationPrincipalProvider: + """Rebuild a trigger owner against current tenant authorization.""" + + def __init__( + self, + *, + idm_directory: IdmDirectory | None = None, + identity_directory: IdentityDirectory | None = None, + organization_directory: OrganizationDirectory | None = None, + ) -> None: + self._idm_directory = idm_directory + self._identity_directory = identity_directory + self._organization_directory = organization_directory + + def resolve_automation_principal( + self, + session: object, + *, + request: AutomationPrincipalRequest, + ) -> AutomationPrincipalResolution: + if not isinstance(session, Session): + raise TypeError( + "Access automation principal resolution requires a " + "SQLAlchemy session" + ) + account = session.get(Account, request.account_id) + user = session.get(User, request.membership_id) + tenant = session.get(Tenant, request.tenant_id) + if ( + account is None + or user is None + or tenant is None + or not account.is_active + or not user.is_active + or not tenant.is_active + or user.account_id != account.id + or user.tenant_id != tenant.id + ): + return AutomationPrincipalResolution( + allowed=False, + reason=( + "The automation owner is inactive, missing, or no longer " + "belongs to the tenant." + ), + missing_scopes=tuple(sorted(set(request.grant_scopes))), + provenance={ + "authorization_ref": request.authorization_ref, + "tenant_id": request.tenant_id, + "account_id": request.account_id, + "membership_id": request.membership_id, + "status": "inactive_or_inconsistent", + }, + ) + + idm_assignments, idm_roles = _principal_idm_context( + session, + user=user, + account=account, + tenant_id=request.tenant_id, + idm_directory=self._idm_directory, + organization_directory=self._organization_directory, + ) + authorization_context = collect_user_authorization_context( + session, + user, + account=account, + include_system=False, + extra_roles=idm_roles, + ) + current_scopes = authorization_context.scopes + granted_scopes = tuple( + sorted( + { + required + for required in request.grant_scopes + if scopes_grant_compatible(current_scopes, required) + } + ) + ) + missing_scopes = tuple( + sorted(set(request.grant_scopes) - set(granted_scopes)) + ) + if missing_scopes: + return AutomationPrincipalResolution( + allowed=False, + reason=( + "The automation owner no longer has every scope granted " + "to this trigger." + ), + granted_scopes=granted_scopes, + missing_scopes=missing_scopes, + provenance={ + "authorization_ref": request.authorization_ref, + "tenant_id": request.tenant_id, + "account_id": request.account_id, + "membership_id": request.membership_id, + "status": "authorization_reduced", + }, + ) + + principal_ref = _build_principal_ref( + session, + account=account, + user=user, + tenant_id=request.tenant_id, + scopes=list(granted_scopes), + auth_method="service_account", + idm_assignments=idm_assignments, + identity_directory=self._identity_directory, + extra_roles=idm_roles, + authorization_context=authorization_context, + include_system_roles=False, + ) + principal_ref = replace( + principal_ref, + service_account_id=request.authorization_ref, + acting_for_account_id=account.id, + ) + api_principal = _api_principal_from_ref( + session, + principal_ref, + permission_evaluator=LegacyPermissionEvaluator(), + ) + return AutomationPrincipalResolution( + allowed=True, + principal=api_principal, + granted_scopes=granted_scopes, + provenance={ + "authorization_ref": request.authorization_ref, + "tenant_id": request.tenant_id, + "account_id": request.account_id, + "membership_id": request.membership_id, + "role_ids": sorted(principal_ref.role_ids), + "group_ids": sorted(principal_ref.group_ids), + "function_assignment_ids": sorted( + principal_ref.function_assignment_ids + ), + "delegation_ids": sorted(principal_ref.delegation_ids), + "status": "current_authorization_resolved", + }, + ) + + def get_api_principal( request: Request, session: Session = Depends(get_session), diff --git a/src/govoplan_access/backend/manifest.py b/src/govoplan_access/backend/manifest.py index 6785fe4..11206ae 100644 --- a/src/govoplan_access/backend/manifest.py +++ b/src/govoplan_access/backend/manifest.py @@ -15,6 +15,7 @@ from govoplan_core.core.access import ( CAPABILITY_ACCESS_TENANT_PROVISIONER, CAPABILITY_ACCESS_SEMANTIC_DIRECTORY, CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER, + CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER, CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER, @@ -479,6 +480,18 @@ def _api_principal_provider(context: ModuleContext) -> object: return AccessApiPrincipalProvider() +def _automation_principal_provider(context: ModuleContext) -> object: + from govoplan_access.backend.auth.dependencies import ( + AccessAutomationPrincipalProvider, + ) + + return AccessAutomationPrincipalProvider( + idm_directory=_optional_idm_directory(context), + identity_directory=_optional_identity_directory(context), + organization_directory=_optional_organization_directory(context), + ) + + def _tenant_context_switcher(context: ModuleContext) -> object: del context from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher @@ -612,6 +625,10 @@ manifest = ModuleManifest( optional_dependencies=("identity", "organizations", "tenancy", "idm"), provides_interfaces=( ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"), + ModuleInterfaceProvider( + name="auth.automation_principal", + version="0.1.0", + ), ), permissions=ACCESS_PERMISSIONS, role_templates=ACCESS_ROLE_TEMPLATES, @@ -653,6 +670,9 @@ manifest = ModuleManifest( ), capability_factories={ CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER: _api_principal_provider, + CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER: ( + _automation_principal_provider + ), CAPABILITY_AUTH_PRINCIPAL_RESOLVER: _legacy_principal_resolver, CAPABILITY_AUTH_PERMISSION_EVALUATOR: _legacy_permission_evaluator, CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: _tenant_context_switcher, diff --git a/tests/test_automation_principal.py b/tests/test_automation_principal.py new file mode 100644 index 0000000..ae0052d --- /dev/null +++ b/tests/test_automation_principal.py @@ -0,0 +1,162 @@ +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, 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", + ), + ) + + 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.assertEqual( + "dataflow-trigger:1", + result.principal.principal.service_account_id, + ) + self.assertNotIn( + "system:settings:write", + result.principal.scopes, + ) + + 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_manifest_registers_automation_resolution(self) -> None: + self.assertIn( + CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER, + manifest.capability_factories, + ) + + +if __name__ == "__main__": + unittest.main()