diff --git a/src/govoplan_idm/backend/api/v1/routes.py b/src/govoplan_idm/backend/api/v1/routes.py index e52daa2..73dac9f 100644 --- a/src/govoplan_idm/backend/api/v1/routes.py +++ b/src/govoplan_idm/backend/api/v1/routes.py @@ -17,6 +17,13 @@ from govoplan_core.core.configuration_control import ( ensure_configuration_change_allowed, record_configuration_change_applied, ) +from govoplan_core.core.events import ( + EventActorRef, + EventObjectRef, + EventTenantRef, + PlatformEvent, + emit_platform_event, +) from govoplan_core.core.identity import ( CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_SEARCH, @@ -31,6 +38,7 @@ from govoplan_core.core.organizations import ( ) from govoplan_core.core.runtime import get_registry from govoplan_core.db.session import get_session +from govoplan_core.security.time import utc_now from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings from .schemas import ( @@ -413,6 +421,55 @@ def _record_assignment_audit( ) +def _publish_assignment_event( + session: Session, + principal: ApiPrincipal, + item: OrganizationFunctionAssignmentItem, + *, + event_type: str, +) -> None: + emit_platform_event( + session, + PlatformEvent( + type=event_type, + module_id="idm", + payload={ + "identity_id": item.identity_id, + "account_id": item.account_id, + "function_id": item.function_id, + "organization_unit_id": item.organization_unit_id, + "source": item.source, + "delegated_from_assignment_id": ( + item.delegated_from_assignment_id + ), + "acting_for_account_id": item.acting_for_account_id, + "valid_from": ( + item.valid_from.isoformat() + if item.valid_from is not None + else None + ), + "valid_until": ( + item.valid_until.isoformat() + if item.valid_until is not None + else None + ), + "is_active": item.is_active, + }, + actor=EventActorRef(type="account", id=principal.account_id), + tenant=EventTenantRef(id=principal.tenant_id), + subject=EventObjectRef( + type="organization_function", + id=item.function_id, + ), + resource=EventObjectRef( + type="organization_function_assignment", + id=item.id, + ), + classification="internal", + ) + ) + + @router.get("/settings", response_model=IdmSettingsItem) def get_idm_settings( session: Session = Depends(get_session), @@ -467,17 +524,34 @@ def update_idm_settings( @router.get("/organization-function-assignments", response_model=OrganizationFunctionAssignmentList) def list_organization_function_assignments( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*IDM_ASSIGNMENT_READ_SCOPES)), ) -> OrganizationFunctionAssignmentList: tenant_id = _tenant_id(principal) - assignments = ( + query = ( session.query(IdmOrganizationFunctionAssignment) .filter(IdmOrganizationFunctionAssignment.tenant_id == tenant_id) - .order_by(IdmOrganizationFunctionAssignment.created_at.asc()) + ) + total = query.order_by(None).count() + pages = max(1, (total + page_size - 1) // page_size) + assignments = ( + query.order_by( + IdmOrganizationFunctionAssignment.created_at.asc(), + IdmOrganizationFunctionAssignment.id.asc(), + ) + .offset((page - 1) * page_size) + .limit(page_size) .all() ) - return OrganizationFunctionAssignmentList(assignments=[_assignment_item(item) for item in assignments]) + return OrganizationFunctionAssignmentList( + assignments=[_assignment_item(item) for item in assignments], + total=total, + page=page, + page_size=page_size, + pages=pages, + ) @router.post("/organization-function-assignments", response_model=OrganizationFunctionAssignmentItem, status_code=status.HTTP_201_CREATED) @@ -523,6 +597,13 @@ def create_organization_function_assignment( before=None, after=after, ) + _publish_assignment_event( + session, + principal, + result, + event_type="idm.function_assignment.created.v1", + ) + session.commit() return result @@ -586,6 +667,36 @@ def update_organization_function_assignment( before=before, after=after, ) + _publish_assignment_event( + session, + principal, + result, + event_type="idm.function_assignment.changed.v1", + ) + before_values = before if isinstance(before, dict) else {} + if before_values.get("is_active") is True and result.is_active is False: + _publish_assignment_event( + session, + principal, + result, + event_type="idm.function_assignment.revoked.v1", + ) + previous_valid_until = before_values.get("valid_until") + if ( + result.valid_until is not None + and result.valid_until <= utc_now() + and ( + previous_valid_until is None + or str(previous_valid_until) != result.valid_until.isoformat() + ) + ): + _publish_assignment_event( + session, + principal, + result, + event_type="idm.function_assignment.expired.v1", + ) + session.commit() return result diff --git a/src/govoplan_idm/backend/api/v1/schemas.py b/src/govoplan_idm/backend/api/v1/schemas.py index 3db5df0..e6775d2 100644 --- a/src/govoplan_idm/backend/api/v1/schemas.py +++ b/src/govoplan_idm/backend/api/v1/schemas.py @@ -45,6 +45,10 @@ class OrganizationFunctionAssignmentItem(BaseModel): class OrganizationFunctionAssignmentList(BaseModel): assignments: list[OrganizationFunctionAssignmentItem] + total: int = 0 + page: int = 1 + page_size: int = 500 + pages: int = 1 class OrganizationFunctionAssignmentCreateRequest(BaseModel): diff --git a/src/govoplan_idm/backend/directory.py b/src/govoplan_idm/backend/directory.py index 5b8146f..040b96f 100644 --- a/src/govoplan_idm/backend/directory.py +++ b/src/govoplan_idm/backend/directory.py @@ -1,9 +1,16 @@ from __future__ import annotations +from collections.abc import Sequence +from datetime import datetime + from sqlalchemy import or_ from govoplan_core.core.identity import IdentityDirectory -from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef +from govoplan_core.core.idm import ( + IdmDirectory, + OrganizationFunctionAssignmentRef, + OrganizationFunctionIncumbencyRef, +) from govoplan_core.core.organizations import OrganizationDirectory from govoplan_core.db.session import get_database from govoplan_core.security.time import utc_now @@ -47,83 +54,263 @@ class SqlIdmDirectory(IdmDirectory): identity_id: str, *, tenant_id: str | None = None, + effective_at: datetime | None = None, ) -> tuple[OrganizationFunctionAssignmentRef, ...]: - return self._assignments_for_identity_ids(identity_ids=(identity_id,), tenant_id=tenant_id) + return tuple( + self.organization_function_assignments_for_identities( + (identity_id,), + tenant_id=tenant_id, + effective_at=effective_at, + ).get(identity_id, ()) + ) def organization_function_assignments_for_account( self, account_id: str, *, tenant_id: str | None = None, + effective_at: datetime | None = None, ) -> tuple[OrganizationFunctionAssignmentRef, ...]: - identity_ids = [identity.id for identity in self._identities.identities_for_accounts((account_id,))] - if not identity_ids: - return () - return self._assignments_for_identity_ids(identity_ids=tuple(identity_ids), tenant_id=tenant_id, account_id=account_id) + return tuple( + self.organization_function_assignments_for_accounts( + (account_id,), + tenant_id=tenant_id, + effective_at=effective_at, + ).get(account_id, ()) + ) - def _assignments_for_identity_ids( + def organization_function_assignments_for_identities( self, + identity_ids: Sequence[str], *, - identity_ids: tuple[str, ...], tenant_id: str | None = None, - account_id: str | None = None, - ) -> tuple[OrganizationFunctionAssignmentRef, ...]: - if not identity_ids: - return () - now = utc_now() + effective_at: datetime | None = None, + ) -> dict[str, tuple[OrganizationFunctionAssignmentRef, ...]]: + requested = tuple(dict.fromkeys(identity_ids)) + result: dict[str, list[OrganizationFunctionAssignmentRef]] = { + identity_id: [] for identity_id in requested + } + if not requested: + return {} with get_database().session() as session: - query = ( - session.query(IdmOrganizationFunctionAssignment) - .filter( - IdmOrganizationFunctionAssignment.identity_id.in_(identity_ids), - IdmOrganizationFunctionAssignment.is_active.is_(True), - or_(IdmOrganizationFunctionAssignment.valid_from.is_(None), IdmOrganizationFunctionAssignment.valid_from <= now), - or_(IdmOrganizationFunctionAssignment.valid_until.is_(None), IdmOrganizationFunctionAssignment.valid_until > now), - ) - .order_by(IdmOrganizationFunctionAssignment.created_at.asc()) + items = self._effective_assignment_items( + session, + tenant_id=tenant_id, + effective_at=effective_at or utc_now(), + identity_ids=requested, ) - if account_id is not None: - query = query.filter(or_(IdmOrganizationFunctionAssignment.account_id.is_(None), IdmOrganizationFunctionAssignment.account_id == account_id)) - if tenant_id is not None: - query = query.filter(IdmOrganizationFunctionAssignment.tenant_id == tenant_id) - items = query.all() - source_ids = { - item.delegated_from_assignment_id - for item in items - if item.source in {"delegated", "acting_for"} - and item.delegated_from_assignment_id is not None + for item in items: + result[item.identity_id].append(_assignment_ref(item)) + return { + identity_id: tuple(assignments) + for identity_id, assignments in result.items() + } + + def organization_function_assignments_for_accounts( + self, + account_ids: Sequence[str], + *, + tenant_id: str | None = None, + effective_at: datetime | None = None, + ) -> dict[str, tuple[OrganizationFunctionAssignmentRef, ...]]: + requested = tuple(dict.fromkeys(account_ids)) + result: dict[str, list[OrganizationFunctionAssignmentRef]] = { + account_id: [] for account_id in requested + } + if not requested: + return {} + identities = self._identities.identities_for_accounts(requested) + identity_ids_by_account: dict[str, set[str]] = { + account_id: set() for account_id in requested + } + requested_set = set(requested) + for identity in identities: + linked_accounts = set(identity.account_ids) + if identity.primary_account_id: + linked_accounts.add(identity.primary_account_id) + for account_id in linked_accounts & requested_set: + identity_ids_by_account[account_id].add(identity.id) + identity_ids = tuple( + dict.fromkeys( + identity_id + for values in identity_ids_by_account.values() + for identity_id in values + ) + ) + if not identity_ids: + return { + account_id: tuple(assignments) + for account_id, assignments in result.items() } - effective_sources = ( - session.query(IdmOrganizationFunctionAssignment) - .filter( - IdmOrganizationFunctionAssignment.id.in_(source_ids), - IdmOrganizationFunctionAssignment.is_active.is_(True), - or_( - IdmOrganizationFunctionAssignment.valid_from.is_(None), - IdmOrganizationFunctionAssignment.valid_from <= now, - ), - or_( - IdmOrganizationFunctionAssignment.valid_until.is_(None), - IdmOrganizationFunctionAssignment.valid_until > now, - ), - ) - .all() - if source_ids - else () + with get_database().session() as session: + items = self._effective_assignment_items( + session, + tenant_id=tenant_id, + effective_at=effective_at or utc_now(), + identity_ids=identity_ids, ) - effective_sources_by_id = {item.id: item for item in effective_sources} - active_items: list[IdmOrganizationFunctionAssignment] = [] - for item in items: - if item.source in {"delegated", "acting_for"}: - source = effective_sources_by_id.get(item.delegated_from_assignment_id or "") - if ( - source is None - or source.tenant_id != item.tenant_id - or source.function_id != item.function_id - ): - continue - function = self._organizations.get_function(item.function_id) - if function is None or function.status != "active" or function.tenant_id != item.tenant_id: + for account_id, linked_identity_ids in identity_ids_by_account.items(): + result[account_id].extend( + _assignment_ref(item) + for item in items + if item.identity_id in linked_identity_ids + and item.account_id in {None, account_id} + ) + return { + account_id: tuple(assignments) + for account_id, assignments in result.items() + } + + def organization_function_assignments_for_function( + self, + function_id: str, + *, + tenant_id: str | None = None, + effective_at: datetime | None = None, + ) -> tuple[OrganizationFunctionAssignmentRef, ...]: + function = self._organizations.get_function(function_id) + if function is None: + return () + if tenant_id is not None and function.tenant_id != tenant_id: + raise ValueError("Organization function belongs to another tenant.") + resolved_tenant_id = tenant_id or function.tenant_id + return self.organization_function_incumbencies( + (function_id,), + tenant_id=resolved_tenant_id, + effective_at=effective_at, + )[function_id].assignments + + def organization_function_incumbencies( + self, + function_ids: Sequence[str], + *, + tenant_id: str, + effective_at: datetime | None = None, + ) -> dict[str, OrganizationFunctionIncumbencyRef]: + requested = tuple(dict.fromkeys(function_ids)) + functions = {} + for function_id in requested: + function = self._organizations.get_function(function_id) + if function is None: + raise ValueError( + f"Organization function does not exist: {function_id}" + ) + if function.tenant_id != tenant_id: + raise ValueError( + "Organization function belongs to another tenant." + ) + functions[function_id] = function + if not requested: + return {} + with get_database().session() as session: + items = self._effective_assignment_items( + session, + tenant_id=tenant_id, + effective_at=effective_at or utc_now(), + function_ids=requested, + ) + assignments: dict[str, list[OrganizationFunctionAssignmentRef]] = { + function_id: [] for function_id in requested + } + for item in items: + assignments[item.function_id].append(_assignment_ref(item)) + return { + function_id: OrganizationFunctionIncumbencyRef( + tenant_id=tenant_id, + function_id=function_id, + assignments=tuple(assignments[function_id]), + function_active=functions[function_id].status == "active", + ) + for function_id in requested + } + + def _effective_assignment_items( + self, + session, + *, + effective_at: datetime, + tenant_id: str | None = None, + identity_ids: Sequence[str] = (), + function_ids: Sequence[str] = (), + ) -> tuple[IdmOrganizationFunctionAssignment, ...]: + query = ( + session.query(IdmOrganizationFunctionAssignment) + .filter( + IdmOrganizationFunctionAssignment.is_active.is_(True), + or_( + IdmOrganizationFunctionAssignment.valid_from.is_(None), + IdmOrganizationFunctionAssignment.valid_from <= effective_at, + ), + or_( + IdmOrganizationFunctionAssignment.valid_until.is_(None), + IdmOrganizationFunctionAssignment.valid_until > effective_at, + ), + ) + .order_by(IdmOrganizationFunctionAssignment.created_at.asc()) + ) + if identity_ids: + query = query.filter( + IdmOrganizationFunctionAssignment.identity_id.in_(identity_ids), + ) + if function_ids: + query = query.filter( + IdmOrganizationFunctionAssignment.function_id.in_(function_ids), + ) + if tenant_id is not None: + query = query.filter( + IdmOrganizationFunctionAssignment.tenant_id == tenant_id + ) + items = query.all() + source_ids = { + item.delegated_from_assignment_id + for item in items + if item.source in {"delegated", "acting_for"} + and item.delegated_from_assignment_id is not None + } + effective_sources = ( + session.query(IdmOrganizationFunctionAssignment) + .filter( + IdmOrganizationFunctionAssignment.id.in_(source_ids), + IdmOrganizationFunctionAssignment.is_active.is_(True), + or_( + IdmOrganizationFunctionAssignment.valid_from.is_(None), + IdmOrganizationFunctionAssignment.valid_from <= effective_at, + ), + or_( + IdmOrganizationFunctionAssignment.valid_until.is_(None), + IdmOrganizationFunctionAssignment.valid_until > effective_at, + ), + ) + .all() + if source_ids + else () + ) + effective_sources_by_id = { + item.id: item for item in effective_sources + } + function_cache = {} + active_items: list[IdmOrganizationFunctionAssignment] = [] + for item in items: + if item.source in {"delegated", "acting_for"}: + source = effective_sources_by_id.get( + item.delegated_from_assignment_id or "" + ) + if ( + source is None + or source.tenant_id != item.tenant_id + or source.function_id != item.function_id + ): continue - active_items.append(item) - return tuple(_assignment_ref(item) for item in active_items) + if item.function_id not in function_cache: + function_cache[item.function_id] = ( + self._organizations.get_function(item.function_id) + ) + function = function_cache[item.function_id] + if ( + function is None + or function.status != "active" + or function.tenant_id != item.tenant_id + ): + continue + active_items.append(item) + return tuple(active_items) diff --git a/src/govoplan_idm/backend/manifest.py b/src/govoplan_idm/backend/manifest.py index 416ed6a..aca0e27 100644 --- a/src/govoplan_idm/backend/manifest.py +++ b/src/govoplan_idm/backend/manifest.py @@ -4,7 +4,10 @@ from pathlib import Path from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_SEARCH, IdentityDirectory -from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY +from govoplan_core.core.idm import ( + CAPABILITY_IDM_DIRECTORY, + CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, +) from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory from govoplan_core.core.views import ViewSurface from govoplan_core.core.module_guards import persistent_table_uninstall_guard @@ -14,6 +17,7 @@ from govoplan_core.core.modules import ( FrontendRoute, MigrationSpec, ModuleContext, + ModuleInterfaceProvider, ModuleManifest, NavItem, PermissionDefinition, @@ -120,6 +124,12 @@ manifest = ModuleManifest( CAPABILITY_IDENTITY_SEARCH, CAPABILITY_ORGANIZATION_DIRECTORY, ), + provides_interfaces=( + ModuleInterfaceProvider( + name=CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, + version="0.1.8", + ), + ), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, route_factory=_route_factory, @@ -153,6 +163,7 @@ manifest = ModuleManifest( ), capability_factories={ CAPABILITY_IDM_DIRECTORY: _idm_directory, + CAPABILITY_IDM_FUNCTION_ASSIGNMENTS: _idm_directory, }, documentation=( DocumentationTopic( diff --git a/tests/test_directory.py b/tests/test_directory.py index f3927dd..84e2abb 100644 --- a/tests/test_directory.py +++ b/tests/test_directory.py @@ -133,6 +133,100 @@ class IdmDirectoryDelegationTests(unittest.TestCase): ) self.assertEqual(expected_ids, tuple(item.id for item in assignments)) + def test_reverse_lookup_returns_only_effective_function_assignments(self) -> None: + now = datetime.now(timezone.utc) + active = IdmOrganizationFunctionAssignment( + id="active-function-holder", + tenant_id="tenant-1", + identity_id="identity-active", + account_id="account-active", + function_id="function-1", + organization_unit_id="unit-1", + source="direct", + is_active=True, + settings={}, + ) + expired = IdmOrganizationFunctionAssignment( + id="expired-function-holder", + tenant_id="tenant-1", + identity_id="identity-expired", + function_id="function-1", + organization_unit_id="unit-1", + source="direct", + is_active=True, + valid_until=now - timedelta(minutes=1), + settings={}, + ) + other_tenant = IdmOrganizationFunctionAssignment( + id="other-tenant-holder", + tenant_id="tenant-2", + identity_id="identity-other", + function_id="function-1", + organization_unit_id="unit-1", + source="direct", + is_active=True, + settings={}, + ) + with self.database.session() as session: + session.add_all((active, expired, other_tenant)) + session.commit() + + assignments = self.directory.organization_function_assignments_for_function( + "function-1", + tenant_id="tenant-1", + ) + + self.assertEqual( + ("active-function-holder",), + tuple(item.id for item in assignments), + ) + + def test_batch_incumbency_reports_vacancy_and_honors_effective_time( + self, + ) -> None: + boundary = datetime.now(timezone.utc) + assignment = IdmOrganizationFunctionAssignment( + id="bounded-holder", + tenant_id="tenant-1", + identity_id="identity-bounded", + account_id="account-bounded", + function_id="function-1", + organization_unit_id="unit-1", + source="direct", + is_active=True, + valid_from=boundary - timedelta(hours=1), + valid_until=boundary + timedelta(hours=1), + settings={}, + ) + with self.database.session() as session: + session.add(assignment) + session.commit() + + current = self.directory.organization_function_incumbencies( + ("function-1", "function-2"), + tenant_id="tenant-1", + effective_at=boundary, + ) + later = self.directory.organization_function_incumbencies( + ("function-1",), + tenant_id="tenant-1", + effective_at=boundary + timedelta(hours=2), + ) + + self.assertEqual( + ("bounded-holder",), + tuple(item.id for item in current["function-1"].assignments), + ) + self.assertFalse(current["function-1"].vacant) + self.assertTrue(current["function-2"].vacant) + self.assertTrue(later["function-1"].vacant) + + with self.assertRaisesRegex(ValueError, "another tenant"): + self.directory.organization_function_incumbencies( + ("function-1",), + tenant_id="tenant-2", + ) + if __name__ == "__main__": unittest.main() diff --git a/webui/package.json b/webui/package.json index ec39993..434dee0 100644 --- a/webui/package.json +++ b/webui/package.json @@ -19,7 +19,7 @@ "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.1", + "react-router-dom": ">=7.18.2 <8", "typescript": "^5.7.2", "vite": "^6.0.6" }, diff --git a/webui/src/api/idm.ts b/webui/src/api/idm.ts index 47bb75f..f673915 100644 --- a/webui/src/api/idm.ts +++ b/webui/src/api/idm.ts @@ -70,6 +70,10 @@ export type OrganizationFunctionAssignmentItem = { export type OrganizationFunctionAssignmentList = { assignments: OrganizationFunctionAssignmentItem[]; + total?: number; + page?: number; + page_size?: number; + pages?: number; }; export type IdmSettings = { @@ -111,8 +115,27 @@ export function getOrganizationModel(settings: ApiSettings): Promise(settings, "/api/v1/organizations/model"); } -export function getOrganizationFunctionAssignments(settings: ApiSettings): Promise { - return apiFetch(settings, "/api/v1/idm/organization-function-assignments"); +export async function getOrganizationFunctionAssignments(settings: ApiSettings): Promise { + const pageSize = 500; + const assignments: OrganizationFunctionAssignmentItem[] = []; + let total = 0; + for (let page = 1; ; page += 1) { + const response = await apiFetch( + settings, + `/api/v1/idm/organization-function-assignments?page=${page}&page_size=${pageSize}` + ); + assignments.push(...response.assignments); + total = response.total ?? assignments.length; + if (page >= (response.pages ?? 1)) { + return { + assignments, + total, + page: 1, + page_size: assignments.length, + pages: 1 + }; + } + } } export function getIdmSettings(settings: ApiSettings): Promise {