Harden module update compatibility cleanup
Some checks failed
Dependency Audit / dependency-audit (push) Successful in 1m46s
Module Matrix / module-matrix (push) Failing after 2m38s
Release Integration / release-integration (push) Failing after 1m41s

This commit is contained in:
2026-07-10 23:27:48 +02:00
parent 2b0cdf13f3
commit b788afcae1
16 changed files with 1442 additions and 886 deletions

View File

@@ -76,7 +76,8 @@ from govoplan_core.core.campaigns import (
CampaignRetentionProvider,
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory as CoreOrganizationDirectory, OrganizationFunctionRef as CoreOrganizationFunctionRef
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory as CoreOrganizationDirectory, OrganizationFunctionRef as CoreOrganizationFunctionRef, OrganizationUnitRef as CoreOrganizationUnitRef
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import clear_runtime, configure_runtime
from govoplan_core.db.base import Base
@@ -85,6 +86,7 @@ from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.tenancy.scope import create_scope_tables
from govoplan_access.backend.db.models import (
Account,
ExternalFunctionRoleAssignment,
Function,
FunctionAssignment,
FunctionRoleAssignment,
@@ -1031,6 +1033,207 @@ class AccessContractTests(unittest.TestCase):
clear_runtime()
shutil.rmtree(root, ignore_errors=True)
def test_access_explanation_includes_idm_function_role_sources(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-access-explanation-"))
try:
tenant_id = "tenant-access-explanation"
account_id = "account-access-explanation"
user_id = "user-access-explanation"
role_id = "role-access-explanation"
function_id = "function-access-explanation"
organization_unit_id = "unit-access-explanation"
assignment_id = "assignment-access-explanation"
identity_id = "identity-access-explanation"
assignment = OrganizationFunctionAssignmentRef(
id=assignment_id,
tenant_id=tenant_id,
identity_id=identity_id,
account_id=account_id,
function_id=function_id,
organization_unit_id=organization_unit_id,
applies_to_subunits=True,
source="direct",
)
class FakeIdmDirectory:
def get_organization_function_assignment(self, requested_assignment_id: str):
return assignment if requested_assignment_id == assignment_id else None
def organization_function_assignments_for_identity(self, requested_identity_id: str, *, tenant_id: str | None = None):
if requested_identity_id == identity_id and tenant_id in (None, assignment.tenant_id):
return (assignment,)
return ()
def organization_function_assignments_for_account(self, requested_account_id: str, *, tenant_id: str | None = None):
if requested_account_id == account_id and tenant_id in (None, assignment.tenant_id):
return (assignment,)
return ()
class FakeOrganizationDirectory(CoreOrganizationDirectory):
def get_organization_unit(self, requested_organization_unit_id: str):
if requested_organization_unit_id != organization_unit_id:
return None
return CoreOrganizationUnitRef(
id=organization_unit_id,
tenant_id=tenant_id,
slug="registry",
name="Registry Office",
)
def organization_units_for_tenant(self, requested_tenant_id: str):
if requested_tenant_id != tenant_id:
return ()
unit = self.get_organization_unit(organization_unit_id)
return (unit,) if unit is not None else ()
def get_function(self, requested_function_id: str):
if requested_function_id != function_id:
return None
return CoreOrganizationFunctionRef(
id=function_id,
tenant_id=tenant_id,
organization_unit_id=organization_unit_id,
slug="registry-clerk",
name="Registry Clerk",
)
def functions_for_organization_unit(self, requested_organization_unit_id: str, *, include_subunits: bool = False):
del include_subunits
if requested_organization_unit_id != organization_unit_id:
return ()
function = self.get_function(function_id)
return (function,) if function is not None else ()
idm_directory = FakeIdmDirectory()
organization_directory = FakeOrganizationDirectory()
with temporary_database(f"sqlite:///{root / 'test.db'}") as database:
create_scope_tables(database.engine)
Base.metadata.create_all(bind=database.engine)
with database.session() as session:
tenant = Tenant(id=tenant_id, slug="access-explanation", name="Access Explanation", settings={})
account = Account(
id=account_id,
email="explanation@example.test",
normalized_email="explanation@example.test",
display_name="Access Explanation User",
)
user = User(
id=user_id,
tenant_id=tenant_id,
account_id=account_id,
email=account.email,
display_name=account.display_name,
settings={},
mail_profile_policy={},
)
role = Role(
id=role_id,
tenant_id=tenant_id,
slug="file-reader",
name="File Reader",
permissions=["files:file:read"],
)
mapping = ExternalFunctionRoleAssignment(
tenant_id=tenant_id,
source_module="organizations",
function_id=function_id,
role_id=role_id,
)
session.add_all([tenant, account, user, role, mapping])
session.commit()
from govoplan_access.backend.explanation import SqlAccessExplanationService, build_user_access_explanation
explanation = build_user_access_explanation(
session,
user,
idm_directory=idm_directory,
organization_directory=organization_directory,
)
self.assertEqual(1, len(explanation.function_facts))
self.assertEqual("Registry Clerk", explanation.function_facts[0].function_name)
self.assertEqual((role_id,), explanation.function_facts[0].role_ids)
source = next(item for item in explanation.role_sources if item.source_type == "idm_function_role")
self.assertEqual("Registry Clerk", source.function_name)
self.assertEqual("File Reader", source.role_name)
scope = next(item for item in explanation.scopes if item.scope == "files:file:read")
self.assertEqual(["idm_function_role"], [item.source_type for item in scope.sources])
service = SqlAccessExplanationService(
idm_directory=idm_directory,
organization_directory=organization_directory,
)
provenance = service.explain_scope_provenance(
PrincipalRef(
account_id=account_id,
membership_id=user_id,
tenant_id=tenant_id,
identity_id=identity_id,
scopes=frozenset({"files:file:read"}),
),
"files:file:read",
)
self.assertTrue(any(item.kind == "function" and item.label == "Registry Clerk" for item in provenance))
self.assertTrue(any(item.kind == "role" and item.label == "File Reader" for item in provenance))
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="idm",
name="IDM",
version="test",
capability_factories={CAPABILITY_IDM_DIRECTORY: lambda context: idm_directory},
)
)
registry.register(
ModuleManifest(
id="organizations",
name="Organizations",
version="test",
capability_factories={CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: organization_directory},
)
)
context = ModuleContext(registry=registry, settings=object())
registry.configure_capability_context(context)
configure_runtime(context)
from govoplan_access.backend.api.v1.routes import router as admin_router
app = FastAPI()
app.include_router(admin_router)
def principal_override() -> ApiPrincipal:
with database.session() as session:
account = session.get(Account, account_id)
user = session.get(User, user_id)
assert account is not None
assert user is not None
return ApiPrincipal(
principal=PrincipalRef(
account_id=account_id,
membership_id=user_id,
tenant_id=tenant_id,
scopes=frozenset({"admin:users:read"}),
),
account=account,
user=user,
)
app.dependency_overrides[get_api_principal] = principal_override
with TestClient(app) as client:
response = client.get(f"/admin/users/{user_id}/access-explanation")
self.assertEqual(200, response.status_code, response.text)
payload = response.json()
self.assertIn("files:file:read", payload["user"]["effective_scopes"])
self.assertEqual("idm_function_role", payload["role_sources"][0]["source_type"])
self.assertEqual("Registry Clerk", payload["function_facts"][0]["function_name"])
finally:
clear_runtime()
shutil.rmtree(root, ignore_errors=True)
def test_api_principal_dependency_uses_access_resolver_capability(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-"))
try: