9 Commits
v0.1.7 ... main

15 changed files with 633 additions and 195 deletions

View File

@@ -1,5 +1,9 @@
# GovOPlaN IDM # GovOPlaN IDM
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
`govoplan-idm` is the planned integration module for external identity `govoplan-idm` is the planned integration module for external identity
management systems. It does not own GovOPlaN's internal identity, organization, management systems. It does not own GovOPlaN's internal identity, organization,
account, role, or tenant tables; those remain with `govoplan-identity`, account, role, or tenant tables; those remain with `govoplan-identity`,

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/idm-webui", "name": "@govoplan/idm-webui",
"version": "0.1.7", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.7", "@govoplan/core-webui": "^0.1.9",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",

View File

@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-idm" name = "govoplan-idm"
version = "0.1.7" version = "0.1.8"
description = "GovOPlaN identity management bridge module." description = "GovOPlaN identity management bridge module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.7", "govoplan-core>=0.1.8",
"govoplan-identity>=0.1.7", "govoplan-identity>=0.1.8",
"govoplan-organizations>=0.1.7", "govoplan-organizations>=0.1.8",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

@@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from typing import Any, TypeVar from typing import Any, TypeVar
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from sqlalchemy import func, or_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -17,11 +17,21 @@ 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.identity import (
CAPABILITY_IDENTITY_DIRECTORY,
CAPABILITY_IDENTITY_SEARCH,
IdentityDirectory,
IdentityRef,
IdentitySearchProvider,
)
from govoplan_core.core.organizations import (
CAPABILITY_ORGANIZATION_DIRECTORY,
OrganizationDirectory,
OrganizationFunctionRef,
)
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_identity.backend.db.models import Identity, IdentityAccountLink
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
from govoplan_organizations.backend.db.models import OrganizationFunction
from .schemas import ( from .schemas import (
IdmSettingsItem, IdmSettingsItem,
@@ -125,31 +135,76 @@ def _default_settings(tenant_id: str) -> IdmSettingsItem:
) )
def _ensure_identity(session: Session, identity_id: str) -> None: def _identity_directory() -> IdentityDirectory:
identity = session.get(Identity, identity_id) registry = get_registry()
if identity is None: if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Identity directory is unavailable",
)
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if not isinstance(capability, IdentityDirectory):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}",
)
return capability
def _identity_search() -> IdentitySearchProvider:
registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_SEARCH):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Identity search is unavailable",
)
capability = registry.require_capability(CAPABILITY_IDENTITY_SEARCH)
if not isinstance(capability, IdentitySearchProvider):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Invalid capability: {CAPABILITY_IDENTITY_SEARCH}",
)
return capability
def _organization_directory() -> OrganizationDirectory:
registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_ORGANIZATION_DIRECTORY):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Organization directory is unavailable",
)
capability = registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY)
if not isinstance(capability, OrganizationDirectory):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Invalid capability: {CAPABILITY_ORGANIZATION_DIRECTORY}",
)
return capability
def _organization_function(function_id: str, tenant_id: str) -> OrganizationFunctionRef:
function = _organization_directory().get_function(function_id)
if function is None or function.tenant_id != tenant_id:
raise _not_found("Organization function")
return function
def _ensure_identity(identity_id: str) -> None:
if _identity_directory().get_identity(identity_id) is None:
raise _not_found("Identity") raise _not_found("Identity")
def _ensure_account_link(session: Session, identity_id: str, account_id: str | None) -> None: def _ensure_account_link(identity_id: str, account_id: str | None) -> None:
if account_id is None: if account_id is None:
return return
exists = ( links = _identity_directory().accounts_for_identity(identity_id)
session.query(IdentityAccountLink) if not any(link.account_id == account_id for link in links):
.filter(IdentityAccountLink.identity_id == identity_id, IdentityAccountLink.account_id == account_id)
.first()
)
if exists is None:
raise _invalid("Account is not linked to the selected identity.") raise _invalid("Account is not linked to the selected identity.")
def _account_linked_to_identity(session: Session, identity_id: str, account_id: str) -> bool: def _account_linked_to_identity(identity_id: str, account_id: str) -> bool:
return ( return any(link.account_id == account_id for link in _identity_directory().accounts_for_identity(identity_id))
session.query(IdentityAccountLink)
.filter(IdentityAccountLink.identity_id == identity_id, IdentityAccountLink.account_id == account_id)
.first()
is not None
)
def _ensure_assignment_workflow( def _ensure_assignment_workflow(
@@ -158,6 +213,18 @@ def _ensure_assignment_workflow(
*, *,
tenant_id: str, tenant_id: str,
) -> None: ) -> None:
_ensure_assignment_shape(item)
function = _organization_function(item.function_id, tenant_id)
base = _assignment_source_assignment(session, item, tenant_id=tenant_id)
_ensure_assignment_source_rules(
item,
function=function,
base=base,
account_linked_to_identity=_account_linked_to_identity,
)
def _ensure_assignment_shape(item: IdmOrganizationFunctionAssignment) -> None:
if item.source not in ASSIGNMENT_SOURCES: if item.source not in ASSIGNMENT_SOURCES:
raise _invalid("Assignment source is not supported.") raise _invalid("Assignment source is not supported.")
if item.valid_from is not None and item.valid_until is not None and item.valid_until <= item.valid_from: if item.valid_from is not None and item.valid_until is not None and item.valid_until <= item.valid_from:
@@ -165,43 +232,81 @@ def _ensure_assignment_workflow(
if item.delegated_from_assignment_id is not None and item.delegated_from_assignment_id == item.id: if item.delegated_from_assignment_id is not None and item.delegated_from_assignment_id == item.id:
raise _invalid("A function assignment cannot delegate from itself.") raise _invalid("A function assignment cannot delegate from itself.")
function = _get_tenant_row(session, OrganizationFunction, item.function_id, tenant_id, "Organization function")
base: IdmOrganizationFunctionAssignment | None = None
if item.delegated_from_assignment_id is not None:
base = _get_tenant_row(session, IdmOrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
if not base.is_active:
raise _invalid("Delegation source assignment must be active.")
if base.function_id != item.function_id:
raise _invalid("Delegated and acting-for assignments must use the same organization function as the source assignment.")
def _assignment_source_assignment(
session: Session,
item: IdmOrganizationFunctionAssignment,
*,
tenant_id: str,
) -> IdmOrganizationFunctionAssignment | None:
if item.delegated_from_assignment_id is None:
return None
base = _get_tenant_row(session, IdmOrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
if not base.is_active:
raise _invalid("Delegation source assignment must be active.")
if base.function_id != item.function_id:
raise _invalid("Delegated and acting-for assignments must use the same organization function as the source assignment.")
return base
def _ensure_assignment_source_rules(
item: IdmOrganizationFunctionAssignment,
*,
function: OrganizationFunctionRef,
base: IdmOrganizationFunctionAssignment | None,
account_linked_to_identity: Callable[[str, str], bool],
) -> None:
if item.source == "delegated": if item.source == "delegated":
if base is None: _ensure_delegated_assignment(item, function=function, base=base)
raise _invalid("Delegated assignments require a source assignment.") return
if not function.delegable: if item.source == "acting_for":
raise _invalid("This organization function does not allow delegation.") _ensure_acting_for_assignment(item, function=function, base=base, account_linked_to_identity=account_linked_to_identity)
if item.acting_for_account_id is not None: return
raise _invalid("Delegated assignments cannot set an acting-for account.") _ensure_direct_assignment_shape(item)
if item.identity_id == base.identity_id and (item.account_id or "") == (base.account_id or ""):
raise _invalid("A delegated assignment must target another identity or account.")
elif item.source == "acting_for": def _ensure_delegated_assignment(
if base is None: item: IdmOrganizationFunctionAssignment,
raise _invalid("Acting-for assignments require a source assignment.") *,
if not function.act_in_place_allowed: function: OrganizationFunctionRef,
raise _invalid("This organization function does not allow acting in place.") base: IdmOrganizationFunctionAssignment | None,
if item.acting_for_account_id is None: ) -> None:
raise _invalid("Acting-for assignments require an acting-for account.") if base is None:
if base.account_id is not None: raise _invalid("Delegated assignments require a source assignment.")
if item.acting_for_account_id != base.account_id: if not function.delegable:
raise _invalid("Acting-for account must match the source assignment account.") raise _invalid("This organization function does not allow delegation.")
elif not _account_linked_to_identity(session, base.identity_id, item.acting_for_account_id): if item.acting_for_account_id is not None:
raise _invalid("Acting-for account must belong to the source assignment identity.") raise _invalid("Delegated assignments cannot set an acting-for account.")
if item.account_id == item.acting_for_account_id: if item.identity_id == base.identity_id and (item.account_id or "") == (base.account_id or ""):
raise _invalid("The acting account and acting-for account must be different.") raise _invalid("A delegated assignment must target another identity or account.")
else:
if item.delegated_from_assignment_id is not None:
raise _invalid("Only delegated or acting-for assignments can reference a source assignment.") def _ensure_acting_for_assignment(
if item.acting_for_account_id is not None: item: IdmOrganizationFunctionAssignment,
raise _invalid("Only acting-for assignments can set an acting-for account.") *,
function: OrganizationFunctionRef,
base: IdmOrganizationFunctionAssignment | None,
account_linked_to_identity: Callable[[str, str], bool],
) -> None:
if base is None:
raise _invalid("Acting-for assignments require a source assignment.")
if not function.act_in_place_allowed:
raise _invalid("This organization function does not allow acting in place.")
if item.acting_for_account_id is None:
raise _invalid("Acting-for assignments require an acting-for account.")
if base.account_id is not None and item.acting_for_account_id != base.account_id:
raise _invalid("Acting-for account must match the source assignment account.")
if base.account_id is None and not account_linked_to_identity(base.identity_id, item.acting_for_account_id):
raise _invalid("Acting-for account must belong to the source assignment identity.")
if item.account_id == item.acting_for_account_id:
raise _invalid("The acting account and acting-for account must be different.")
def _ensure_direct_assignment_shape(item: IdmOrganizationFunctionAssignment) -> None:
if item.delegated_from_assignment_id is not None:
raise _invalid("Only delegated or acting-for assignments can reference a source assignment.")
if item.acting_for_account_id is not None:
raise _invalid("Only acting-for assignments can set an acting-for account.")
def _requires_assignment_change_request(session: Session, tenant_id: str) -> bool: def _requires_assignment_change_request(session: Session, tenant_id: str) -> bool:
@@ -383,9 +488,9 @@ def create_organization_function_assignment(
) -> OrganizationFunctionAssignmentItem: ) -> OrganizationFunctionAssignmentItem:
tenant_id = _tenant_id(principal) tenant_id = _tenant_id(principal)
approval, target, _value = _ensure_assignment_change_allowed(session, principal, tenant_id=tenant_id, operation="created", payload=payload) approval, target, _value = _ensure_assignment_change_allowed(session, principal, tenant_id=tenant_id, operation="created", payload=payload)
_ensure_identity(session, payload.identity_id) _ensure_identity(payload.identity_id)
_ensure_account_link(session, payload.identity_id, payload.account_id) _ensure_account_link(payload.identity_id, payload.account_id)
function = _get_tenant_row(session, OrganizationFunction, payload.function_id, tenant_id, "Organization function") function = _organization_function(payload.function_id, tenant_id)
item = IdmOrganizationFunctionAssignment( item = IdmOrganizationFunctionAssignment(
tenant_id=tenant_id, tenant_id=tenant_id,
identity_id=payload.identity_id, identity_id=payload.identity_id,
@@ -435,7 +540,7 @@ def update_organization_function_assignment(
if "function_id" in payload.model_fields_set: if "function_id" in payload.model_fields_set:
if payload.function_id is None: if payload.function_id is None:
raise _invalid("Function is required.") raise _invalid("Function is required.")
function = _get_tenant_row(session, OrganizationFunction, payload.function_id, tenant_id, "Organization function") function = _organization_function(payload.function_id, tenant_id)
item.function_id = function.id item.function_id = function.id
item.organization_unit_id = function.organization_unit_id item.organization_unit_id = function.organization_unit_id
identity_id = payload.identity_id if "identity_id" in payload.model_fields_set else item.identity_id identity_id = payload.identity_id if "identity_id" in payload.model_fields_set else item.identity_id
@@ -443,9 +548,9 @@ def update_organization_function_assignment(
if "identity_id" in payload.model_fields_set: if "identity_id" in payload.model_fields_set:
if payload.identity_id is None: if payload.identity_id is None:
raise _invalid("Identity is required.") raise _invalid("Identity is required.")
_ensure_identity(session, payload.identity_id) _ensure_identity(payload.identity_id)
item.identity_id = payload.identity_id item.identity_id = payload.identity_id
_ensure_account_link(session, identity_id, account_id) _ensure_account_link(identity_id, account_id)
if "source" in payload.model_fields_set and payload.source is None: if "source" in payload.model_fields_set and payload.source is None:
raise _invalid("Assignment source is required.") raise _invalid("Assignment source is required.")
if "applies_to_subunits" in payload.model_fields_set and payload.applies_to_subunits is None: if "applies_to_subunits" in payload.model_fields_set and payload.applies_to_subunits is None:
@@ -492,53 +597,25 @@ def list_organization_identity_candidates(
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORGANIZATION_IDENTITY_READ_SCOPES)), principal: ApiPrincipal = Depends(require_any_scope(*ORGANIZATION_IDENTITY_READ_SCOPES)),
) -> OrganizationIdentityCandidateList: ) -> OrganizationIdentityCandidateList:
del principal del session, principal
identity_query = session.query(Identity) identities = _identity_search().search_identities(
if not include_inactive: query,
identity_query = identity_query.filter(Identity.is_active.is_(True)) include_inactive=include_inactive,
if query: limit=limit,
pattern = f"%{query.strip().casefold()}%" )
matching_account_links = session.query(IdentityAccountLink.identity_id).filter(
func.lower(IdentityAccountLink.account_id).like(pattern)
)
identity_query = identity_query.filter(
or_(
func.lower(Identity.id).like(pattern),
func.lower(Identity.display_name).like(pattern),
func.lower(Identity.external_subject).like(pattern),
Identity.id.in_(matching_account_links),
)
)
identities = identity_query.order_by(Identity.display_name.asc(), Identity.id.asc()).limit(limit).all()
identity_ids = [identity.id for identity in identities]
links_by_identity: dict[str, list[IdentityAccountLink]] = {identity_id: [] for identity_id in identity_ids}
if identity_ids:
links = (
session.query(IdentityAccountLink)
.filter(IdentityAccountLink.identity_id.in_(identity_ids))
.order_by(IdentityAccountLink.is_primary.desc(), IdentityAccountLink.account_id.asc())
.all()
)
for link in links:
links_by_identity.setdefault(link.identity_id, []).append(link)
return OrganizationIdentityCandidateList( return OrganizationIdentityCandidateList(
identities=[ identities=[_identity_candidate(identity) for identity in identities]
_identity_candidate(identity, links_by_identity.get(identity.id, []))
for identity in identities
]
) )
def _identity_candidate(identity: Identity, links: list[IdentityAccountLink]) -> OrganizationIdentityCandidate: def _identity_candidate(identity: IdentityRef) -> OrganizationIdentityCandidate:
primary_link = next((link for link in links if link.is_primary), None)
return OrganizationIdentityCandidate( return OrganizationIdentityCandidate(
id=identity.id, id=identity.id,
display_name=identity.display_name, display_name=identity.display_name,
external_subject=identity.external_subject, external_subject=identity.external_subject,
source=identity.source, source=identity.source,
primary_account_id=primary_link.account_id if primary_link is not None else None, primary_account_id=identity.primary_account_id,
account_ids=[link.account_id for link in links], account_ids=list(identity.account_ids),
status="active" if identity.is_active else "inactive", status=identity.status,
) )

View File

@@ -2,12 +2,12 @@ from __future__ import annotations
from sqlalchemy import or_ 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
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
from govoplan_identity.backend.db.models import IdentityAccountLink
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
from govoplan_organizations.backend.db.models import OrganizationFunction
def _status(active: bool) -> str: def _status(active: bool) -> str:
@@ -33,6 +33,10 @@ def _assignment_ref(item: IdmOrganizationFunctionAssignment) -> OrganizationFunc
class SqlIdmDirectory(IdmDirectory): class SqlIdmDirectory(IdmDirectory):
def __init__(self, *, identities: IdentityDirectory, organizations: OrganizationDirectory) -> None:
self._identities = identities
self._organizations = organizations
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None: def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
with get_database().session() as session: with get_database().session() as session:
item = session.get(IdmOrganizationFunctionAssignment, assignment_id) item = session.get(IdmOrganizationFunctionAssignment, assignment_id)
@@ -52,13 +56,7 @@ class SqlIdmDirectory(IdmDirectory):
*, *,
tenant_id: str | None = None, tenant_id: str | None = None,
) -> tuple[OrganizationFunctionAssignmentRef, ...]: ) -> tuple[OrganizationFunctionAssignmentRef, ...]:
with get_database().session() as session: identity_ids = [identity.id for identity in self._identities.identities_for_accounts((account_id,))]
identity_ids = [
row[0]
for row in session.query(IdentityAccountLink.identity_id)
.filter(IdentityAccountLink.account_id == account_id)
.all()
]
if not identity_ids: if not identity_ids:
return () return ()
return self._assignments_for_identity_ids(identity_ids=tuple(identity_ids), tenant_id=tenant_id, account_id=account_id) return self._assignments_for_identity_ids(identity_ids=tuple(identity_ids), tenant_id=tenant_id, account_id=account_id)
@@ -76,11 +74,9 @@ class SqlIdmDirectory(IdmDirectory):
with get_database().session() as session: with get_database().session() as session:
query = ( query = (
session.query(IdmOrganizationFunctionAssignment) session.query(IdmOrganizationFunctionAssignment)
.join(OrganizationFunction, OrganizationFunction.id == IdmOrganizationFunctionAssignment.function_id)
.filter( .filter(
IdmOrganizationFunctionAssignment.identity_id.in_(identity_ids), IdmOrganizationFunctionAssignment.identity_id.in_(identity_ids),
IdmOrganizationFunctionAssignment.is_active.is_(True), IdmOrganizationFunctionAssignment.is_active.is_(True),
OrganizationFunction.is_active.is_(True),
or_(IdmOrganizationFunctionAssignment.valid_from.is_(None), IdmOrganizationFunctionAssignment.valid_from <= now), or_(IdmOrganizationFunctionAssignment.valid_from.is_(None), IdmOrganizationFunctionAssignment.valid_from <= now),
or_(IdmOrganizationFunctionAssignment.valid_until.is_(None), IdmOrganizationFunctionAssignment.valid_until > now), or_(IdmOrganizationFunctionAssignment.valid_until.is_(None), IdmOrganizationFunctionAssignment.valid_until > now),
) )
@@ -90,4 +86,44 @@ class SqlIdmDirectory(IdmDirectory):
query = query.filter(or_(IdmOrganizationFunctionAssignment.account_id.is_(None), IdmOrganizationFunctionAssignment.account_id == account_id)) query = query.filter(or_(IdmOrganizationFunctionAssignment.account_id.is_(None), IdmOrganizationFunctionAssignment.account_id == account_id))
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)
return tuple(_assignment_ref(item) for item in query.all()) 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 <= 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}
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:
continue
active_items.append(item)
return tuple(_assignment_ref(item) for item in active_items)

View File

@@ -3,7 +3,10 @@ from __future__ import annotations
from pathlib import Path 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.idm import CAPABILITY_IDM_DIRECTORY from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY
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 from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
DocumentationTopic, DocumentationTopic,
@@ -93,19 +96,30 @@ def _route_factory(context: ModuleContext):
def _idm_directory(context: ModuleContext) -> object: def _idm_directory(context: ModuleContext) -> object:
del context
from govoplan_idm.backend.directory import SqlIdmDirectory from govoplan_idm.backend.directory import SqlIdmDirectory
return SqlIdmDirectory() identities = context.registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
organizations = context.registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY)
if not isinstance(identities, IdentityDirectory):
raise RuntimeError(f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
if not isinstance(organizations, OrganizationDirectory):
raise RuntimeError(f"Invalid capability: {CAPABILITY_ORGANIZATION_DIRECTORY}")
return SqlIdmDirectory(identities=identities, organizations=organizations)
manifest = ModuleManifest( manifest = ModuleManifest(
id="idm", id="idm",
name="IDM", name="IDM",
version="0.1.7", version="0.1.8",
dependencies=("identity", "organizations"), dependencies=("identity", "organizations"),
optional_dependencies=("access", "audit"), optional_dependencies=("access", "audit"),
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_IDENTITY_DIRECTORY,
CAPABILITY_IDENTITY_SEARCH,
CAPABILITY_ORGANIZATION_DIRECTORY,
),
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
route_factory=_route_factory, route_factory=_route_factory,
@@ -115,6 +129,15 @@ manifest = ModuleManifest(
package_name="@govoplan/idm-webui", package_name="@govoplan/idm-webui",
routes=(FrontendRoute(path="/idm", component="IdmPage", required_any=IDM_READ_SCOPES, order=72),), routes=(FrontendRoute(path="/idm", component="IdmPage", required_any=IDM_READ_SCOPES, order=72),),
nav_items=(NavItem(path="/idm", label="IDM", icon="users", required_any=IDM_READ_SCOPES, order=72),), nav_items=(NavItem(path="/idm", label="IDM", icon="users", required_any=IDM_READ_SCOPES, order=72),),
view_surfaces=(
ViewSurface(
id="idm.action.view-function-assignments",
module_id="idm",
kind="action",
label="View function assignments",
order=40,
),
),
), ),
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id="idm", module_id="idm",

View File

@@ -0,0 +1 @@
"""IDM migration revisions."""

View File

@@ -0,0 +1,65 @@
"""v0.1.7 idm baseline
Revision ID: 8f9a0b1c2d3e
Revises: None
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = '8f9a0b1c2d3e'
down_revision = None
branch_labels = None
depends_on = ('5c6d7e8f9a10', '6d7e8f9a0b1c')
def upgrade() -> None:
op.create_table('idm_tenant_settings',
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('require_assignment_change_requests', sa.Boolean(), nullable=False),
sa.Column('audit_detail_level', sa.String(length=20), nullable=False),
sa.Column('change_retention_days', sa.Integer(), nullable=True),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('tenant_id', name=op.f('pk_idm_tenant_settings'))
)
op.create_table('idm_organization_function_assignments',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('identity_id', sa.String(length=36), nullable=False),
sa.Column('account_id', sa.String(length=36), nullable=True),
sa.Column('function_id', sa.String(length=36), nullable=False),
sa.Column('organization_unit_id', sa.String(length=36), nullable=False),
sa.Column('applies_to_subunits', sa.Boolean(), nullable=False),
sa.Column('source', sa.String(length=50), nullable=False),
sa.Column('delegated_from_assignment_id', sa.String(length=36), nullable=True),
sa.Column('acting_for_account_id', sa.String(length=36), nullable=True),
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['delegated_from_assignment_id'], ['idm_organization_function_assignments.id'], name=op.f('fk_idm_organization_function_assignments_delegated_from_assignment_id_idm_organization_function_assignments'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['function_id'], ['organizations_functions.id'], name=op.f('fk_idm_organization_function_assignments_function_id_organizations_functions'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['identity_id'], ['identity_identities.id'], name=op.f('fk_idm_organization_function_assignments_identity_id_identity_identities'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['organization_unit_id'], ['organizations_units.id'], name=op.f('fk_idm_organization_function_assignments_organization_unit_id_organizations_units'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_idm_organization_function_assignments')),
sa.UniqueConstraint('tenant_id', 'identity_id', 'function_id', 'organization_unit_id', name='uq_idm_org_function_assignments_identity_scope')
)
op.create_index(op.f('ix_idm_organization_function_assignments_account_id'), 'idm_organization_function_assignments', ['account_id'], unique=False)
op.create_index(op.f('ix_idm_organization_function_assignments_acting_for_account_id'), 'idm_organization_function_assignments', ['acting_for_account_id'], unique=False)
op.create_index(op.f('ix_idm_organization_function_assignments_delegated_from_assignment_id'), 'idm_organization_function_assignments', ['delegated_from_assignment_id'], unique=False)
op.create_index(op.f('ix_idm_organization_function_assignments_function_id'), 'idm_organization_function_assignments', ['function_id'], unique=False)
op.create_index(op.f('ix_idm_organization_function_assignments_identity_id'), 'idm_organization_function_assignments', ['identity_id'], unique=False)
op.create_index(op.f('ix_idm_organization_function_assignments_organization_unit_id'), 'idm_organization_function_assignments', ['organization_unit_id'], unique=False)
op.create_index(op.f('ix_idm_organization_function_assignments_tenant_id'), 'idm_organization_function_assignments', ['tenant_id'], unique=False)
def downgrade() -> None:
op.drop_table('idm_organization_function_assignments')
op.drop_table('idm_tenant_settings')

View File

@@ -0,0 +1,132 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from fastapi import HTTPException
from govoplan_idm.backend.api.v1.routes import _ensure_assignment_shape, _ensure_assignment_source_rules
def assignment(**overrides: object) -> SimpleNamespace:
values = {
"id": "assignment-1",
"identity_id": "identity-1",
"account_id": "account-1",
"function_id": "function-1",
"source": "direct",
"delegated_from_assignment_id": None,
"acting_for_account_id": None,
"valid_from": None,
"valid_until": None,
}
values.update(overrides)
return SimpleNamespace(**values)
def function(**overrides: object) -> SimpleNamespace:
values = {"delegable": True, "act_in_place_allowed": True}
values.update(overrides)
return SimpleNamespace(**values)
class AssignmentWorkflowTests(unittest.TestCase):
def assert_invalid(self, expected_detail: str, callback: object) -> None:
with self.assertRaises(HTTPException) as captured:
callback()
self.assertEqual(captured.exception.detail, expected_detail)
def test_direct_assignment_accepts_plain_shape(self) -> None:
item = assignment()
_ensure_assignment_shape(item) # type: ignore[arg-type]
_ensure_assignment_source_rules( # type: ignore[arg-type]
item,
function=function(),
base=None,
account_linked_to_identity=lambda _identity_id, _account_id: False,
)
def test_shape_rejects_backwards_validity_window(self) -> None:
now = datetime.now(timezone.utc)
item = assignment(valid_from=now, valid_until=now - timedelta(days=1))
self.assert_invalid("Valid until must be after valid from.", lambda: _ensure_assignment_shape(item)) # type: ignore[arg-type]
def test_shape_rejects_self_delegation(self) -> None:
item = assignment(id="assignment-1", delegated_from_assignment_id="assignment-1")
self.assert_invalid("A function assignment cannot delegate from itself.", lambda: _ensure_assignment_shape(item)) # type: ignore[arg-type]
def test_delegated_assignment_accepts_different_target(self) -> None:
base = assignment(id="source-1", identity_id="identity-1", account_id="account-1")
item = assignment(
id="assignment-2",
identity_id="identity-2",
account_id="account-2",
source="delegated",
delegated_from_assignment_id="source-1",
)
_ensure_assignment_source_rules( # type: ignore[arg-type]
item,
function=function(delegable=True),
base=base,
account_linked_to_identity=lambda _identity_id, _account_id: False,
)
def test_delegated_assignment_rejects_same_target(self) -> None:
base = assignment(id="source-1", identity_id="identity-1", account_id="account-1")
item = assignment(source="delegated", delegated_from_assignment_id="source-1")
self.assert_invalid(
"A delegated assignment must target another identity or account.",
lambda: _ensure_assignment_source_rules( # type: ignore[arg-type]
item,
function=function(delegable=True),
base=base,
account_linked_to_identity=lambda _identity_id, _account_id: False,
),
)
def test_acting_for_assignment_accepts_identity_linked_account(self) -> None:
base = assignment(id="source-1", identity_id="identity-1", account_id=None)
item = assignment(
id="assignment-2",
source="acting_for",
delegated_from_assignment_id="source-1",
account_id="acting-account",
acting_for_account_id="represented-account",
)
_ensure_assignment_source_rules( # type: ignore[arg-type]
item,
function=function(act_in_place_allowed=True),
base=base,
account_linked_to_identity=lambda identity_id, account_id: identity_id == "identity-1" and account_id == "represented-account",
)
def test_acting_for_assignment_rejects_unlinked_represented_account(self) -> None:
base = assignment(id="source-1", identity_id="identity-1", account_id=None)
item = assignment(
id="assignment-2",
source="acting_for",
delegated_from_assignment_id="source-1",
account_id="acting-account",
acting_for_account_id="represented-account",
)
self.assert_invalid(
"Acting-for account must belong to the source assignment identity.",
lambda: _ensure_assignment_source_rules( # type: ignore[arg-type]
item,
function=function(act_in_place_allowed=True),
base=base,
account_linked_to_identity=lambda _identity_id, _account_id: False,
),
)
if __name__ == "__main__":
unittest.main()

138
tests/test_directory.py Normal file
View File

@@ -0,0 +1,138 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from govoplan_core.core.identity import IdentityAccountLinkRef, IdentityRef
from govoplan_core.core.organizations import OrganizationFunctionRef
from govoplan_core.db.base import Base
from govoplan_core.db.session import configure_database, reset_database
from govoplan_identity.backend.db import models as identity_models # noqa: F401 - resolve assignment foreign keys
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
from govoplan_idm.backend.directory import SqlIdmDirectory
from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - resolve assignment foreign keys
class StubIdentityDirectory:
def get_identity(self, identity_id: str) -> IdentityRef | None:
return None
def identity_for_account(self, account_id: str) -> IdentityRef | None:
return None
def identities_for_accounts(self, account_ids: tuple[str, ...]) -> tuple[IdentityRef, ...]:
return ()
def accounts_for_identity(self, identity_id: str) -> tuple[IdentityAccountLinkRef, ...]:
return ()
class StubOrganizationDirectory:
def get_function(self, function_id: str) -> OrganizationFunctionRef | None:
return OrganizationFunctionRef(
id=function_id,
tenant_id="tenant-1",
organization_unit_id="unit-1",
slug=function_id,
name=function_id,
status="active",
)
def get_organization_unit(self, organization_unit_id: str):
return None
def organization_units_for_tenant(self, tenant_id: str):
return ()
def functions_for_organization_unit(self, organization_unit_id: str, *, include_subunits: bool = False):
return ()
class IdmDirectoryDelegationTests(unittest.TestCase):
def setUp(self) -> None:
self.database = configure_database("sqlite:///:memory:")
Base.metadata.create_all(
self.database.engine,
tables=[IdmOrganizationFunctionAssignment.__table__],
)
self.directory = SqlIdmDirectory(
identities=StubIdentityDirectory(), # type: ignore[arg-type]
organizations=StubOrganizationDirectory(), # type: ignore[arg-type]
)
def tearDown(self) -> None:
reset_database(dispose=True)
def _source_and_child(
self,
*,
case: str,
child_source: str = "delegated",
source_tenant_id: str = "tenant-1",
source_function_id: str = "function-1",
source_is_active: bool = True,
source_valid_from: datetime | None = None,
source_valid_until: datetime | None = None,
) -> tuple[IdmOrganizationFunctionAssignment, IdmOrganizationFunctionAssignment]:
source = IdmOrganizationFunctionAssignment(
id=f"source-{case}",
tenant_id=source_tenant_id,
identity_id=f"source-identity-{case}",
function_id=source_function_id,
organization_unit_id="unit-1",
source="direct",
is_active=source_is_active,
valid_from=source_valid_from,
valid_until=source_valid_until,
settings={},
)
child = IdmOrganizationFunctionAssignment(
id=f"child-{case}",
tenant_id="tenant-1",
identity_id=f"target-identity-{case}",
function_id="function-1",
organization_unit_id="unit-1",
source=child_source,
delegated_from_assignment_id=source.id,
acting_for_account_id="represented-account" if child_source == "acting_for" else None,
is_active=True,
settings={},
)
return source, child
def test_effective_delegations_require_a_current_matching_source(self) -> None:
now = datetime.now(timezone.utc)
cases = (
self._source_and_child(case="valid"),
self._source_and_child(case="acting", child_source="acting_for"),
self._source_and_child(case="revoked", source_is_active=False),
self._source_and_child(case="expired", source_valid_until=now - timedelta(days=1)),
self._source_and_child(case="future", source_valid_from=now + timedelta(days=1)),
self._source_and_child(case="wrong-tenant", source_tenant_id="tenant-2"),
self._source_and_child(case="wrong-function", source_function_id="function-2"),
)
with self.database.session() as session:
for source, child in cases:
session.add_all((source, child))
session.commit()
expected = {
"valid": ("child-valid",),
"acting": ("child-acting",),
"revoked": (),
"expired": (),
"future": (),
"wrong-tenant": (),
"wrong-function": (),
}
for case, expected_ids in expected.items():
with self.subTest(case=case):
assignments = self.directory.organization_function_assignments_for_identity(
f"target-identity-{case}",
tenant_id="tenant-1",
)
self.assertEqual(expected_ids, tuple(item.id for item in assignments))
if __name__ == "__main__":
unittest.main()

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/idm-webui", "name": "@govoplan/idm-webui",
"version": "0.1.7", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/idm.css": "./src/styles/idm.css" "./styles/idm.css": "./src/styles/idm.css"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.7", "@govoplan/core-webui": "^0.1.9",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",

View File

@@ -10,8 +10,11 @@ import {
DismissibleAlert, DismissibleAlert,
FormField, FormField,
LoadingFrame, LoadingFrame,
PageScrollViewport,
PageTitle, PageTitle,
StatusBadge, StatusBadge,
TableActionGroup,
ToggleSwitch,
hasScope, hasScope,
useUnsavedDraftGuard, useUnsavedDraftGuard,
type ApiSettings, type ApiSettings,
@@ -559,18 +562,15 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
}, },
{ {
id: "actions", id: "actions",
header: "", header: "Actions",
width: 88, width: 72,
sticky: "end", sticky: "end",
render: (row) => ( render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-idm.edit.a5a0f3cc", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canManage || busy, onClick: () => editAssignment(row) }]} />
<div className="idm-row-actions">
<AdminIconButton label="i18n:govoplan-idm.edit.a5a0f3cc" icon={<Edit3 size={16} aria-hidden="true" />} disabled={!canManage || busy} onClick={() => editAssignment(row)} />
</div>
)
} }
]; ];
return ( return (
<PageScrollViewport>
<div className="content-pad idm-page"> <div className="content-pad idm-page">
<div className="page-heading split idm-heading"> <div className="page-heading split idm-heading">
<div> <div>
@@ -592,17 +592,9 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
<div className="idm-table-stack"> <div className="idm-table-stack">
{canReadSettings && ( {canReadSettings && (
<Card title="i18n:govoplan-idm.idm_governance.6e4f3251" collapsible collapseKey="idm.governance"> <Card title="i18n:govoplan-idm.idm_governance.6e4f3251" collapsible collapseKey="idm.governance">
<form className="idm-form-grid" onSubmit={(event) => { event.preventDefault(); void submitSettings(); }}> <form className="admin-form-grid two-columns" onSubmit={(event) => { event.preventDefault(); void submitSettings(); }}>
<div className="idm-check-list wide"> <div className="idm-check-list wide">
<label> <ToggleSwitch label="i18n:govoplan-idm.require_assignment_change_requests.697718a1" checked={settingsDraft.require_assignment_change_requests} disabled={!canManageSettings || busy} onChange={(require_assignment_change_requests) => setSettingsDraft({ ...settingsDraft, require_assignment_change_requests })} />
<input
type="checkbox"
checked={settingsDraft.require_assignment_change_requests}
disabled={!canManageSettings || busy}
onChange={(event) => setSettingsDraft({ ...settingsDraft, require_assignment_change_requests: event.target.checked })}
/>
<span>i18n:govoplan-idm.require_assignment_change_requests.697718a1</span>
</label>
</div> </div>
<FormField label="i18n:govoplan-idm.audit_detail_level.eb2e6fd2"> <FormField label="i18n:govoplan-idm.audit_detail_level.eb2e6fd2">
<select <select
@@ -624,7 +616,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
onChange={(event) => setSettingsDraft({ ...settingsDraft, change_retention_days: event.target.value })} onChange={(event) => setSettingsDraft({ ...settingsDraft, change_retention_days: event.target.value })}
/> />
</FormField> </FormField>
<div className="idm-form-actions wide"> <div className="button-row compact-actions wide">
<Button type="submit" variant="primary" disabled={!canManageSettings || busy || !hasDirtySettingsDraft}> <Button type="submit" variant="primary" disabled={!canManageSettings || busy || !hasDirtySettingsDraft}>
i18n:govoplan-idm.save_settings.4602c430 i18n:govoplan-idm.save_settings.4602c430
</Button> </Button>
@@ -648,6 +640,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
</LoadingFrame> </LoadingFrame>
{renderAssignmentDialog()} {renderAssignmentDialog()}
</div> </div>
</PageScrollViewport>
); );
function renderAssignmentDialog() { function renderAssignmentDialog() {
@@ -668,7 +661,7 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
</> </>
)} )}
> >
<form id={formId} className="idm-form-grid" onSubmit={(event) => void submitAssignment(event)}> <form id={formId} className="admin-form-grid two-columns" onSubmit={(event) => void submitAssignment(event)}>
<FormField label="i18n:govoplan-idm.identity_search.d3460fcf"> <FormField label="i18n:govoplan-idm.identity_search.d3460fcf">
<input <input
value={identitySearch} value={identitySearch}
@@ -731,14 +724,8 @@ export default function IdmPage({ settings, auth }: IdmPageProps) {
</> </>
)} )}
<div className="idm-check-list"> <div className="idm-check-list">
<label> <ToggleSwitch label="i18n:govoplan-idm.applies_to_subunits.2e31b50b" checked={assignmentDraft.applies_to_subunits} disabled={!canManage || busy} onChange={(applies_to_subunits) => setAssignmentDraft({ ...assignmentDraft, applies_to_subunits })} />
<input type="checkbox" checked={assignmentDraft.applies_to_subunits} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, applies_to_subunits: event.target.checked })} /> <ToggleSwitch label="i18n:govoplan-idm.active.7bd0e9f8" checked={assignmentDraft.is_active} disabled={!canManage || busy} onChange={(is_active) => setAssignmentDraft({ ...assignmentDraft, is_active })} />
<span>i18n:govoplan-idm.applies_to_subunits.2e31b50b</span>
</label>
<label>
<input type="checkbox" checked={assignmentDraft.is_active} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, is_active: event.target.checked })} />
<span>i18n:govoplan-idm.active.7bd0e9f8</span>
</label>
</div> </div>
<div className="wide idm-dialog-change-request"> <div className="wide idm-dialog-change-request">
<FormField label="i18n:govoplan-idm.change_request_id.b7d816db"> <FormField label="i18n:govoplan-idm.change_request_id.b7d816db">

View File

@@ -1,5 +1,6 @@
import { createElement, lazy } from "react"; import { createElement, lazy } from "react";
import { Button, type OrganizationFunctionActionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui"; import { Users } from "lucide-react";
import type { OrganizationFunctionActionsUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations"; import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/idm.css"; import "./styles/idm.css";
@@ -20,23 +21,16 @@ const organizationFunctionActions: OrganizationFunctionActionsUiCapability = {
actions: [ actions: [
{ {
id: "idm.view-function-assignments", id: "idm.view-function-assignments",
label: "i18n:govoplan-idm.assignments.a0d19ec5", surfaceId: "idm.action.view-function-assignments",
label: "i18n:govoplan-idm.view_assignments.2d40d6a5",
icon: createElement(Users, { size: 16 }),
anyOf: idmReadScopes, anyOf: idmReadScopes,
order: 40, order: 40,
render: ({ function: item }) => createElement( onClick: ({ function: item }) => {
Button, if (typeof window !== "undefined") {
{ window.location.href = `/idm?function_id=${encodeURIComponent(item.id)}`;
type: "button", }
variant: "ghost", }
title: "i18n:govoplan-idm.view_assignments.2d40d6a5",
onClick: () => {
if (typeof window !== "undefined") {
window.location.href = `/idm?function_id=${encodeURIComponent(item.id)}`;
}
}
},
"i18n:govoplan-idm.assignments.a0d19ec5"
)
} }
] ]
}; };
@@ -47,6 +41,15 @@ export const idmModule: PlatformWebModule = {
version: "0.1.6", version: "0.1.6",
dependencies: ["identity", "organizations"], dependencies: ["identity", "organizations"],
translations, translations,
viewSurfaces: [
{
id: "idm.action.view-function-assignments",
moduleId: "idm",
kind: "action",
label: "View function assignments",
order: 40
}
],
navItems: [ navItems: [
{ {
to: "/idm", to: "/idm",

View File

@@ -27,16 +27,6 @@
width: 100%; width: 100%;
} }
.idm-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.idm-form-grid .wide {
grid-column: 1 / -1;
}
.idm-check-list { .idm-check-list {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -51,18 +41,6 @@
font-weight: 600; font-weight: 600;
} }
.idm-form-actions,
.idm-row-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
}
.idm-row-actions {
justify-content: flex-end;
}
.idm-identity { .idm-identity {
display: grid; display: grid;
gap: 2px; gap: 2px;
@@ -88,9 +66,3 @@
.idm-dialog-change-request { .idm-dialog-change-request {
padding-top: 2px; padding-top: 2px;
} }
@media (max-width: 900px) {
.idm-form-grid {
grid-template-columns: 1fr;
}
}