203 lines
6.0 KiB
Python
203 lines
6.0 KiB
Python
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()
|