feat: add canonical identity administration

This commit is contained in:
2026-08-20 12:27:36 +02:00
parent 43f0128e6a
commit 34357fe0ec
13 changed files with 1813 additions and 28 deletions
+156
View File
@@ -0,0 +1,156 @@
from __future__ import annotations
from types import SimpleNamespace
import shutil
import tempfile
import unittest
from unittest.mock import patch
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.db.base import Base
from govoplan_core.db.session import configure_database, reset_database
from govoplan_identity.backend.api.v1.routes import router
class IdentityAdminApiTests(unittest.TestCase):
def setUp(self) -> None:
self.root = Path(tempfile.mkdtemp(prefix="govoplan-identity-admin-api-"))
self.database = configure_database(f"sqlite:///{self.root / 'identity.db'}")
Base.metadata.create_all(self.database.engine)
app = FastAPI()
app.include_router(router, prefix="/api/v1")
app.dependency_overrides[get_api_principal] = self._principal
self.client = TestClient(app)
self.audit_patches = (
patch("govoplan_identity.backend.api.v1.routes.audit_from_principal"),
patch("govoplan_identity.backend.lifecycle.audit_event"),
)
self.route_audit = self.audit_patches[0].start()
self.lifecycle_audit = self.audit_patches[1].start()
def tearDown(self) -> None:
self.client.close()
for item in reversed(self.audit_patches):
item.stop()
reset_database(dispose=True)
shutil.rmtree(self.root, ignore_errors=True)
def test_primary_lifecycle_and_system_scope_are_enforced(self) -> None:
created = self.client.post(
"/api/v1/identity/identities",
json={
"display_name": "Ada Example",
"external_subject": "subject-ada",
"source": "local",
},
)
self.assertEqual(201, created.status_code, created.text)
identity = created.json()
self.assertEqual("system", identity["management_scope"])
identity_id = identity["id"]
first = self.client.post(
f"/api/v1/identity/identities/{identity_id}/account-links",
json={"account_id": "account-1", "source": "local"},
)
self.assertEqual(201, first.status_code, first.text)
self.assertEqual("account-1", first.json()["primary_account_id"])
second = self.client.post(
f"/api/v1/identity/identities/{identity_id}/account-links",
json={"account_id": "account-2", "source": "idm:accepted"},
)
self.assertEqual(201, second.status_code, second.text)
links = second.json()["account_links"]
first_link = next(item for item in links if item["account_id"] == "account-1")
second_link = next(item for item in links if item["account_id"] == "account-2")
blocked = self.client.delete(
f"/api/v1/identity/identities/{identity_id}/account-links/{first_link['id']}"
)
self.assertEqual(409, blocked.status_code, blocked.text)
promoted = self.client.patch(
f"/api/v1/identity/identities/{identity_id}/account-links/{second_link['id']}",
json={"is_primary": True, "reason": "Preferred institutional account"},
)
self.assertEqual(200, promoted.status_code, promoted.text)
self.assertEqual("account-2", promoted.json()["primary_account_id"])
removed = self.client.delete(
f"/api/v1/identity/identities/{identity_id}/account-links/{first_link['id']}"
)
self.assertEqual(204, removed.status_code, removed.text)
deactivated = self.client.post(
f"/api/v1/identity/identities/{identity_id}/deactivate",
json={"reason": "Duplicate subject under review"},
)
self.assertEqual(200, deactivated.status_code, deactivated.text)
self.assertEqual("inactive", deactivated.json()["status"])
default_list = self.client.get("/api/v1/identity/identities")
self.assertEqual([], default_list.json()["identities"])
inclusive_list = self.client.get(
"/api/v1/identity/identities",
params={"include_inactive": "true"},
)
self.assertEqual(identity_id, inclusive_list.json()["identities"][0]["id"])
self.assertEqual("system", inclusive_list.json()["management_scope"])
self.assertEqual("tenant-1", inclusive_list.json()["tenant_context_id"])
self.assertTrue(
all(call.kwargs["scope"] == "system" for call in self.route_audit.call_args_list)
)
self.assertTrue(
all(call.kwargs["scope"] == "system" for call in self.lifecycle_audit.call_args_list)
)
def test_account_can_only_be_linked_to_one_identity(self) -> None:
identity_ids = []
for name in ("Ada", "Grace"):
response = self.client.post(
"/api/v1/identity/identities",
json={"display_name": name},
)
self.assertEqual(201, response.status_code, response.text)
identity_ids.append(response.json()["id"])
first = self.client.post(
f"/api/v1/identity/identities/{identity_ids[0]}/account-links",
json={"account_id": "account-shared"},
)
self.assertEqual(201, first.status_code, first.text)
conflict = self.client.post(
f"/api/v1/identity/identities/{identity_ids[1]}/account-links",
json={"account_id": "account-shared"},
)
self.assertEqual(409, conflict.status_code, conflict.text)
@staticmethod
def _principal() -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-admin",
membership_id="user-admin",
tenant_id="tenant-1",
scopes=frozenset(
{
"identity:identity:read",
"identity:identity:admin",
"identity:account_link:admin",
}
),
),
account=SimpleNamespace(id="account-admin"),
user=SimpleNamespace(id="user-admin"),
)
if __name__ == "__main__":
unittest.main()