feat: manage bounded automation service accounts
This commit is contained in:
202
tests/test_service_accounts.py
Normal file
202
tests/test_service_accounts.py
Normal 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()
|
||||
Reference in New Issue
Block a user