Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a8138ea03 | |||
| 906879caf1 | |||
| dd1937a3af | |||
| a0c9e59c34 | |||
| 853125c80c | |||
| d098e3e9dd | |||
| d2e34b323a | |||
| 74652686ca | |||
| b47f71916d |
@@ -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`,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,16 +232,45 @@ 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
|
def _assignment_source_assignment(
|
||||||
if item.delegated_from_assignment_id is not None:
|
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")
|
base = _get_tenant_row(session, IdmOrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
|
||||||
if not base.is_active:
|
if not base.is_active:
|
||||||
raise _invalid("Delegation source assignment must be active.")
|
raise _invalid("Delegation source assignment must be active.")
|
||||||
if base.function_id != item.function_id:
|
if base.function_id != item.function_id:
|
||||||
raise _invalid("Delegated and acting-for assignments must use the same organization function as the source assignment.")
|
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":
|
||||||
|
_ensure_delegated_assignment(item, function=function, base=base)
|
||||||
|
return
|
||||||
|
if item.source == "acting_for":
|
||||||
|
_ensure_acting_for_assignment(item, function=function, base=base, account_linked_to_identity=account_linked_to_identity)
|
||||||
|
return
|
||||||
|
_ensure_direct_assignment_shape(item)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_delegated_assignment(
|
||||||
|
item: IdmOrganizationFunctionAssignment,
|
||||||
|
*,
|
||||||
|
function: OrganizationFunctionRef,
|
||||||
|
base: IdmOrganizationFunctionAssignment | None,
|
||||||
|
) -> None:
|
||||||
if base is None:
|
if base is None:
|
||||||
raise _invalid("Delegated assignments require a source assignment.")
|
raise _invalid("Delegated assignments require a source assignment.")
|
||||||
if not function.delegable:
|
if not function.delegable:
|
||||||
@@ -183,21 +279,30 @@ def _ensure_assignment_workflow(
|
|||||||
raise _invalid("Delegated assignments cannot set an acting-for account.")
|
raise _invalid("Delegated assignments cannot set an acting-for account.")
|
||||||
if item.identity_id == base.identity_id and (item.account_id or "") == (base.account_id or ""):
|
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.")
|
raise _invalid("A delegated assignment must target another identity or account.")
|
||||||
elif item.source == "acting_for":
|
|
||||||
|
|
||||||
|
def _ensure_acting_for_assignment(
|
||||||
|
item: IdmOrganizationFunctionAssignment,
|
||||||
|
*,
|
||||||
|
function: OrganizationFunctionRef,
|
||||||
|
base: IdmOrganizationFunctionAssignment | None,
|
||||||
|
account_linked_to_identity: Callable[[str, str], bool],
|
||||||
|
) -> None:
|
||||||
if base is None:
|
if base is None:
|
||||||
raise _invalid("Acting-for assignments require a source assignment.")
|
raise _invalid("Acting-for assignments require a source assignment.")
|
||||||
if not function.act_in_place_allowed:
|
if not function.act_in_place_allowed:
|
||||||
raise _invalid("This organization function does not allow acting in place.")
|
raise _invalid("This organization function does not allow acting in place.")
|
||||||
if item.acting_for_account_id is None:
|
if item.acting_for_account_id is None:
|
||||||
raise _invalid("Acting-for assignments require an acting-for account.")
|
raise _invalid("Acting-for assignments require an acting-for account.")
|
||||||
if base.account_id is not None:
|
if base.account_id is not None and item.acting_for_account_id != base.account_id:
|
||||||
if item.acting_for_account_id != base.account_id:
|
|
||||||
raise _invalid("Acting-for account must match the source assignment account.")
|
raise _invalid("Acting-for account must match the source assignment account.")
|
||||||
elif not _account_linked_to_identity(session, base.identity_id, item.acting_for_account_id):
|
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.")
|
raise _invalid("Acting-for account must belong to the source assignment identity.")
|
||||||
if item.account_id == item.acting_for_account_id:
|
if item.account_id == item.acting_for_account_id:
|
||||||
raise _invalid("The acting account and acting-for account must be different.")
|
raise _invalid("The acting account and acting-for account must be different.")
|
||||||
else:
|
|
||||||
|
|
||||||
|
def _ensure_direct_assignment_shape(item: IdmOrganizationFunctionAssignment) -> None:
|
||||||
if item.delegated_from_assignment_id is not None:
|
if item.delegated_from_assignment_id is not None:
|
||||||
raise _invalid("Only delegated or acting-for assignments can reference a source assignment.")
|
raise _invalid("Only delegated or acting-for assignments can reference a source assignment.")
|
||||||
if item.acting_for_account_id is not None:
|
if item.acting_for_account_id is not None:
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""IDM migration revisions."""
|
||||||
@@ -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')
|
||||||
132
tests/test_assignment_workflow.py
Normal file
132
tests/test_assignment_workflow.py
Normal 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
138
tests/test_directory.py
Normal 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()
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
|
||||||
{
|
|
||||||
type: "button",
|
|
||||||
variant: "ghost",
|
|
||||||
title: "i18n:govoplan-idm.view_assignments.2d40d6a5",
|
|
||||||
onClick: () => {
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.location.href = `/idm?function_id=${encodeURIComponent(item.id)}`;
|
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",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user