feat: expose effective function incumbency contracts
This commit is contained in:
@@ -17,6 +17,13 @@ from govoplan_core.core.configuration_control import (
|
|||||||
ensure_configuration_change_allowed,
|
ensure_configuration_change_allowed,
|
||||||
record_configuration_change_applied,
|
record_configuration_change_applied,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.events import (
|
||||||
|
EventActorRef,
|
||||||
|
EventObjectRef,
|
||||||
|
EventTenantRef,
|
||||||
|
PlatformEvent,
|
||||||
|
emit_platform_event,
|
||||||
|
)
|
||||||
from govoplan_core.core.identity import (
|
from govoplan_core.core.identity import (
|
||||||
CAPABILITY_IDENTITY_DIRECTORY,
|
CAPABILITY_IDENTITY_DIRECTORY,
|
||||||
CAPABILITY_IDENTITY_SEARCH,
|
CAPABILITY_IDENTITY_SEARCH,
|
||||||
@@ -31,6 +38,7 @@ from govoplan_core.core.organizations import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.runtime import get_registry
|
from govoplan_core.core.runtime import get_registry
|
||||||
from govoplan_core.db.session import get_session
|
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 govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
|
||||||
|
|
||||||
from .schemas import (
|
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)
|
@router.get("/settings", response_model=IdmSettingsItem)
|
||||||
def get_idm_settings(
|
def get_idm_settings(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
@@ -467,17 +524,34 @@ def update_idm_settings(
|
|||||||
|
|
||||||
@router.get("/organization-function-assignments", response_model=OrganizationFunctionAssignmentList)
|
@router.get("/organization-function-assignments", response_model=OrganizationFunctionAssignmentList)
|
||||||
def list_organization_function_assignments(
|
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),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope(*IDM_ASSIGNMENT_READ_SCOPES)),
|
principal: ApiPrincipal = Depends(require_any_scope(*IDM_ASSIGNMENT_READ_SCOPES)),
|
||||||
) -> OrganizationFunctionAssignmentList:
|
) -> OrganizationFunctionAssignmentList:
|
||||||
tenant_id = _tenant_id(principal)
|
tenant_id = _tenant_id(principal)
|
||||||
assignments = (
|
query = (
|
||||||
session.query(IdmOrganizationFunctionAssignment)
|
session.query(IdmOrganizationFunctionAssignment)
|
||||||
.filter(IdmOrganizationFunctionAssignment.tenant_id == tenant_id)
|
.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()
|
.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)
|
@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,
|
before=None,
|
||||||
after=after,
|
after=after,
|
||||||
)
|
)
|
||||||
|
_publish_assignment_event(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
result,
|
||||||
|
event_type="idm.function_assignment.created.v1",
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -586,6 +667,36 @@ def update_organization_function_assignment(
|
|||||||
before=before,
|
before=before,
|
||||||
after=after,
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ class OrganizationFunctionAssignmentItem(BaseModel):
|
|||||||
|
|
||||||
class OrganizationFunctionAssignmentList(BaseModel):
|
class OrganizationFunctionAssignmentList(BaseModel):
|
||||||
assignments: list[OrganizationFunctionAssignmentItem]
|
assignments: list[OrganizationFunctionAssignmentItem]
|
||||||
|
total: int = 0
|
||||||
|
page: int = 1
|
||||||
|
page_size: int = 500
|
||||||
|
pages: int = 1
|
||||||
|
|
||||||
|
|
||||||
class OrganizationFunctionAssignmentCreateRequest(BaseModel):
|
class OrganizationFunctionAssignmentCreateRequest(BaseModel):
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
|
|
||||||
from govoplan_core.core.identity import IdentityDirectory
|
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.core.organizations import OrganizationDirectory
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
from govoplan_core.security.time import utc_now
|
from govoplan_core.security.time import utc_now
|
||||||
@@ -47,83 +54,263 @@ class SqlIdmDirectory(IdmDirectory):
|
|||||||
identity_id: str,
|
identity_id: str,
|
||||||
*,
|
*,
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
) -> 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(
|
def organization_function_assignments_for_account(
|
||||||
self,
|
self,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
*,
|
*,
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
||||||
identity_ids = [identity.id for identity in self._identities.identities_for_accounts((account_id,))]
|
return tuple(
|
||||||
if not identity_ids:
|
self.organization_function_assignments_for_accounts(
|
||||||
return ()
|
(account_id,),
|
||||||
return self._assignments_for_identity_ids(identity_ids=tuple(identity_ids), tenant_id=tenant_id, account_id=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,
|
self,
|
||||||
|
identity_ids: Sequence[str],
|
||||||
*,
|
*,
|
||||||
identity_ids: tuple[str, ...],
|
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
account_id: str | None = None,
|
effective_at: datetime | None = None,
|
||||||
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
) -> dict[str, tuple[OrganizationFunctionAssignmentRef, ...]]:
|
||||||
if not identity_ids:
|
requested = tuple(dict.fromkeys(identity_ids))
|
||||||
return ()
|
result: dict[str, list[OrganizationFunctionAssignmentRef]] = {
|
||||||
now = utc_now()
|
identity_id: [] for identity_id in requested
|
||||||
|
}
|
||||||
|
if not requested:
|
||||||
|
return {}
|
||||||
with get_database().session() as session:
|
with get_database().session() as session:
|
||||||
query = (
|
items = self._effective_assignment_items(
|
||||||
session.query(IdmOrganizationFunctionAssignment)
|
session,
|
||||||
.filter(
|
tenant_id=tenant_id,
|
||||||
IdmOrganizationFunctionAssignment.identity_id.in_(identity_ids),
|
effective_at=effective_at or utc_now(),
|
||||||
IdmOrganizationFunctionAssignment.is_active.is_(True),
|
identity_ids=requested,
|
||||||
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())
|
|
||||||
)
|
)
|
||||||
if account_id is not None:
|
for item in items:
|
||||||
query = query.filter(or_(IdmOrganizationFunctionAssignment.account_id.is_(None), IdmOrganizationFunctionAssignment.account_id == account_id))
|
result[item.identity_id].append(_assignment_ref(item))
|
||||||
if tenant_id is not None:
|
return {
|
||||||
query = query.filter(IdmOrganizationFunctionAssignment.tenant_id == tenant_id)
|
identity_id: tuple(assignments)
|
||||||
items = query.all()
|
for identity_id, assignments in result.items()
|
||||||
source_ids = {
|
}
|
||||||
item.delegated_from_assignment_id
|
|
||||||
for item in items
|
def organization_function_assignments_for_accounts(
|
||||||
if item.source in {"delegated", "acting_for"}
|
self,
|
||||||
and item.delegated_from_assignment_id is not None
|
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 = (
|
with get_database().session() as session:
|
||||||
session.query(IdmOrganizationFunctionAssignment)
|
items = self._effective_assignment_items(
|
||||||
.filter(
|
session,
|
||||||
IdmOrganizationFunctionAssignment.id.in_(source_ids),
|
tenant_id=tenant_id,
|
||||||
IdmOrganizationFunctionAssignment.is_active.is_(True),
|
effective_at=effective_at or utc_now(),
|
||||||
or_(
|
identity_ids=identity_ids,
|
||||||
IdmOrganizationFunctionAssignment.valid_from.is_(None),
|
|
||||||
IdmOrganizationFunctionAssignment.valid_from <= now,
|
|
||||||
),
|
|
||||||
or_(
|
|
||||||
IdmOrganizationFunctionAssignment.valid_until.is_(None),
|
|
||||||
IdmOrganizationFunctionAssignment.valid_until > now,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
if source_ids
|
|
||||||
else ()
|
|
||||||
)
|
)
|
||||||
effective_sources_by_id = {item.id: item for item in effective_sources}
|
for account_id, linked_identity_ids in identity_ids_by_account.items():
|
||||||
active_items: list[IdmOrganizationFunctionAssignment] = []
|
result[account_id].extend(
|
||||||
for item in items:
|
_assignment_ref(item)
|
||||||
if item.source in {"delegated", "acting_for"}:
|
for item in items
|
||||||
source = effective_sources_by_id.get(item.delegated_from_assignment_id or "")
|
if item.identity_id in linked_identity_ids
|
||||||
if (
|
and item.account_id in {None, account_id}
|
||||||
source is None
|
)
|
||||||
or source.tenant_id != item.tenant_id
|
return {
|
||||||
or source.function_id != item.function_id
|
account_id: tuple(assignments)
|
||||||
):
|
for account_id, assignments in result.items()
|
||||||
continue
|
}
|
||||||
function = self._organizations.get_function(item.function_id)
|
|
||||||
if function is None or function.status != "active" or function.tenant_id != item.tenant_id:
|
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
|
continue
|
||||||
active_items.append(item)
|
if item.function_id not in function_cache:
|
||||||
return tuple(_assignment_ref(item) for item in active_items)
|
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)
|
||||||
|
|||||||
@@ -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.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.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.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||||
@@ -14,6 +17,7 @@ from govoplan_core.core.modules import (
|
|||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
MigrationSpec,
|
MigrationSpec,
|
||||||
ModuleContext,
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
@@ -120,6 +124,12 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_IDENTITY_SEARCH,
|
CAPABILITY_IDENTITY_SEARCH,
|
||||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||||
),
|
),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||||
|
version="0.1.8",
|
||||||
|
),
|
||||||
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
route_factory=_route_factory,
|
route_factory=_route_factory,
|
||||||
@@ -153,6 +163,7 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_IDM_DIRECTORY: _idm_directory,
|
CAPABILITY_IDM_DIRECTORY: _idm_directory,
|
||||||
|
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS: _idm_directory,
|
||||||
},
|
},
|
||||||
documentation=(
|
documentation=(
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
|
|||||||
@@ -133,6 +133,100 @@ class IdmDirectoryDelegationTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(expected_ids, tuple(item.id for item in assignments))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^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",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.6"
|
"vite": "^6.0.6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ export type OrganizationFunctionAssignmentItem = {
|
|||||||
|
|
||||||
export type OrganizationFunctionAssignmentList = {
|
export type OrganizationFunctionAssignmentList = {
|
||||||
assignments: OrganizationFunctionAssignmentItem[];
|
assignments: OrganizationFunctionAssignmentItem[];
|
||||||
|
total?: number;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
pages?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IdmSettings = {
|
export type IdmSettings = {
|
||||||
@@ -111,8 +115,27 @@ export function getOrganizationModel(settings: ApiSettings): Promise<Organizatio
|
|||||||
return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model");
|
return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOrganizationFunctionAssignments(settings: ApiSettings): Promise<OrganizationFunctionAssignmentList> {
|
export async function getOrganizationFunctionAssignments(settings: ApiSettings): Promise<OrganizationFunctionAssignmentList> {
|
||||||
return apiFetch<OrganizationFunctionAssignmentList>(settings, "/api/v1/idm/organization-function-assignments");
|
const pageSize = 500;
|
||||||
|
const assignments: OrganizationFunctionAssignmentItem[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for (let page = 1; ; page += 1) {
|
||||||
|
const response = await apiFetch<OrganizationFunctionAssignmentList>(
|
||||||
|
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<IdmSettings> {
|
export function getIdmSettings(settings: ApiSettings): Promise<IdmSettings> {
|
||||||
|
|||||||
Reference in New Issue
Block a user