feat: manage bounded automation service accounts

This commit is contained in:
2026-07-29 17:48:35 +02:00
parent 984a015704
commit 6b0dd8beab
10 changed files with 1633 additions and 113 deletions

View File

@@ -10,7 +10,11 @@ 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.db.models import (
Account,
ServiceAccount,
User,
)
from govoplan_access.backend.manifest import manifest
from govoplan_access.backend.security.sessions import (
UserAuthorizationContext,
@@ -70,6 +74,19 @@ class AutomationPrincipalTests(unittest.TestCase):
"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:
@@ -106,14 +123,35 @@ class AutomationPrincipalTests(unittest.TestCase):
),
result.principal.scopes,
)
self.assertIsNone(
result.principal.principal.service_account_id
)
self.assertEqual(
"dataflow-trigger:1",
result.principal.principal.service_account_id,
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(
@@ -151,6 +189,101 @@ class AutomationPrincipalTests(unittest.TestCase):
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,

View File

@@ -0,0 +1,202 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db.models import (
Account,
ApiKey,
AuthSession,
User,
)
from govoplan_access.backend.service_accounts import (
ServiceAccountConflictError,
create_service_account,
retire_service_account,
update_service_account,
)
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.tenancy.scope import (
Tenant,
create_scope_tables,
scope_registry,
)
class ServiceAccountTests(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.tenant = Tenant(
id="tenant-1",
slug="tenant-1",
name="Tenant 1",
)
self.account = Account(
id="account-1",
email="admin@example.test",
normalized_email="admin@example.test",
)
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()
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 _principal(
self,
scopes: frozenset[str] = frozenset({"tenant:*"}),
) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id=self.account.id,
membership_id=self.user.id,
tenant_id=self.tenant.id,
scopes=scopes,
),
account=self.account,
user=self.user,
)
def test_create_builds_a_non_login_identity_without_secrets(self) -> None:
item = create_service_account(
self.session,
tenant=self.tenant,
principal=self._principal(),
name=" Monthly import ",
description="Runs the governed monthly import.",
scope_ceiling=(
"dataflow:pipeline:run",
"datasources:catalogue:read",
),
)
backing_account = self.session.get(
Account,
item.account_id,
)
membership = self.session.get(
User,
item.membership_id,
)
self.assertEqual("Monthly import", item.name)
self.assertEqual(
[
"dataflow:pipeline:run",
"datasources:catalogue:read",
],
item.scope_ceiling,
)
self.assertEqual(
"service_account",
backing_account.auth_provider,
)
self.assertIsNone(backing_account.password_hash)
self.assertEqual(
"service_account",
membership.auth_provider,
)
self.assertIsNone(membership.password_hash)
self.assertEqual(
0,
self.session.query(ApiKey)
.filter(ApiKey.user_id == membership.id)
.count(),
)
self.assertEqual(
0,
self.session.query(AuthSession)
.filter(AuthSession.user_id == membership.id)
.count(),
)
def test_scope_escalation_and_stale_updates_fail_closed(self) -> None:
principal = self._principal(
frozenset({"dataflow:pipeline:run"})
)
with self.assertRaises(PermissionError):
create_service_account(
self.session,
tenant=self.tenant,
principal=principal,
name="Escalating worker",
description=None,
scope_ceiling=("system:settings:write",),
)
item = create_service_account(
self.session,
tenant=self.tenant,
principal=principal,
name="Bounded worker",
description=None,
scope_ceiling=("dataflow:pipeline:run",),
)
updated = update_service_account(
self.session,
tenant_id=self.tenant.id,
service_account_id=item.id,
principal=principal,
expected_revision=1,
changes={"description": "Updated"},
)
self.assertEqual(2, updated.revision)
with self.assertRaises(ServiceAccountConflictError):
update_service_account(
self.session,
tenant_id=self.tenant.id,
service_account_id=item.id,
principal=principal,
expected_revision=1,
changes={"description": "Stale"},
)
def test_retirement_revokes_the_backing_principal(self) -> None:
principal = self._principal()
item = create_service_account(
self.session,
tenant=self.tenant,
principal=principal,
name="Retired worker",
description=None,
scope_ceiling=("dataflow:pipeline:run",),
)
retired = retire_service_account(
self.session,
tenant_id=self.tenant.id,
service_account_id=item.id,
principal=principal,
expected_revision=1,
)
self.assertFalse(retired.is_active)
self.assertIsNotNone(retired.retired_at)
self.assertFalse(
self.session.get(Account, retired.account_id).is_active
)
self.assertFalse(
self.session.get(User, retired.membership_id).is_active
)
if __name__ == "__main__":
unittest.main()