Resolve current principals for automation
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
162
tests/test_automation_principal.py
Normal file
162
tests/test_automation_principal.py
Normal 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()
|
||||||
Reference in New Issue
Block a user