feat: expose effective function incumbency contracts

This commit is contained in:
2026-07-29 14:16:29 +02:00
parent 5a8138ea03
commit 01c1f7e13a
7 changed files with 501 additions and 71 deletions

View File

@@ -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

View File

@@ -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):

View File

@@ -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,45 +54,212 @@ 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:
items = self._effective_assignment_items(
session,
tenant_id=tenant_id,
effective_at=effective_at or utc_now(),
identity_ids=requested,
)
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()
}
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,
)
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 = ( query = (
session.query(IdmOrganizationFunctionAssignment) session.query(IdmOrganizationFunctionAssignment)
.filter( .filter(
IdmOrganizationFunctionAssignment.identity_id.in_(identity_ids),
IdmOrganizationFunctionAssignment.is_active.is_(True), IdmOrganizationFunctionAssignment.is_active.is_(True),
or_(IdmOrganizationFunctionAssignment.valid_from.is_(None), IdmOrganizationFunctionAssignment.valid_from <= now), or_(
or_(IdmOrganizationFunctionAssignment.valid_until.is_(None), IdmOrganizationFunctionAssignment.valid_until > now), 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()) .order_by(IdmOrganizationFunctionAssignment.created_at.asc())
) )
if account_id is not None: if identity_ids:
query = query.filter(or_(IdmOrganizationFunctionAssignment.account_id.is_(None), IdmOrganizationFunctionAssignment.account_id == account_id)) 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: if tenant_id is not None:
query = query.filter(IdmOrganizationFunctionAssignment.tenant_id == tenant_id) query = query.filter(
IdmOrganizationFunctionAssignment.tenant_id == tenant_id
)
items = query.all() items = query.all()
source_ids = { source_ids = {
item.delegated_from_assignment_id item.delegated_from_assignment_id
@@ -100,30 +274,43 @@ class SqlIdmDirectory(IdmDirectory):
IdmOrganizationFunctionAssignment.is_active.is_(True), IdmOrganizationFunctionAssignment.is_active.is_(True),
or_( or_(
IdmOrganizationFunctionAssignment.valid_from.is_(None), IdmOrganizationFunctionAssignment.valid_from.is_(None),
IdmOrganizationFunctionAssignment.valid_from <= now, IdmOrganizationFunctionAssignment.valid_from <= effective_at,
), ),
or_( or_(
IdmOrganizationFunctionAssignment.valid_until.is_(None), IdmOrganizationFunctionAssignment.valid_until.is_(None),
IdmOrganizationFunctionAssignment.valid_until > now, IdmOrganizationFunctionAssignment.valid_until > effective_at,
), ),
) )
.all() .all()
if source_ids if source_ids
else () else ()
) )
effective_sources_by_id = {item.id: item for item in effective_sources} effective_sources_by_id = {
item.id: item for item in effective_sources
}
function_cache = {}
active_items: list[IdmOrganizationFunctionAssignment] = [] active_items: list[IdmOrganizationFunctionAssignment] = []
for item in items: for item in items:
if item.source in {"delegated", "acting_for"}: if item.source in {"delegated", "acting_for"}:
source = effective_sources_by_id.get(item.delegated_from_assignment_id or "") source = effective_sources_by_id.get(
item.delegated_from_assignment_id or ""
)
if ( if (
source is None source is None
or source.tenant_id != item.tenant_id or source.tenant_id != item.tenant_id
or source.function_id != item.function_id or source.function_id != item.function_id
): ):
continue continue
function = self._organizations.get_function(item.function_id) if item.function_id not in function_cache:
if function is None or function.status != "active" or function.tenant_id != item.tenant_id: 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 continue
active_items.append(item) active_items.append(item)
return tuple(_assignment_ref(item) for item in active_items) return tuple(active_items)

View File

@@ -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(

View File

@@ -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()

View File

@@ -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"
}, },

View File

@@ -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> {