Resolve current principals for automation

This commit is contained in:
2026-07-28 15:03:50 +02:00
parent f1d64d247e
commit fdfcfbb440
4 changed files with 338 additions and 1 deletions

View File

@@ -84,6 +84,14 @@ instead of importing access ORM models or backend dependency internals.
The detailed module boundary and serialization fields are documented in The detailed module boundary and serialization fields are documented in
[docs/ACCESS_MODULE_BOUNDARY.md](docs/ACCESS_MODULE_BOUNDARY.md). [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 ## WebUI Package
The repository root and `webui/` directory both expose the package The repository root and `webui/` directory both expose the package

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass, replace
from fastapi import Depends, Header, HTTPException, Request, status from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -15,6 +15,10 @@ from govoplan_core.core.access import (
PrincipalRef, PrincipalRef,
PrincipalResolver, PrincipalResolver,
) )
from govoplan_core.core.automation import (
AutomationPrincipalRequest,
AutomationPrincipalResolution,
)
from govoplan_core.core.identity import IdentityDirectory from govoplan_core.core.identity import IdentityDirectory
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import OrganizationDirectory 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( def get_api_principal(
request: Request, request: Request,
session: Session = Depends(get_session), session: Session = Depends(get_session),

View File

@@ -15,6 +15,7 @@ from govoplan_core.core.access import (
CAPABILITY_ACCESS_TENANT_PROVISIONER, CAPABILITY_ACCESS_TENANT_PROVISIONER,
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY, CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER, CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER, CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
@@ -479,6 +480,18 @@ def _api_principal_provider(context: ModuleContext) -> object:
return AccessApiPrincipalProvider() 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: def _tenant_context_switcher(context: ModuleContext) -> object:
del context del context
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
@@ -612,6 +625,10 @@ manifest = ModuleManifest(
optional_dependencies=("identity", "organizations", "tenancy", "idm"), optional_dependencies=("identity", "organizations", "tenancy", "idm"),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"), ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
ModuleInterfaceProvider(
name="auth.automation_principal",
version="0.1.0",
),
), ),
permissions=ACCESS_PERMISSIONS, permissions=ACCESS_PERMISSIONS,
role_templates=ACCESS_ROLE_TEMPLATES, role_templates=ACCESS_ROLE_TEMPLATES,
@@ -653,6 +670,9 @@ manifest = ModuleManifest(
), ),
capability_factories={ capability_factories={
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER: _api_principal_provider, 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_PRINCIPAL_RESOLVER: _legacy_principal_resolver,
CAPABILITY_AUTH_PERMISSION_EVALUATOR: _legacy_permission_evaluator, CAPABILITY_AUTH_PERMISSION_EVALUATOR: _legacy_permission_evaluator,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: _tenant_context_switcher, CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: _tenant_context_switcher,

View File

@@ -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()