From b09c1449b907ed91097c971bb0c4da7a3275e86d Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 10 Jul 2026 21:57:26 +0200 Subject: [PATCH] Sync GovOPlaN module state --- .gitignore | 348 +++++++++ README.md | 74 ++ package.json | 36 + pyproject.toml | 25 + src/govoplan_idm/__init__.py | 1 + src/govoplan_idm/backend/__init__.py | 1 + src/govoplan_idm/backend/api/__init__.py | 1 + src/govoplan_idm/backend/api/v1/__init__.py | 1 + src/govoplan_idm/backend/api/v1/routes.py | 544 +++++++++++++ src/govoplan_idm/backend/api/v1/schemas.py | 94 +++ src/govoplan_idm/backend/db/__init__.py | 1 + src/govoplan_idm/backend/db/models.py | 55 ++ src/govoplan_idm/backend/directory.py | 93 +++ src/govoplan_idm/backend/manifest.py | 186 +++++ .../backend/migrations/__init__.py | 1 + ...e_idm_organization_function_assignments.py | 139 ++++ .../backend/migrations/versions/__init__.py | 1 + src/govoplan_idm/py.typed | 1 + webui/package.json | 31 + webui/src/api/idm.ts | 147 ++++ webui/src/features/IdmPage.tsx | 730 ++++++++++++++++++ webui/src/i18n/generatedTranslations.ts | 126 +++ webui/src/index.ts | 1 + webui/src/module.ts | 72 ++ webui/src/styles/idm.css | 100 +++ 25 files changed, 2809 insertions(+) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 pyproject.toml create mode 100644 src/govoplan_idm/__init__.py create mode 100644 src/govoplan_idm/backend/__init__.py create mode 100644 src/govoplan_idm/backend/api/__init__.py create mode 100644 src/govoplan_idm/backend/api/v1/__init__.py create mode 100644 src/govoplan_idm/backend/api/v1/routes.py create mode 100644 src/govoplan_idm/backend/api/v1/schemas.py create mode 100644 src/govoplan_idm/backend/db/__init__.py create mode 100644 src/govoplan_idm/backend/db/models.py create mode 100644 src/govoplan_idm/backend/directory.py create mode 100644 src/govoplan_idm/backend/manifest.py create mode 100644 src/govoplan_idm/backend/migrations/__init__.py create mode 100644 src/govoplan_idm/backend/migrations/versions/8f9a0b1c2d3e_idm_organization_function_assignments.py create mode 100644 src/govoplan_idm/backend/migrations/versions/__init__.py create mode 100644 src/govoplan_idm/py.typed create mode 100644 webui/package.json create mode 100644 webui/src/api/idm.ts create mode 100644 webui/src/features/IdmPage.tsx create mode 100644 webui/src/i18n/generatedTranslations.ts create mode 100644 webui/src/index.ts create mode 100644 webui/src/module.ts create mode 100644 webui/src/styles/idm.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6750d45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,348 @@ +# ---> Node +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Local WebUI test/build scratch directories +.component-test-build/ +.module-test-build/ +.policy-test-build/ +.template-preview-test-build/ +.import-test-build/ +webui/.component-test-build/ +webui/.module-test-build/ +webui/.policy-test-build/ +webui/.template-preview-test-build/ +webui/.import-test-build/ + +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# ---> VisualStudioCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +*.db + +# GovOPlaN local runtime state +runtime/ + +# GovOPlaN WebUI test output +webui/.module-test-build/ +webui/.component-test-build/ diff --git a/README.md b/README.md index 8ffac9c..728b8d0 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ vendor or protocol. - inbound synchronization from external identity-management systems - identity lifecycle import, update, disable, and reconciliation jobs +- identity-to-organization-function assignment links inside GovOPlaN +- bridge views that combine identity and organization facts, such as identity + candidates for organization function assignments - mapping external identities, accounts, groups, organizational units, functions, and attributes to GovOPlaN identity, organization, access, and tenancy DTOs @@ -50,6 +53,77 @@ No feature module should import IDM internals directly. Feature modules should ask access, tenancy, or future directory capabilities for normalized identity facts. +`govoplan-idm` has hard module dependencies on `govoplan-identity` and +`govoplan-organizations`, because its core value is connecting those normalized +facts. Identity remains the owner of identities/accounts. Organizations remains +the owner of units/functions. + +## Current Runtime API + +The first runtime API exposes identity lookup and organization-function +assignment links: + +- `GET /api/v1/idm/settings` +- `PATCH /api/v1/idm/settings` +- `GET /api/v1/idm/organization-identities` +- `GET /api/v1/idm/organization-function-assignments` +- `POST /api/v1/idm/organization-function-assignments` +- `PATCH /api/v1/idm/organization-function-assignments/{assignment_id}` + +The candidate endpoint returns searchable identity/account candidates for IDM +assignment forms. Assignment writes validate the identity/account link through +`govoplan-identity` and the function/unit scope through +`govoplan-organizations`. These endpoints exist only when IDM is enabled, which +implies both identity and organizations are enabled through module dependency +planning. + +The WebUI exposed by this repository is a normal module UI at `/idm`. It is the +editing surface for identity-to-organization-function assignment links. + +## Migration And Permission Transition + +The initial IDM migration creates new IDM-owned tables only. No data is migrated +from the legacy access/organization assignment tables. + +Canonical permissions for assignment work are: + +- `idm:organization_identity:read` +- `idm:organization_assignment:read` +- `idm:organization_assignment:write` +- `idm:settings:read` +- `idm:settings:write` + +During the transition, `organizations:function:assign` remains accepted by IDM +assignment and identity-candidate endpoints as a compatibility scope. New role +templates and documentation should use the IDM scopes. Organizations remains the +owner of units and function definitions; IDM owns identity-to-function +assignment links. + +`govoplan-access` can consume IDM assignments through the optional +`idm.directory` capability when IDM is installed. This is deliberately optional: +access still works without IDM, and IDM does not require access to store or edit +assignment links. If the audit module is enabled, IDM records assignment and +settings audit events; otherwise the assignment API remains usable without an +audit hard dependency. + +An IDM assignment does not grant application permissions by itself. Access grants +role-derived permissions only when an explicit external function role mapping +connects the organization function ID to an assignable role. Those mappings are +managed through the access API at +`/api/v1/admin/external-function-role-mappings`. + +Tenant IDM settings can require recorded change requests before assignment +creates or updates are applied. The change-control key is +`idm.organization_assignments`. + +Delegated and acting-for assignments are source-specific: + +- `delegated` requires a source assignment for the same function, and the + organization function must allow delegation. +- `acting_for` requires a source assignment for the same function, an + acting-for account on that source identity, and the organization function must + allow acting in place. + ## First Milestone The first useful milestone is a read-only synchronization preview: diff --git a/package.json b/package.json new file mode 100644 index 0000000..92ddee8 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "@govoplan/idm-webui", + "version": "0.1.6", + "private": true, + "type": "module", + "main": "webui/src/index.ts", + "module": "webui/src/index.ts", + "types": "webui/src/index.ts", + "exports": { + ".": { + "types": "./webui/src/index.ts", + "import": "./webui/src/index.ts" + }, + "./styles/idm.css": "./webui/src/styles/idm.css" + }, + "files": [ + "webui/src", + "README.md", + "LICENSE" + ], + "peerDependencies": { + "@govoplan/core-webui": "^0.1.6", + "@vitejs/plugin-react": "^4.3.4", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "typescript": "^5.7.2", + "vite": "^6.0.6" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..61d18af --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-idm" +version = "0.1.6" +description = "GovOPlaN identity management bridge module." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "GovOPlaN" }] +dependencies = [ + "govoplan-core>=0.1.6", + "govoplan-identity>=0.1.6", + "govoplan-organizations>=0.1.6", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_idm = ["py.typed"] + +[project.entry-points."govoplan.modules"] +idm = "govoplan_idm.backend.manifest:get_manifest" diff --git a/src/govoplan_idm/__init__.py b/src/govoplan_idm/__init__.py new file mode 100644 index 0000000..f9caef6 --- /dev/null +++ b/src/govoplan_idm/__init__.py @@ -0,0 +1 @@ +"""GovOPlaN identity management bridge module.""" diff --git a/src/govoplan_idm/backend/__init__.py b/src/govoplan_idm/backend/__init__.py new file mode 100644 index 0000000..1624bba --- /dev/null +++ b/src/govoplan_idm/backend/__init__.py @@ -0,0 +1 @@ +"""Backend integration for the GovOPlaN IDM module.""" diff --git a/src/govoplan_idm/backend/api/__init__.py b/src/govoplan_idm/backend/api/__init__.py new file mode 100644 index 0000000..2571801 --- /dev/null +++ b/src/govoplan_idm/backend/api/__init__.py @@ -0,0 +1 @@ +"""IDM API package.""" diff --git a/src/govoplan_idm/backend/api/v1/__init__.py b/src/govoplan_idm/backend/api/v1/__init__.py new file mode 100644 index 0000000..c099a8a --- /dev/null +++ b/src/govoplan_idm/backend/api/v1/__init__.py @@ -0,0 +1 @@ +"""IDM API v1 package.""" diff --git a/src/govoplan_idm/backend/api/v1/routes.py b/src/govoplan_idm/backend/api/v1/routes.py new file mode 100644 index 0000000..f59a1b6 --- /dev/null +++ b/src/govoplan_idm/backend/api/v1/routes.py @@ -0,0 +1,544 @@ +from __future__ import annotations + +from typing import Any, TypeVar + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.encoders import jsonable_encoder +from sqlalchemy import func, or_ +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from govoplan_core.audit.logging import audit_from_principal +from govoplan_core.auth import ApiPrincipal, require_any_scope +from govoplan_core.core.access import CAPABILITY_AUDIT_RECORDER +from govoplan_core.core.configuration_control import ( + ConfigurationChangeApproval, + ConfigurationControlError, + ensure_configuration_change_allowed, + record_configuration_change_applied, +) +from govoplan_core.core.runtime import get_registry +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_organizations.backend.db.models import OrganizationFunction + +from .schemas import ( + IdmSettingsItem, + IdmSettingsUpdateRequest, + OrganizationFunctionAssignmentCreateRequest, + OrganizationFunctionAssignmentItem, + OrganizationFunctionAssignmentList, + OrganizationFunctionAssignmentUpdateRequest, + OrganizationIdentityCandidate, + OrganizationIdentityCandidateList, +) + + +router = APIRouter(prefix="/idm", tags=["idm"]) + +ORGANIZATION_IDENTITY_READ_SCOPES = ( + "idm:organization_identity:read", + "idm:organization_assignment:write", + "organizations:function:assign", + "admin:users:read", +) +IDM_ASSIGNMENT_READ_SCOPES = ("idm:organization_assignment:read", "idm:organization_assignment:write", "organizations:function:assign") +IDM_ASSIGNMENT_WRITE_SCOPES = ("idm:organization_assignment:write", "organizations:function:assign") +IDM_SETTINGS_READ_SCOPES = ( + "idm:settings:read", + "idm:settings:write", + "idm:organization_assignment:read", + "idm:organization_assignment:write", +) +IDM_SETTINGS_WRITE_SCOPES = ("idm:settings:write",) +IDM_ASSIGNMENT_CHANGE_CONTROL_KEY = "idm.organization_assignments" +IDM_ASSIGNMENT_AUDIT_EVENT = "idm.organization_assignment.updated" +ASSIGNMENT_SOURCES = {"direct", "delegated", "acting_for", "directory", "governance", "system"} + +ModelT = TypeVar("ModelT") + + +def _tenant_id(principal: ApiPrincipal) -> str: + return principal.tenant_id + + +def _not_found(label: str) -> HTTPException: + return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{label} not found") + + +def _conflict(message: str) -> HTTPException: + return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=message) + + +def _invalid(message: str) -> HTTPException: + return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=message) + + +def _configuration_control_http_error(exc: ConfigurationControlError) -> HTTPException: + return HTTPException(status_code=status.HTTP_409_CONFLICT, detail={"code": exc.code, "message": str(exc), "plan": exc.plan}) + + +def _get_tenant_row(session: Session, model: type[ModelT], item_id: str, tenant_id: str, label: str) -> ModelT: + item = session.get(model, item_id) + if item is None or getattr(item, "tenant_id") != tenant_id: + raise _not_found(label) + return item + + +def _commit(session: Session, item: ModelT) -> ModelT: + try: + session.commit() + except IntegrityError as exc: + session.rollback() + raise _conflict("The IDM assignment conflicts with existing data.") from exc + session.refresh(item) + return item + + +def _row_fields(item: object) -> dict[str, Any]: + keys = [column.name for column in item.__table__.columns] # type: ignore[attr-defined] + return {key: getattr(item, key) for key in keys} + + +def _jsonable(value: object) -> object: + return jsonable_encoder(value) + + +def _assignment_item(item: IdmOrganizationFunctionAssignment) -> OrganizationFunctionAssignmentItem: + return OrganizationFunctionAssignmentItem(**_row_fields(item)) + + +def _settings_item(item: IdmTenantSettings) -> IdmSettingsItem: + return IdmSettingsItem(**_row_fields(item)) + + +def _default_settings(tenant_id: str) -> IdmSettingsItem: + return IdmSettingsItem( + tenant_id=tenant_id, + require_assignment_change_requests=False, + audit_detail_level="standard", + change_retention_days=None, + settings={}, + created_at=None, + updated_at=None, + ) + + +def _ensure_identity(session: Session, identity_id: str) -> None: + identity = session.get(Identity, identity_id) + if identity is None: + raise _not_found("Identity") + + +def _ensure_account_link(session: Session, identity_id: str, account_id: str | None) -> None: + if account_id is None: + return + exists = ( + session.query(IdentityAccountLink) + .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.") + + +def _account_linked_to_identity(session: Session, identity_id: str, account_id: str) -> bool: + return ( + session.query(IdentityAccountLink) + .filter(IdentityAccountLink.identity_id == identity_id, IdentityAccountLink.account_id == account_id) + .first() + is not None + ) + + +def _ensure_assignment_workflow( + session: Session, + item: IdmOrganizationFunctionAssignment, + *, + tenant_id: str, +) -> None: + if item.source not in ASSIGNMENT_SOURCES: + 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: + raise _invalid("Valid until must be after valid from.") + 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.") + + 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.") + + if item.source == "delegated": + if base is None: + raise _invalid("Delegated assignments require a source assignment.") + if not function.delegable: + raise _invalid("This organization function does not allow delegation.") + if item.acting_for_account_id is not None: + 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 ""): + raise _invalid("A delegated assignment must target another identity or account.") + elif item.source == "acting_for": + 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: + if item.acting_for_account_id != base.account_id: + 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): + 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.") + else: + 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: + item = session.query(IdmTenantSettings).filter(IdmTenantSettings.tenant_id == tenant_id).one_or_none() + return bool(item and item.require_assignment_change_requests) + + +def _actor_id(principal: ApiPrincipal) -> str: + return principal.membership_id or principal.account_id + + +def _payload_for_control(resource_type: str, operation: str, payload: object) -> dict[str, Any]: + if hasattr(payload, "model_dump"): + values = payload.model_dump(mode="json", exclude={"change_request_id"}, exclude_unset=True) # type: ignore[attr-defined] + else: + values = {} + return {"resource_type": resource_type, "operation": operation, "payload": values} + + +def _target_for_control(tenant_id: str, resource_type: str, operation: str, resource_id: str | None = None) -> dict[str, Any]: + target: dict[str, Any] = {"tenant_id": tenant_id, "resource_type": resource_type, "operation": operation} + if resource_id is not None: + target["resource_id"] = resource_id + return target + + +def _ensure_assignment_change_allowed( + session: Session, + principal: ApiPrincipal, + *, + tenant_id: str, + operation: str, + payload: object, + resource_id: str | None = None, +) -> tuple[ConfigurationChangeApproval | None, dict[str, Any], dict[str, Any]]: + value = _payload_for_control("organization_function_assignment", operation, payload) + target = _target_for_control(tenant_id, "organization_function_assignment", operation, resource_id) + if not _requires_assignment_change_request(session, tenant_id): + return None, target, value + try: + approval = ensure_configuration_change_allowed( + session, + key=IDM_ASSIGNMENT_CHANGE_CONTROL_KEY, + value=value, + actor_user_id=_actor_id(principal), + actor_scopes=tuple(principal.scopes), + change_request_id=getattr(payload, "change_request_id", None), + target=target, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc + return approval, target, value + + +def _record_assignment_change_applied( + session: Session, + principal: ApiPrincipal, + *, + approval: ConfigurationChangeApproval | None, + target: dict[str, Any], + before: object, + after: object, +) -> None: + if approval is None: + return + record_configuration_change_applied( + session, + key=IDM_ASSIGNMENT_CHANGE_CONTROL_KEY, + before_value=before, + after_value=after, + actor_user_id=_actor_id(principal), + approval=approval, + target=target, + audit_event=IDM_ASSIGNMENT_AUDIT_EVENT, + ) + session.commit() + + +def _audit_capability_available() -> bool: + registry = get_registry() + return bool(registry is not None and hasattr(registry, "has_capability") and registry.has_capability(CAPABILITY_AUDIT_RECORDER)) + + +def _record_assignment_audit( + session: Session, + principal: ApiPrincipal, + *, + action: str, + resource_type: str, + resource_id: str, + before: object, + after: object, +) -> None: + if not _audit_capability_available(): + return + audit_from_principal( + session, + principal, + action=action, + object_type=resource_type, + object_id=resource_id, + details={"before": before, "after": after}, + commit=True, + ) + + +@router.get("/settings", response_model=IdmSettingsItem) +def get_idm_settings( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*IDM_SETTINGS_READ_SCOPES)), +) -> IdmSettingsItem: + tenant_id = _tenant_id(principal) + item = session.query(IdmTenantSettings).filter(IdmTenantSettings.tenant_id == tenant_id).one_or_none() + return _settings_item(item) if item is not None else _default_settings(tenant_id) + + +@router.patch("/settings", response_model=IdmSettingsItem) +def update_idm_settings( + payload: IdmSettingsUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*IDM_SETTINGS_WRITE_SCOPES)), +) -> IdmSettingsItem: + tenant_id = _tenant_id(principal) + item = session.query(IdmTenantSettings).filter(IdmTenantSettings.tenant_id == tenant_id).one_or_none() + before = _jsonable(_row_fields(item)) if item is not None else None + if item is None: + item = IdmTenantSettings( + tenant_id=tenant_id, + require_assignment_change_requests=False, + audit_detail_level="standard", + change_retention_days=None, + settings={}, + ) + session.add(item) + fields = payload.model_fields_set + for field in ("require_assignment_change_requests", "audit_detail_level", "change_retention_days"): + if field in fields: + value = getattr(payload, field) + if field != "change_retention_days" and value is None: + raise _invalid(f"{field} cannot be empty.") + setattr(item, field, value) + if "settings" in fields: + if payload.settings is None: + raise _invalid("Settings cannot be empty.") + item.settings = payload.settings + result = _settings_item(_commit(session, item)) + _record_assignment_audit( + session, + principal, + action="idm.settings.updated", + resource_type="idm_tenant_settings", + resource_id=tenant_id, + before=before, + after=result.model_dump(mode="json"), + ) + return result + + +@router.get("/organization-function-assignments", response_model=OrganizationFunctionAssignmentList) +def list_organization_function_assignments( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*IDM_ASSIGNMENT_READ_SCOPES)), +) -> OrganizationFunctionAssignmentList: + tenant_id = _tenant_id(principal) + assignments = ( + session.query(IdmOrganizationFunctionAssignment) + .filter(IdmOrganizationFunctionAssignment.tenant_id == tenant_id) + .order_by(IdmOrganizationFunctionAssignment.created_at.asc()) + .all() + ) + return OrganizationFunctionAssignmentList(assignments=[_assignment_item(item) for item in assignments]) + + +@router.post("/organization-function-assignments", response_model=OrganizationFunctionAssignmentItem, status_code=status.HTTP_201_CREATED) +def create_organization_function_assignment( + payload: OrganizationFunctionAssignmentCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*IDM_ASSIGNMENT_WRITE_SCOPES)), +) -> OrganizationFunctionAssignmentItem: + tenant_id = _tenant_id(principal) + approval, target, _value = _ensure_assignment_change_allowed(session, principal, tenant_id=tenant_id, operation="created", payload=payload) + _ensure_identity(session, payload.identity_id) + _ensure_account_link(session, payload.identity_id, payload.account_id) + function = _get_tenant_row(session, OrganizationFunction, payload.function_id, tenant_id, "Organization function") + item = IdmOrganizationFunctionAssignment( + tenant_id=tenant_id, + identity_id=payload.identity_id, + account_id=payload.account_id, + function_id=function.id, + organization_unit_id=function.organization_unit_id, + applies_to_subunits=payload.applies_to_subunits, + source=payload.source, + delegated_from_assignment_id=payload.delegated_from_assignment_id, + acting_for_account_id=payload.acting_for_account_id, + valid_from=payload.valid_from, + valid_until=payload.valid_until, + is_active=payload.is_active, + settings=payload.settings, + ) + if item.delegated_from_assignment_id is not None: + _get_tenant_row(session, IdmOrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment") + _ensure_assignment_workflow(session, item, tenant_id=tenant_id) + session.add(item) + saved = _commit(session, item) + result = _assignment_item(saved) + after = result.model_dump(mode="json") + _record_assignment_change_applied(session, principal, approval=approval, target={**target, "resource_id": saved.id}, before=None, after=after) + _record_assignment_audit( + session, + principal, + action="idm.organization_assignment.created", + resource_type="idm_organization_function_assignment", + resource_id=saved.id, + before=None, + after=after, + ) + return result + + +@router.patch("/organization-function-assignments/{assignment_id}", response_model=OrganizationFunctionAssignmentItem) +def update_organization_function_assignment( + assignment_id: str, + payload: OrganizationFunctionAssignmentUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*IDM_ASSIGNMENT_WRITE_SCOPES)), +) -> OrganizationFunctionAssignmentItem: + tenant_id = _tenant_id(principal) + item = _get_tenant_row(session, IdmOrganizationFunctionAssignment, assignment_id, tenant_id, "Organization function assignment") + before = _jsonable(_row_fields(item)) + approval, target, _value = _ensure_assignment_change_allowed(session, principal, tenant_id=tenant_id, operation="updated", payload=payload, resource_id=assignment_id) + if "function_id" in payload.model_fields_set: + if payload.function_id is None: + raise _invalid("Function is required.") + function = _get_tenant_row(session, OrganizationFunction, payload.function_id, tenant_id, "Organization function") + item.function_id = function.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 + account_id = payload.account_id if "account_id" in payload.model_fields_set else item.account_id + if "identity_id" in payload.model_fields_set: + if payload.identity_id is None: + raise _invalid("Identity is required.") + _ensure_identity(session, payload.identity_id) + item.identity_id = payload.identity_id + _ensure_account_link(session, identity_id, account_id) + if "source" in payload.model_fields_set and payload.source is None: + raise _invalid("Assignment source is required.") + if "applies_to_subunits" in payload.model_fields_set and payload.applies_to_subunits is None: + raise _invalid("Subunit applicability cannot be empty.") + if "is_active" in payload.model_fields_set and payload.is_active is None: + raise _invalid("Active state cannot be empty.") + if "settings" in payload.model_fields_set and payload.settings is None: + raise _invalid("Settings cannot be empty.") + for field in ( + "account_id", + "applies_to_subunits", + "source", + "delegated_from_assignment_id", + "acting_for_account_id", + "valid_from", + "valid_until", + "is_active", + "settings", + ): + if field in payload.model_fields_set: + setattr(item, field, getattr(payload, field)) + _ensure_assignment_workflow(session, item, tenant_id=tenant_id) + saved = _commit(session, item) + result = _assignment_item(saved) + after = result.model_dump(mode="json") + _record_assignment_change_applied(session, principal, approval=approval, target=target, before=before, after=after) + _record_assignment_audit( + session, + principal, + action="idm.organization_assignment.updated", + resource_type="idm_organization_function_assignment", + resource_id=saved.id, + before=before, + after=after, + ) + return result + + +@router.get("/organization-identities", response_model=OrganizationIdentityCandidateList) +def list_organization_identity_candidates( + query: str | None = Query(default=None, min_length=1, max_length=255), + limit: int = Query(default=25, ge=1, le=100), + include_inactive: bool = False, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORGANIZATION_IDENTITY_READ_SCOPES)), +) -> OrganizationIdentityCandidateList: + del principal + identity_query = session.query(Identity) + if not include_inactive: + identity_query = identity_query.filter(Identity.is_active.is_(True)) + if query: + 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( + identities=[ + _identity_candidate(identity, links_by_identity.get(identity.id, [])) + for identity in identities + ] + ) + + +def _identity_candidate(identity: Identity, links: list[IdentityAccountLink]) -> OrganizationIdentityCandidate: + primary_link = next((link for link in links if link.is_primary), None) + return OrganizationIdentityCandidate( + id=identity.id, + display_name=identity.display_name, + external_subject=identity.external_subject, + source=identity.source, + primary_account_id=primary_link.account_id if primary_link is not None else None, + account_ids=[link.account_id for link in links], + status="active" if identity.is_active else "inactive", + ) diff --git a/src/govoplan_idm/backend/api/v1/schemas.py b/src/govoplan_idm/backend/api/v1/schemas.py new file mode 100644 index 0000000..3db5df0 --- /dev/null +++ b/src/govoplan_idm/backend/api/v1/schemas.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import Field +from pydantic import BaseModel + + +AuditDetailLevel = Literal["summary", "standard", "full"] + + +class OrganizationIdentityCandidate(BaseModel): + id: str + display_name: str | None = None + external_subject: str | None = None + source: str + primary_account_id: str | None = None + account_ids: list[str] + status: str + + +class OrganizationIdentityCandidateList(BaseModel): + identities: list[OrganizationIdentityCandidate] + + +class OrganizationFunctionAssignmentItem(BaseModel): + id: str + tenant_id: str + identity_id: str + account_id: str | None = None + function_id: str + organization_unit_id: str + applies_to_subunits: bool + source: str + delegated_from_assignment_id: str | None = None + acting_for_account_id: str | None = None + valid_from: datetime | None = None + valid_until: datetime | None = None + is_active: bool + settings: dict[str, Any] + created_at: datetime + updated_at: datetime + + +class OrganizationFunctionAssignmentList(BaseModel): + assignments: list[OrganizationFunctionAssignmentItem] + + +class OrganizationFunctionAssignmentCreateRequest(BaseModel): + identity_id: str = Field(min_length=1) + function_id: str = Field(min_length=1) + account_id: str | None = None + applies_to_subunits: bool = False + source: str = "direct" + delegated_from_assignment_id: str | None = None + acting_for_account_id: str | None = None + valid_from: datetime | None = None + valid_until: datetime | None = None + is_active: bool = True + settings: dict[str, Any] = Field(default_factory=dict) + change_request_id: str | None = None + + +class OrganizationFunctionAssignmentUpdateRequest(BaseModel): + identity_id: str | None = None + function_id: str | None = None + account_id: str | None = None + applies_to_subunits: bool | None = None + source: str | None = None + delegated_from_assignment_id: str | None = None + acting_for_account_id: str | None = None + valid_from: datetime | None = None + valid_until: datetime | None = None + is_active: bool | None = None + settings: dict[str, Any] | None = None + change_request_id: str | None = None + + +class IdmSettingsItem(BaseModel): + tenant_id: str + require_assignment_change_requests: bool + audit_detail_level: AuditDetailLevel + change_retention_days: int | None = None + settings: dict[str, Any] + created_at: datetime | None = None + updated_at: datetime | None = None + + +class IdmSettingsUpdateRequest(BaseModel): + require_assignment_change_requests: bool | None = None + audit_detail_level: AuditDetailLevel | None = None + change_retention_days: int | None = Field(default=None, ge=0) + settings: dict[str, Any] | None = None diff --git a/src/govoplan_idm/backend/db/__init__.py b/src/govoplan_idm/backend/db/__init__.py new file mode 100644 index 0000000..85ba2b8 --- /dev/null +++ b/src/govoplan_idm/backend/db/__init__.py @@ -0,0 +1 @@ +"""IDM database models.""" diff --git a/src/govoplan_idm/backend/db/models.py b/src/govoplan_idm/backend/db/models.py new file mode 100644 index 0000000..083dbc2 --- /dev/null +++ b/src/govoplan_idm/backend/db/models.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from govoplan_core.db.base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class IdmOrganizationFunctionAssignment(Base, TimestampMixin): + __tablename__ = "idm_organization_function_assignments" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "identity_id", + "function_id", + "organization_unit_id", + name="uq_idm_org_function_assignments_identity_scope", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + identity_id: Mapped[str] = mapped_column(ForeignKey("identity_identities.id", ondelete="CASCADE"), nullable=False, index=True) + account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + function_id: Mapped[str] = mapped_column(ForeignKey("organizations_functions.id", ondelete="CASCADE"), nullable=False, index=True) + organization_unit_id: Mapped[str] = mapped_column(ForeignKey("organizations_units.id", ondelete="CASCADE"), nullable=False, index=True) + applies_to_subunits: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + source: Mapped[str] = mapped_column(String(50), default="direct", nullable=False) + delegated_from_assignment_id: Mapped[str | None] = mapped_column(ForeignKey("idm_organization_function_assignments.id", ondelete="SET NULL"), nullable=True, index=True) + acting_for_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class IdmTenantSettings(Base, TimestampMixin): + __tablename__ = "idm_tenant_settings" + + tenant_id: Mapped[str] = mapped_column(String(36), primary_key=True) + require_assignment_change_requests: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + audit_detail_level: Mapped[str] = mapped_column(String(20), default="standard", nullable=False) + change_retention_days: Mapped[int | None] = mapped_column(Integer, nullable=True) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +__all__ = ["IdmOrganizationFunctionAssignment", "IdmTenantSettings", "new_uuid"] diff --git a/src/govoplan_idm/backend/directory.py b/src/govoplan_idm/backend/directory.py new file mode 100644 index 0000000..65e0ef2 --- /dev/null +++ b/src/govoplan_idm/backend/directory.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from sqlalchemy import or_ + +from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef +from govoplan_core.db.session import get_database +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_organizations.backend.db.models import OrganizationFunction + + +def _status(active: bool) -> str: + return "active" if active else "inactive" + + +def _assignment_ref(item: IdmOrganizationFunctionAssignment) -> OrganizationFunctionAssignmentRef: + return OrganizationFunctionAssignmentRef( + id=item.id, + tenant_id=item.tenant_id, + identity_id=item.identity_id, + account_id=item.account_id, + function_id=item.function_id, + organization_unit_id=item.organization_unit_id, + applies_to_subunits=item.applies_to_subunits, + source=item.source, # type: ignore[arg-type] + delegated_from_assignment_id=item.delegated_from_assignment_id, + acting_for_account_id=item.acting_for_account_id, + valid_from=item.valid_from, + valid_until=item.valid_until, + status=_status(item.is_active), # type: ignore[arg-type] + ) + + +class SqlIdmDirectory(IdmDirectory): + def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None: + with get_database().session() as session: + item = session.get(IdmOrganizationFunctionAssignment, assignment_id) + return _assignment_ref(item) if item is not None else None + + def organization_function_assignments_for_identity( + self, + identity_id: str, + *, + tenant_id: str | None = None, + ) -> tuple[OrganizationFunctionAssignmentRef, ...]: + return self._assignments_for_identity_ids(identity_ids=(identity_id,), tenant_id=tenant_id) + + def organization_function_assignments_for_account( + self, + account_id: str, + *, + tenant_id: str | None = None, + ) -> tuple[OrganizationFunctionAssignmentRef, ...]: + with get_database().session() as session: + identity_ids = [ + row[0] + for row in session.query(IdentityAccountLink.identity_id) + .filter(IdentityAccountLink.account_id == account_id) + .all() + ] + if not identity_ids: + return () + return self._assignments_for_identity_ids(identity_ids=tuple(identity_ids), tenant_id=tenant_id, account_id=account_id) + + def _assignments_for_identity_ids( + self, + *, + identity_ids: tuple[str, ...], + tenant_id: str | None = None, + account_id: str | None = None, + ) -> tuple[OrganizationFunctionAssignmentRef, ...]: + if not identity_ids: + return () + now = utc_now() + with get_database().session() as session: + query = ( + session.query(IdmOrganizationFunctionAssignment) + .join(OrganizationFunction, OrganizationFunction.id == IdmOrganizationFunctionAssignment.function_id) + .filter( + IdmOrganizationFunctionAssignment.identity_id.in_(identity_ids), + IdmOrganizationFunctionAssignment.is_active.is_(True), + OrganizationFunction.is_active.is_(True), + or_(IdmOrganizationFunctionAssignment.valid_from.is_(None), IdmOrganizationFunctionAssignment.valid_from <= now), + or_(IdmOrganizationFunctionAssignment.valid_until.is_(None), IdmOrganizationFunctionAssignment.valid_until > now), + ) + .order_by(IdmOrganizationFunctionAssignment.created_at.asc()) + ) + if account_id is not None: + query = query.filter(or_(IdmOrganizationFunctionAssignment.account_id.is_(None), IdmOrganizationFunctionAssignment.account_id == account_id)) + if tenant_id is not None: + query = query.filter(IdmOrganizationFunctionAssignment.tenant_id == tenant_id) + return tuple(_assignment_ref(item) for item in query.all()) diff --git a/src/govoplan_idm/backend/manifest.py b/src/govoplan_idm/backend/manifest.py new file mode 100644 index 0000000..6fe66c3 --- /dev/null +++ b/src/govoplan_idm/backend/manifest.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from pathlib import Path + +from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER +from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY +from govoplan_core.core.module_guards import persistent_table_uninstall_guard +from govoplan_core.core.modules import ( + DocumentationTopic, + FrontendModule, + FrontendRoute, + MigrationSpec, + ModuleContext, + ModuleManifest, + NavItem, + PermissionDefinition, + RoleTemplate, +) +from govoplan_core.db.base import Base +from govoplan_idm.backend.db import models as idm_models # noqa: F401 - populate metadata + +IDM_READ_SCOPES = ( + "idm:organization_assignment:read", + "idm:organization_assignment:write", + "idm:settings:read", + "organizations:function:assign", +) + + +def _permission(scope: str, label: str, description: str) -> PermissionDefinition: + module_id, resource, action = scope.split(":", 2) + return PermissionDefinition( + scope=scope, + label=label, + description=description, + category="IDM", + level="tenant", + module_id=module_id, + resource=resource, + action=action, + ) + + +PERMISSIONS = ( + _permission( + "idm:organization_identity:read", + "View organization identity candidates", + "Search identities and account links for organization function assignments.", + ), + _permission( + "idm:organization_assignment:read", + "View organization function assignments", + "Read identity-to-organization-function assignment links.", + ), + _permission( + "idm:organization_assignment:write", + "Manage organization function assignments", + "Create and edit identity-to-organization-function assignment links.", + ), + _permission( + "idm:settings:read", + "View IDM settings", + "Read IDM governance and assignment-change policy settings.", + ), + _permission( + "idm:settings:write", + "Manage IDM settings", + "Update IDM governance and assignment-change policy settings.", + ), +) + +ROLE_TEMPLATES = ( + RoleTemplate( + slug="idm_organization_mapper", + name="IDM organization mapper", + description="Resolve identity/account candidates and manage organization function assignment links.", + permissions=( + "idm:organization_identity:read", + "idm:organization_assignment:read", + "idm:organization_assignment:write", + "idm:settings:read", + "idm:settings:write", + ), + ), +) + + +def _route_factory(context: ModuleContext): + del context + from govoplan_idm.backend.api.v1.routes import router + + return router + + +def _idm_directory(context: ModuleContext) -> object: + del context + from govoplan_idm.backend.directory import SqlIdmDirectory + + return SqlIdmDirectory() + + +manifest = ModuleManifest( + id="idm", + name="IDM", + version="0.1.6", + dependencies=("identity", "organizations"), + optional_dependencies=("access", "audit"), + required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), + permissions=PERMISSIONS, + role_templates=ROLE_TEMPLATES, + route_factory=_route_factory, + nav_items=(NavItem(path="/idm", label="IDM", icon="users", required_any=IDM_READ_SCOPES, order=72),), + frontend=FrontendModule( + module_id="idm", + package_name="@govoplan/idm-webui", + 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),), + ), + migration_spec=MigrationSpec( + module_id="idm", + metadata=Base.metadata, + script_location=str(Path(__file__).with_name("migrations") / "versions"), + ), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + idm_models.IdmOrganizationFunctionAssignment, + idm_models.IdmTenantSettings, + label="IDM", + ), + ), + capability_factories={ + CAPABILITY_IDM_DIRECTORY: _idm_directory, + }, + documentation=( + DocumentationTopic( + id="idm.organization_identity_bridge", + title="Identity to organization bridge", + summary="IDM resolves which identities and accounts can be matched to organization function assignments.", + body=( + "Identity owns normalized identities and account links. Organizations owns units and functions. " + "IDM owns assignment links between identities and organization functions, including identity search for organization assignments and future synchronization/mapping workflows. " + "Access may consume these assignment links when IDM is installed, but IDM does not require access to evaluate rights." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("tenant_admin", "access_admin", "operator"), + related_modules=("identity", "organizations", "access"), + order=26, + ), + DocumentationTopic( + id="idm.reference.assignment-governance", + title="IDM assignment governance", + summary="Tenant IDM settings can require approved change requests before identity-to-function assignments are applied.", + body=( + "Assignment links are high-impact because they can later feed access decisions. " + "Tenants can enable recorded change requests for assignment create and update operations. " + "The legacy organizations:function:assign scope remains accepted for transition, while new role templates should grant idm:organization_assignment:write." + ), + layer="configured", + documentation_types=("admin",), + audience=("tenant_admin", "access_admin", "operator"), + related_modules=("identity", "organizations", "access", "audit", "policy"), + order=27, + ), + DocumentationTopic( + id="idm.workflow.assign-function-to-identity", + title="Assign an organization function to an identity", + summary="IDM links identities or accounts to organization functions. Access can consume those accepted links when a function-to-role mapping exists.", + body=( + "Create the unit and function in Organizations first. Make sure the person and account exist in Identity. " + "Then create the assignment in IDM. Direct assignments state who holds the function. Delegated assignments require a source assignment and a delegable function. " + "Acting-for assignments require a source assignment, an acting account, and a function that allows acting in place. " + "Access maps accepted function facts to roles and rights; without such a mapping, the assignment is recorded but does not grant application permissions." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("tenant_admin", "access_admin", "operator", "user"), + related_modules=("identity", "organizations", "access"), + order=28, + ), + ), +) + + +def get_manifest() -> ModuleManifest: + return manifest diff --git a/src/govoplan_idm/backend/migrations/__init__.py b/src/govoplan_idm/backend/migrations/__init__.py new file mode 100644 index 0000000..6de629d --- /dev/null +++ b/src/govoplan_idm/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""IDM database migrations.""" diff --git a/src/govoplan_idm/backend/migrations/versions/8f9a0b1c2d3e_idm_organization_function_assignments.py b/src/govoplan_idm/backend/migrations/versions/8f9a0b1c2d3e_idm_organization_function_assignments.py new file mode 100644 index 0000000..ebc2b12 --- /dev/null +++ b/src/govoplan_idm/backend/migrations/versions/8f9a0b1c2d3e_idm_organization_function_assignments.py @@ -0,0 +1,139 @@ +"""idm organization function assignments + +Revision ID: 8f9a0b1c2d3e +Revises: +Create Date: 2026-07-10 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 = None + + +def _tables() -> set[str]: + return set(sa.inspect(op.get_bind()).get_table_names()) + + +def _indexes(table_name: str) -> set[str]: + if table_name not in _tables(): + return set() + return {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table_name)} + + +def _create_index_if_missing(name: str, table_name: str, columns: list[str], *, unique: bool = False) -> None: + if table_name in _tables() and name not in _indexes(table_name): + op.create_index(name, table_name, columns, unique=unique) + + +def _drop_index_if_exists(name: str, table_name: str) -> None: + if table_name in _tables() and name in _indexes(table_name): + op.drop_index(name, table_name=table_name) + + +def upgrade() -> None: + if "idm_tenant_settings" not in _tables(): + 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")), + ) + + if "idm_organization_function_assignments" not in _tables(): + 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", + ), + ) + + for column in ( + "account_id", + "acting_for_account_id", + "delegated_from_assignment_id", + "function_id", + "identity_id", + "organization_unit_id", + "tenant_id", + ): + _create_index_if_missing( + op.f(f"ix_idm_organization_function_assignments_{column}"), + "idm_organization_function_assignments", + [column], + ) + + +def downgrade() -> None: + for column in ( + "tenant_id", + "organization_unit_id", + "identity_id", + "function_id", + "delegated_from_assignment_id", + "acting_for_account_id", + "account_id", + ): + _drop_index_if_exists( + op.f(f"ix_idm_organization_function_assignments_{column}"), + "idm_organization_function_assignments", + ) + if "idm_organization_function_assignments" in _tables(): + op.drop_table("idm_organization_function_assignments") + if "idm_tenant_settings" in _tables(): + op.drop_table("idm_tenant_settings") diff --git a/src/govoplan_idm/backend/migrations/versions/__init__.py b/src/govoplan_idm/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..cd968b7 --- /dev/null +++ b/src/govoplan_idm/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""IDM migration revisions.""" diff --git a/src/govoplan_idm/py.typed b/src/govoplan_idm/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_idm/py.typed @@ -0,0 +1 @@ + diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..c6c37df --- /dev/null +++ b/webui/package.json @@ -0,0 +1,31 @@ +{ + "name": "@govoplan/idm-webui", + "version": "0.1.6", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./styles/idm.css": "./src/styles/idm.css" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.6", + "@vitejs/plugin-react": "^4.3.4", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "typescript": "^5.7.2", + "vite": "^6.0.6" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/src/api/idm.ts b/webui/src/api/idm.ts new file mode 100644 index 0000000..47bb75f --- /dev/null +++ b/webui/src/api/idm.ts @@ -0,0 +1,147 @@ +import { apiFetch, type ApiSettings } from "@govoplan/core-webui"; + +export type OrganizationUnitItem = { + id: string; + tenant_id: string; + unit_type_id?: string | null; + parent_id?: string | null; + slug: string; + name: string; + description?: string | null; + is_active: boolean; + settings: Record; + created_at: string; + updated_at: string; +}; + +export type OrganizationFunctionItem = { + id: string; + tenant_id: string; + function_type_id?: string | null; + organization_unit_id: string; + slug: string; + name: string; + description?: string | null; + delegable: boolean; + act_in_place_allowed: boolean; + is_active: boolean; + settings: Record; + created_at: string; + updated_at: string; +}; + +export type OrganizationModel = { + units: OrganizationUnitItem[]; + functions: OrganizationFunctionItem[]; +}; + +export type IdentityOption = { + id: string; + display_name?: string | null; + external_subject?: string | null; + source: string; + primary_account_id?: string | null; + account_ids: string[]; + status: string; +}; + +export type IdentityListResponse = { + identities: IdentityOption[]; +}; + +export type OrganizationFunctionAssignmentItem = { + id: string; + tenant_id: string; + identity_id: string; + account_id?: string | null; + function_id: string; + organization_unit_id: string; + applies_to_subunits: boolean; + source: string; + delegated_from_assignment_id?: string | null; + acting_for_account_id?: string | null; + valid_from?: string | null; + valid_until?: string | null; + is_active: boolean; + settings: Record; + created_at: string; + updated_at: string; +}; + +export type OrganizationFunctionAssignmentList = { + assignments: OrganizationFunctionAssignmentItem[]; +}; + +export type IdmSettings = { + tenant_id: string; + require_assignment_change_requests: boolean; + audit_detail_level: "summary" | "standard" | "full"; + change_retention_days?: number | null; + settings: Record; + created_at?: string | null; + updated_at?: string | null; +}; + +export type OrganizationFunctionAssignmentPayload = { + identity_id: string; + function_id: string; + account_id?: string | null; + applies_to_subunits?: boolean; + source?: string; + delegated_from_assignment_id?: string | null; + acting_for_account_id?: string | null; + valid_from?: string | null; + valid_until?: string | null; + is_active?: boolean; + settings?: Record; + change_request_id?: string | null; +}; + +export type IdmSettingsPayload = Partial>; + +function post>(settings: ApiSettings, path: string, payload: P): Promise { + return apiFetch(settings, path, { method: "POST", body: JSON.stringify(payload) }); +} + +function patch>(settings: ApiSettings, path: string, payload: P): Promise { + return apiFetch(settings, path, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function getOrganizationModel(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/organizations/model"); +} + +export function getOrganizationFunctionAssignments(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/idm/organization-function-assignments"); +} + +export function getIdmSettings(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/idm/settings"); +} + +export function patchIdmSettings(settings: ApiSettings, payload: IdmSettingsPayload): Promise { + return patch(settings, "/api/v1/idm/settings", payload); +} + +export function searchOrganizationIdentityOptions(settings: ApiSettings, query = "", limit = 50): Promise { + const params = new URLSearchParams(); + const trimmed = query.trim(); + if (trimmed) params.set("query", trimmed); + params.set("limit", String(limit)); + return apiFetch(settings, `/api/v1/idm/organization-identities?${params.toString()}`); +} + +export function createOrganizationFunctionAssignment( + settings: ApiSettings, + payload: OrganizationFunctionAssignmentPayload +): Promise { + return post(settings, "/api/v1/idm/organization-function-assignments", payload); +} + +export function patchOrganizationFunctionAssignment( + settings: ApiSettings, + id: string, + payload: Partial +): Promise { + return patch(settings, `/api/v1/idm/organization-function-assignments/${encodeURIComponent(id)}`, payload); +} diff --git a/webui/src/features/IdmPage.tsx b/webui/src/features/IdmPage.tsx new file mode 100644 index 0000000..467b5bf --- /dev/null +++ b/webui/src/features/IdmPage.tsx @@ -0,0 +1,730 @@ +import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"; +import { Edit3, RefreshCw, Save, XCircle } from "lucide-react"; +import { + ApiError, + Button, + Card, + DataGrid, + DismissibleAlert, + FormField, + LoadingFrame, + PageTitle, + StatusBadge, + hasScope, + useUnsavedDraftGuard, + type ApiSettings, + type AuthInfo, + type DataGridColumn +} from "@govoplan/core-webui"; +import { + createOrganizationFunctionAssignment, + getIdmSettings, + getOrganizationFunctionAssignments, + getOrganizationModel, + patchIdmSettings, + patchOrganizationFunctionAssignment, + searchOrganizationIdentityOptions, + type IdmSettings, + type IdentityOption, + type OrganizationFunctionAssignmentItem, + type OrganizationFunctionAssignmentPayload, + type OrganizationFunctionItem, + type OrganizationModel, + type OrganizationUnitItem +} from "../api/idm"; + +type IdmPageProps = { + settings: ApiSettings; + auth: AuthInfo; +}; + +type AssignmentDraft = { + identity_id: string; + account_id: string; + function_id: string; + applies_to_subunits: boolean; + source: string; + delegated_from_assignment_id: string; + acting_for_account_id: string; + is_active: boolean; +}; + +type SettingsDraft = { + require_assignment_change_requests: boolean; + audit_detail_level: "summary" | "standard" | "full"; + change_retention_days: string; +}; + +const EMPTY_MODEL: OrganizationModel = { + units: [], + functions: [] +}; + +const DEFAULT_IDM_SETTINGS: IdmSettings = { + tenant_id: "", + require_assignment_change_requests: false, + audit_detail_level: "standard", + change_retention_days: null, + settings: {} +}; + +const SOURCE_OPTIONS = [ + { value: "direct", label: "i18n:govoplan-idm.direct.7b2b1f51" }, + { value: "delegated", label: "i18n:govoplan-idm.delegated.b189f4b6" }, + { value: "acting_for", label: "i18n:govoplan-idm.acting_for.8650e6a6" }, + { value: "directory", label: "i18n:govoplan-idm.directory.12e2e859" }, + { value: "governance", label: "i18n:govoplan-idm.governance.b989a277" }, + { value: "system", label: "i18n:govoplan-idm.system.70c07e3f" } +]; + +function emptyAssignmentDraft(): AssignmentDraft { + return { + identity_id: "", + account_id: "", + function_id: "", + applies_to_subunits: false, + source: "direct", + delegated_from_assignment_id: "", + acting_for_account_id: "", + is_active: true + }; +} + +function textOrNull(value: string): string | null { + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function assignmentPayload(draft: AssignmentDraft): OrganizationFunctionAssignmentPayload { + return { + identity_id: draft.identity_id, + account_id: textOrNull(draft.account_id), + function_id: draft.function_id, + applies_to_subunits: draft.applies_to_subunits, + source: draft.source, + delegated_from_assignment_id: textOrNull(draft.delegated_from_assignment_id), + acting_for_account_id: textOrNull(draft.acting_for_account_id), + is_active: draft.is_active, + settings: {} + }; +} + +function assignmentDraftFrom(item: OrganizationFunctionAssignmentItem): AssignmentDraft { + return { + identity_id: item.identity_id, + account_id: item.account_id ?? "", + function_id: item.function_id, + applies_to_subunits: item.applies_to_subunits, + source: item.source || "direct", + delegated_from_assignment_id: item.delegated_from_assignment_id ?? "", + acting_for_account_id: item.acting_for_account_id ?? "", + is_active: item.is_active + }; +} + +function isAssignmentDirty(draft: AssignmentDraft): boolean { + return Boolean( + draft.identity_id || + draft.account_id || + draft.function_id || + draft.applies_to_subunits || + draft.source !== "direct" || + draft.delegated_from_assignment_id || + draft.acting_for_account_id || + !draft.is_active + ); +} + +function settingsDraftFrom(item: IdmSettings | null): SettingsDraft { + const source = item ?? DEFAULT_IDM_SETTINGS; + return { + require_assignment_change_requests: source.require_assignment_change_requests, + audit_detail_level: source.audit_detail_level, + change_retention_days: source.change_retention_days == null ? "" : String(source.change_retention_days) + }; +} + +function settingsPayload(draft: SettingsDraft, item: IdmSettings | null): Pick { + const trimmedDays = draft.change_retention_days.trim(); + return { + require_assignment_change_requests: draft.require_assignment_change_requests, + audit_detail_level: draft.audit_detail_level, + change_retention_days: trimmedDays ? Number(trimmedDays) : null, + settings: item?.settings ?? {} + }; +} + +function isSettingsDirty(draft: SettingsDraft, item: IdmSettings | null): boolean { + const baseline = settingsDraftFrom(item); + return ( + draft.require_assignment_change_requests !== baseline.require_assignment_change_requests || + draft.audit_detail_level !== baseline.audit_detail_level || + draft.change_retention_days.trim() !== baseline.change_retention_days.trim() + ); +} + +function mapById(items: T[]): Map { + return new Map(items.map((item) => [item.id, item])); +} + +function apiErrorMessage(error: unknown): string { + if (error instanceof ApiError) { + try { + const parsed = JSON.parse(error.body) as { detail?: string | { message?: string } }; + if (typeof parsed.detail === "string") return parsed.detail; + if (parsed.detail && typeof parsed.detail.message === "string") return parsed.detail.message; + } catch { + // Fall back to the transport message below. + } + return error.message; + } + if (error instanceof Error) return error.message; + return String(error); +} + +function identityDisplay(identity: IdentityOption | undefined, fallbackId: string): JSX.Element { + if (!identity) return {fallbackId}; + const label = identity.display_name || identity.external_subject || identity.id; + return ( + + {label} + {identity.id} + + ); +} + +function functionLabel(item: OrganizationFunctionItem, unitById: ReadonlyMap): string { + const unitName = unitById.get(item.organization_unit_id)?.name; + return unitName ? `${item.name} (${unitName})` : item.name; +} + +function assignmentOptionLabel( + item: OrganizationFunctionAssignmentItem, + identityById: ReadonlyMap, + functionById: ReadonlyMap, + unitById: ReadonlyMap +): string { + const identity = identityById.get(item.identity_id); + const identityLabel = identity?.display_name || identity?.external_subject || item.identity_id; + const functionItem = functionById.get(item.function_id); + const functionText = functionItem ? functionLabel(functionItem, unitById) : item.function_id; + return `${identityLabel} - ${functionText}`; +} + +function activeStatus(active: boolean): JSX.Element { + return ; +} + +function sourceLabel(value: string): string { + return SOURCE_OPTIONS.find((item) => item.value === value)?.label ?? value; +} + +export default function IdmPage({ settings, auth }: IdmPageProps) { + const [model, setModel] = useState(EMPTY_MODEL); + const [assignments, setAssignments] = useState([]); + const [idmSettings, setIdmSettings] = useState(null); + const [identityOptions, setIdentityOptions] = useState([]); + const [identitySearch, setIdentitySearch] = useState(""); + const [identityLoading, setIdentityLoading] = useState(false); + const [identityLookupAvailable, setIdentityLookupAvailable] = useState(true); + const [actingForSearch, setActingForSearch] = useState(""); + const [actingForOptions, setActingForOptions] = useState([]); + const [actingForLoading, setActingForLoading] = useState(false); + const [assignmentDraft, setAssignmentDraft] = useState(() => emptyAssignmentDraft()); + const [settingsDraft, setSettingsDraft] = useState(() => settingsDraftFrom(null)); + const [editingAssignmentId, setEditingAssignmentId] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + const canManage = hasScope(auth, "idm:organization_assignment:write") || hasScope(auth, "organizations:function:assign"); + const canSearchIdentities = canManage || hasScope(auth, "idm:organization_identity:read") || hasScope(auth, "admin:users:read"); + const canReadSettings = hasScope(auth, "idm:settings:read") || hasScope(auth, "idm:settings:write") || hasScope(auth, "idm:organization_assignment:read") || hasScope(auth, "idm:organization_assignment:write"); + const canManageSettings = hasScope(auth, "idm:settings:write"); + const unitById = useMemo(() => mapById(model.units), [model.units]); + const functionById = useMemo(() => mapById(model.functions), [model.functions]); + const identityOptionById = useMemo(() => mapById(identityOptions), [identityOptions]); + const actingForOptionById = useMemo(() => mapById(actingForOptions), [actingForOptions]); + const selectedIdentity = identityOptionById.get(assignmentDraft.identity_id); + + const identitySelectOptions = useMemo(() => { + if (!assignmentDraft.identity_id || identityOptionById.has(assignmentDraft.identity_id)) return identityOptions; + return [ + { + id: assignmentDraft.identity_id, + display_name: assignmentDraft.identity_id, + external_subject: null, + source: "local", + primary_account_id: assignmentDraft.account_id || null, + account_ids: assignmentDraft.account_id ? [assignmentDraft.account_id] : [], + status: "active" + }, + ...identityOptions + ]; + }, [assignmentDraft.account_id, assignmentDraft.identity_id, identityOptionById, identityOptions]); + + const accountIds = useMemo(() => { + const values = new Set(selectedIdentity?.account_ids ?? []); + if (assignmentDraft.account_id) values.add(assignmentDraft.account_id); + return Array.from(values).sort((left, right) => left.localeCompare(right)); + }, [assignmentDraft.account_id, selectedIdentity]); + const assignmentOptions = useMemo( + () => assignments.filter((item) => item.id !== editingAssignmentId && (!assignmentDraft.function_id || item.function_id === assignmentDraft.function_id)), + [assignmentDraft.function_id, assignments, editingAssignmentId] + ); + const assignmentById = useMemo(() => mapById(assignments), [assignments]); + const sourceAssignment = assignmentDraft.delegated_from_assignment_id ? assignmentById.get(assignmentDraft.delegated_from_assignment_id) : undefined; + const actingForAccountIds = useMemo(() => { + const values = new Set(); + if (sourceAssignment?.account_id) values.add(sourceAssignment.account_id); + const sourceIdentity = sourceAssignment ? identityOptionById.get(sourceAssignment.identity_id) ?? actingForOptionById.get(sourceAssignment.identity_id) : undefined; + for (const accountId of sourceIdentity?.account_ids ?? []) values.add(accountId); + for (const identity of actingForOptions) { + for (const accountId of identity.account_ids) values.add(accountId); + } + if (assignmentDraft.acting_for_account_id) values.add(assignmentDraft.acting_for_account_id); + return Array.from(values).sort((left, right) => left.localeCompare(right)); + }, [actingForOptionById, actingForOptions, assignmentDraft.acting_for_account_id, identityOptionById, sourceAssignment]); + const initialFunctionFilter = useMemo(() => { + if (typeof window === "undefined") return ""; + return new URLSearchParams(window.location.search).get("function_id") || ""; + }, []); + const hasDirtyAssignmentDraft = isAssignmentDirty(assignmentDraft); + const hasDirtySettingsDraft = canReadSettings && isSettingsDirty(settingsDraft, idmSettings); + const hasDirtyDraft = hasDirtyAssignmentDraft || hasDirtySettingsDraft; + + const loadData = useCallback(async () => { + setLoading(true); + setError(""); + try { + const [nextModel, nextAssignments, nextSettings] = await Promise.all([ + getOrganizationModel(settings), + getOrganizationFunctionAssignments(settings), + canReadSettings ? getIdmSettings(settings).catch(() => null) : Promise.resolve(null) + ]); + setModel(nextModel); + setAssignments(nextAssignments.assignments); + setIdmSettings(nextSettings); + if (nextSettings) setSettingsDraft(settingsDraftFrom(nextSettings)); + if (initialFunctionFilter && nextModel.functions.some((item) => item.id === initialFunctionFilter)) { + setAssignmentDraft((draft) => draft.function_id ? draft : { ...draft, function_id: initialFunctionFilter }); + } + if (canSearchIdentities) { + try { + const identities = await searchOrganizationIdentityOptions(settings, "", 100); + setIdentityOptions(identities.identities); + setIdentityLookupAvailable(true); + } catch { + setIdentityLookupAvailable(false); + } + } else { + setIdentityLookupAvailable(false); + } + } catch (caught) { + setError(apiErrorMessage(caught)); + } finally { + setLoading(false); + } + }, [canReadSettings, canSearchIdentities, initialFunctionFilter, settings]); + + useEffect(() => { + void loadData(); + }, [loadData]); + + useEffect(() => { + if (!canSearchIdentities) return; + let disposed = false; + setIdentityLoading(true); + searchOrganizationIdentityOptions(settings, identitySearch, 50) + .then((payload) => { + if (!disposed) { + setIdentityOptions(payload.identities); + setIdentityLookupAvailable(true); + } + }) + .catch(() => { + if (!disposed) setIdentityLookupAvailable(false); + }) + .finally(() => { + if (!disposed) setIdentityLoading(false); + }); + return () => { + disposed = true; + }; + }, [canSearchIdentities, identitySearch, settings]); + + useEffect(() => { + if (!canSearchIdentities || assignmentDraft.source !== "acting_for") return; + let disposed = false; + setActingForLoading(true); + searchOrganizationIdentityOptions(settings, actingForSearch, 50) + .then((payload) => { + if (!disposed) setActingForOptions(payload.identities); + }) + .catch(() => { + if (!disposed) setActingForOptions([]); + }) + .finally(() => { + if (!disposed) setActingForLoading(false); + }); + return () => { + disposed = true; + }; + }, [actingForSearch, assignmentDraft.source, canSearchIdentities, settings]); + + const runAction = useCallback(async (action: () => Promise, successMessage = ""): Promise => { + setBusy(true); + setError(""); + setSuccess(""); + try { + await action(); + if (successMessage) setSuccess(successMessage); + await loadData(); + return true; + } catch (caught) { + setError(apiErrorMessage(caught)); + return false; + } finally { + setBusy(false); + } + }, [loadData]); + + const discardAssignmentDraft = useCallback(() => { + setAssignmentDraft(emptyAssignmentDraft()); + setEditingAssignmentId(null); + }, []); + + const discardDrafts = useCallback(() => { + discardAssignmentDraft(); + setSettingsDraft(settingsDraftFrom(idmSettings)); + }, [discardAssignmentDraft, idmSettings]); + + const submitSettings = useCallback(async (): Promise => { + if (!hasDirtySettingsDraft) return true; + if (!canManageSettings) { + setError("i18n:govoplan-idm.settings_write_permission_required.37f37efa"); + return false; + } + const ok = await runAction( + () => patchIdmSettings(settings, settingsPayload(settingsDraft, idmSettings)), + "i18n:govoplan-idm.settings_updated.9bbd0867" + ); + return ok; + }, [canManageSettings, hasDirtySettingsDraft, idmSettings, runAction, settings, settingsDraft]); + + const submitAssignment = useCallback(async (event?: FormEvent): Promise => { + event?.preventDefault(); + if (!canManage) { + setError("i18n:govoplan-idm.write_permission_required.c7dde7c6"); + return false; + } + if (!assignmentDraft.identity_id) { + setError("i18n:govoplan-idm.identity_is_required.6ad4ee23"); + return false; + } + if (!assignmentDraft.function_id) { + setError("i18n:govoplan-idm.function_is_required.5cce5b41"); + return false; + } + const payload = assignmentPayload(assignmentDraft); + const ok = await runAction( + () => editingAssignmentId ? patchOrganizationFunctionAssignment(settings, editingAssignmentId, payload) : createOrganizationFunctionAssignment(settings, payload), + editingAssignmentId ? "i18n:govoplan-idm.assignment_updated.fbbf9bd6" : "i18n:govoplan-idm.assignment_added.91f1ee42" + ); + if (ok) discardAssignmentDraft(); + return ok; + }, [assignmentDraft, canManage, discardAssignmentDraft, editingAssignmentId, runAction, settings]); + + const saveDrafts = useCallback(async (): Promise => { + if (hasDirtyAssignmentDraft && !(await submitAssignment())) return false; + if (hasDirtySettingsDraft && !(await submitSettings())) return false; + return true; + }, [hasDirtyAssignmentDraft, hasDirtySettingsDraft, submitAssignment, submitSettings]); + + useUnsavedDraftGuard({ + dirty: hasDirtyDraft, + onSave: saveDrafts, + onDiscard: discardDrafts, + title: "i18n:govoplan-core.unsaved_changes.29267269", + message: "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b" + }); + + const editAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => { + setEditingAssignmentId(item.id); + setAssignmentDraft(assignmentDraftFrom(item)); + }, []); + + const toggleAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => { + void runAction(() => patchOrganizationFunctionAssignment(settings, item.id, { is_active: !item.is_active })); + }, [runAction, settings]); + + function onIdentityChange(identityId: string) { + const identity = identityOptionById.get(identityId); + setAssignmentDraft({ + ...assignmentDraft, + identity_id: identityId, + account_id: identity?.primary_account_id ?? identity?.account_ids[0] ?? "" + }); + } + + function onSourceChange(source: string) { + setAssignmentDraft({ + ...assignmentDraft, + source, + delegated_from_assignment_id: source === "delegated" || source === "acting_for" ? assignmentDraft.delegated_from_assignment_id : "", + acting_for_account_id: source === "acting_for" ? assignmentDraft.acting_for_account_id : "" + }); + } + + const assignmentColumns: DataGridColumn[] = [ + { + id: "identity", + header: "i18n:govoplan-idm.identity.544a8347", + minWidth: 220, + sortable: true, + filterable: true, + filterValue: (row) => identityOptionById.get(row.identity_id)?.display_name ?? row.identity_id, + render: (row) => identityDisplay(identityOptionById.get(row.identity_id), row.identity_id) + }, + { + id: "account", + header: "i18n:govoplan-idm.account.2b2936f8", + minWidth: 180, + value: (row) => row.account_id ?? "", + render: (row) => row.account_id ? {row.account_id} : i18n:govoplan-idm.none.2baf5c66 + }, + { + id: "function", + header: "i18n:govoplan-idm.function.e67d5aba", + minWidth: 220, + sortable: true, + filterable: true, + filterValue: (row) => `${functionById.get(row.function_id)?.name ?? ""} ${row.function_id}`.trim(), + render: (row) => { + const item = functionById.get(row.function_id); + return item ? functionLabel(item, unitById) : {row.function_id}; + } + }, + { + id: "unit", + header: "i18n:govoplan-idm.unit.a94c2fbd", + minWidth: 180, + render: (row) => unitById.get(row.organization_unit_id)?.name ?? {row.organization_unit_id} + }, + { + id: "subunits", + header: "i18n:govoplan-idm.applies_to_subunits.2e31b50b", + width: 150, + render: (row) => activeStatus(row.applies_to_subunits) + }, + { + id: "source", + header: "i18n:govoplan-idm.source.d15b50c9", + width: 130, + value: (row) => row.source, + render: (row) => sourceLabel(row.source) + }, + { + id: "status", + header: "i18n:govoplan-idm.status.9acb445d", + width: 120, + render: (row) => activeStatus(row.is_active) + }, + { + id: "actions", + header: "", + width: 220, + sticky: "end", + render: (row) => ( +
+ + +
+ ) + } + ]; + + return ( +
+
+
+ i18n:govoplan-idm.idm.61f4a7a2 +

i18n:govoplan-idm.identity_links_intro.45fed9dd

+
+
+ + {hasDirtyDraft && ( + <> + + + + )} +
+
+ + {error && {error}} + {success && !error && {success}} + {!canManage && i18n:govoplan-idm.write_permission_required.c7dde7c6} + + +
+
+ +
void submitAssignment(event)}> + + setIdentitySearch(event.target.value)} + /> + + + + + + + + + + + + + + {(assignmentDraft.source === "delegated" || assignmentDraft.source === "acting_for") && ( + + + + )} + {assignmentDraft.source === "acting_for" && ( + <> + + setActingForSearch(event.target.value)} + /> + + + + + + )} +
+ + +
+ {!identityLookupAvailable &&

i18n:govoplan-idm.identity_lookup_unavailable.b76f7714

} + {identityLoading &&

i18n:govoplan-idm.loading_identities.f3b84693

} + {actingForLoading &&

i18n:govoplan-idm.loading_acting_for_accounts.c9894b1e

} + {!model.functions.length &&

i18n:govoplan-idm.no_functions_available.51ba08eb

} +
+ + {editingAssignmentId && ( + + )} +
+
+
+ + {canReadSettings && ( + +
{ event.preventDefault(); void submitSettings(); }}> +
+ +
+ + + + + setSettingsDraft({ ...settingsDraft, change_retention_days: event.target.value })} + /> + +
+ +
+
+
+ )} +
+ + + row.id} + emptyText="i18n:govoplan-idm.no_assignments.41193ce8" + initialFilters={initialFunctionFilter ? { function: initialFunctionFilter } : undefined} + initialFit="container" + /> + +
+
+
+ ); +} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts new file mode 100644 index 0000000..4cdba16 --- /dev/null +++ b/webui/src/i18n/generatedTranslations.ts @@ -0,0 +1,126 @@ +import type { PlatformTranslations } from "@govoplan/core-webui"; + +export const generatedTranslations: PlatformTranslations = { + en: { + "i18n:govoplan-idm.account.2b2936f8": "Account", + "i18n:govoplan-idm.active.7bd0e9f8": "Active", + "i18n:govoplan-idm.acting_for.8650e6a6": "acting for", + "i18n:govoplan-idm.acting_for_account_id.5d7ade5b": "Acting for account ID", + "i18n:govoplan-idm.acting_for_search.b7c526c7": "Acting-for account search", + "i18n:govoplan-idm.add_assignment.08f2a0d5": "Add assignment", + "i18n:govoplan-idm.applies_to_subunits.2e31b50b": "Applies to subunits", + "i18n:govoplan-idm.assignment_added.91f1ee42": "Assignment added.", + "i18n:govoplan-idm.assignment_editor.e20598e7": "Assignment editor", + "i18n:govoplan-idm.assignment_updated.fbbf9bd6": "Assignment updated.", + "i18n:govoplan-idm.assignments.a0d19ec5": "Assignments", + "i18n:govoplan-idm.cancel_edit.ea4781e0": "Cancel edit", + "i18n:govoplan-idm.audit_detail_level.eb2e6fd2": "Audit detail level", + "i18n:govoplan-idm.change_retention_days.4a91f7d3": "Change retention days", + "i18n:govoplan-idm.deactivate.585777c8": "Deactivate", + "i18n:govoplan-idm.delegated.b189f4b6": "delegated", + "i18n:govoplan-idm.delegated_from_assignment_id.20d4a548": "Delegated from assignment", + "i18n:govoplan-idm.direct.7b2b1f51": "direct", + "i18n:govoplan-idm.directory.12e2e859": "directory", + "i18n:govoplan-idm.discard_drafts.c0a86816": "Discard drafts", + "i18n:govoplan-idm.edit.a5a0f3cc": "Edit", + "i18n:govoplan-idm.function.e67d5aba": "Function", + "i18n:govoplan-idm.function_is_required.5cce5b41": "Function is required.", + "i18n:govoplan-idm.full.7f021a14": "Full", + "i18n:govoplan-idm.governance.b989a277": "governance", + "i18n:govoplan-idm.identity.544a8347": "Identity", + "i18n:govoplan-idm.identity_is_required.6ad4ee23": "Identity is required.", + "i18n:govoplan-idm.identity_links_intro.45fed9dd": "Link identities to organization functions. Organizations defines the functions; IDM owns who holds them.", + "i18n:govoplan-idm.identity_lookup_unavailable.b76f7714": "Identity lookup is unavailable.", + "i18n:govoplan-idm.identity_search.d3460fcf": "Identity search", + "i18n:govoplan-idm.idm_governance.6e4f3251": "IDM governance", + "i18n:govoplan-idm.idm.61f4a7a2": "IDM", + "i18n:govoplan-idm.inactive.1baa5fba": "Inactive", + "i18n:govoplan-idm.loading_idm_assignments.0b1501bd": "Loading IDM assignments...", + "i18n:govoplan-idm.loading_acting_for_accounts.c9894b1e": "Loading acting-for accounts...", + "i18n:govoplan-idm.loading_identities.f3b84693": "Loading identities...", + "i18n:govoplan-idm.no_assignments.41193ce8": "No identity/function assignments found.", + "i18n:govoplan-idm.no_functions_available.51ba08eb": "No organization functions are available yet.", + "i18n:govoplan-idm.none.2baf5c66": "None", + "i18n:govoplan-idm.reactivate.e4871a43": "Reactivate", + "i18n:govoplan-idm.reload.870ca3ec": "Reload", + "i18n:govoplan-idm.require_assignment_change_requests.697718a1": "Require assignment change requests", + "i18n:govoplan-idm.save_drafts.32a0d60a": "Save drafts", + "i18n:govoplan-idm.save_settings.4602c430": "Save settings", + "i18n:govoplan-idm.search_identities.88a9ef15": "Search by name, subject, account, or ID", + "i18n:govoplan-idm.select_account.982ee1ad": "Select account", + "i18n:govoplan-idm.select_function.2bec86e0": "Select function", + "i18n:govoplan-idm.select_identity.91d31615": "Select identity", + "i18n:govoplan-idm.settings_updated.9bbd0867": "Settings updated.", + "i18n:govoplan-idm.settings_write_permission_required.37f37efa": "You do not have permission to manage IDM settings.", + "i18n:govoplan-idm.source.d15b50c9": "Source", + "i18n:govoplan-idm.standard.6edc51aa": "Standard", + "i18n:govoplan-idm.status.9acb445d": "Status", + "i18n:govoplan-idm.summary.f1c5b7ab": "Summary", + "i18n:govoplan-idm.system.70c07e3f": "system", + "i18n:govoplan-idm.unit.a94c2fbd": "Unit", + "i18n:govoplan-idm.update_assignment.e20f52aa": "Update assignment", + "i18n:govoplan-idm.view_assignments.2d40d6a5": "View assignments", + "i18n:govoplan-idm.write_permission_required.c7dde7c6": "You do not have permission to manage IDM organization assignments." + }, + de: { + "i18n:govoplan-idm.account.2b2936f8": "Konto", + "i18n:govoplan-idm.active.7bd0e9f8": "Aktiv", + "i18n:govoplan-idm.acting_for.8650e6a6": "in Vertretung", + "i18n:govoplan-idm.acting_for_account_id.5d7ade5b": "Konto-ID der Vertretung", + "i18n:govoplan-idm.acting_for_search.b7c526c7": "Suche nach vertretenem Konto", + "i18n:govoplan-idm.add_assignment.08f2a0d5": "Zuordnung hinzufügen", + "i18n:govoplan-idm.applies_to_subunits.2e31b50b": "Gilt für Untereinheiten", + "i18n:govoplan-idm.assignment_added.91f1ee42": "Zuordnung hinzugefügt.", + "i18n:govoplan-idm.assignment_editor.e20598e7": "Zuordnungseditor", + "i18n:govoplan-idm.assignment_updated.fbbf9bd6": "Zuordnung aktualisiert.", + "i18n:govoplan-idm.assignments.a0d19ec5": "Zuordnungen", + "i18n:govoplan-idm.cancel_edit.ea4781e0": "Bearbeitung abbrechen", + "i18n:govoplan-idm.audit_detail_level.eb2e6fd2": "Audit-Detailgrad", + "i18n:govoplan-idm.change_retention_days.4a91f7d3": "Änderungsaufbewahrung in Tagen", + "i18n:govoplan-idm.deactivate.585777c8": "Deaktivieren", + "i18n:govoplan-idm.delegated.b189f4b6": "delegiert", + "i18n:govoplan-idm.delegated_from_assignment_id.20d4a548": "Delegiert von Zuordnung", + "i18n:govoplan-idm.direct.7b2b1f51": "direkt", + "i18n:govoplan-idm.directory.12e2e859": "Verzeichnis", + "i18n:govoplan-idm.discard_drafts.c0a86816": "Entwürfe verwerfen", + "i18n:govoplan-idm.edit.a5a0f3cc": "Bearbeiten", + "i18n:govoplan-idm.function.e67d5aba": "Funktion", + "i18n:govoplan-idm.function_is_required.5cce5b41": "Funktion ist erforderlich.", + "i18n:govoplan-idm.full.7f021a14": "Vollständig", + "i18n:govoplan-idm.governance.b989a277": "Governance", + "i18n:govoplan-idm.identity.544a8347": "Identität", + "i18n:govoplan-idm.identity_is_required.6ad4ee23": "Identität ist erforderlich.", + "i18n:govoplan-idm.identity_links_intro.45fed9dd": "Verknüpfe Identitäten mit Organisationsfunktionen. Organisationen definiert die Funktionen; IDM verwaltet, wer sie innehat.", + "i18n:govoplan-idm.identity_lookup_unavailable.b76f7714": "Identitätssuche ist nicht verfügbar.", + "i18n:govoplan-idm.identity_search.d3460fcf": "Identitätssuche", + "i18n:govoplan-idm.idm_governance.6e4f3251": "IDM-Governance", + "i18n:govoplan-idm.idm.61f4a7a2": "IDM", + "i18n:govoplan-idm.inactive.1baa5fba": "Inaktiv", + "i18n:govoplan-idm.loading_idm_assignments.0b1501bd": "IDM-Zuordnungen werden geladen...", + "i18n:govoplan-idm.loading_acting_for_accounts.c9894b1e": "Vertretene Konten werden geladen...", + "i18n:govoplan-idm.loading_identities.f3b84693": "Identitäten werden geladen...", + "i18n:govoplan-idm.no_assignments.41193ce8": "Keine Identitäts-/Funktionszuordnungen gefunden.", + "i18n:govoplan-idm.no_functions_available.51ba08eb": "Es sind noch keine Organisationsfunktionen verfügbar.", + "i18n:govoplan-idm.none.2baf5c66": "Keine", + "i18n:govoplan-idm.reactivate.e4871a43": "Reaktivieren", + "i18n:govoplan-idm.reload.870ca3ec": "Neu laden", + "i18n:govoplan-idm.require_assignment_change_requests.697718a1": "Änderungsanträge für Zuordnungen verlangen", + "i18n:govoplan-idm.save_drafts.32a0d60a": "Entwürfe speichern", + "i18n:govoplan-idm.save_settings.4602c430": "Einstellungen speichern", + "i18n:govoplan-idm.search_identities.88a9ef15": "Nach Name, Subject, Konto oder ID suchen", + "i18n:govoplan-idm.select_account.982ee1ad": "Konto auswählen", + "i18n:govoplan-idm.select_function.2bec86e0": "Funktion auswählen", + "i18n:govoplan-idm.select_identity.91d31615": "Identität auswählen", + "i18n:govoplan-idm.settings_updated.9bbd0867": "Einstellungen aktualisiert.", + "i18n:govoplan-idm.settings_write_permission_required.37f37efa": "Du hast keine Berechtigung, IDM-Einstellungen zu verwalten.", + "i18n:govoplan-idm.source.d15b50c9": "Quelle", + "i18n:govoplan-idm.standard.6edc51aa": "Standard", + "i18n:govoplan-idm.status.9acb445d": "Status", + "i18n:govoplan-idm.summary.f1c5b7ab": "Zusammenfassung", + "i18n:govoplan-idm.system.70c07e3f": "System", + "i18n:govoplan-idm.unit.a94c2fbd": "Einheit", + "i18n:govoplan-idm.update_assignment.e20f52aa": "Zuordnung aktualisieren", + "i18n:govoplan-idm.view_assignments.2d40d6a5": "Zuordnungen anzeigen", + "i18n:govoplan-idm.write_permission_required.c7dde7c6": "Du hast keine Berechtigung, IDM-Organisationszuordnungen zu verwalten." + } +}; diff --git a/webui/src/index.ts b/webui/src/index.ts new file mode 100644 index 0000000..cc9af68 --- /dev/null +++ b/webui/src/index.ts @@ -0,0 +1 @@ +export { idmModule, default } from "./module"; diff --git a/webui/src/module.ts b/webui/src/module.ts new file mode 100644 index 0000000..bd6ff95 --- /dev/null +++ b/webui/src/module.ts @@ -0,0 +1,72 @@ +import { createElement, lazy } from "react"; +import { Button, type OrganizationFunctionActionsUiCapability, type PlatformWebModule } from "@govoplan/core-webui"; +import { generatedTranslations } from "./i18n/generatedTranslations"; +import "./styles/idm.css"; + +const IdmPage = lazy(() => import("./features/IdmPage")); + +const idmReadScopes = [ + "idm:organization_assignment:read", + "idm:organization_assignment:write", + "organizations:function:assign" +]; + +const translations = { + en: generatedTranslations.en, + de: generatedTranslations.de +}; + +const organizationFunctionActions: OrganizationFunctionActionsUiCapability = { + actions: [ + { + id: "idm.view-function-assignments", + label: "i18n:govoplan-idm.assignments.a0d19ec5", + anyOf: idmReadScopes, + order: 40, + render: ({ function: item }) => createElement( + Button, + { + 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" + ) + } + ] +}; + +export const idmModule: PlatformWebModule = { + id: "idm", + label: "i18n:govoplan-idm.idm.61f4a7a2", + version: "0.1.6", + dependencies: ["identity", "organizations"], + translations, + navItems: [ + { + to: "/idm", + label: "i18n:govoplan-idm.idm.61f4a7a2", + iconName: "users", + anyOf: idmReadScopes, + order: 72 + } + ], + routes: [ + { + path: "/idm", + anyOf: idmReadScopes, + order: 72, + render: ({ settings, auth }) => createElement(IdmPage, { settings, auth }) + } + ], + uiCapabilities: { + "organizations.functionActions": organizationFunctionActions + } +}; + +export default idmModule; diff --git a/webui/src/styles/idm.css b/webui/src/styles/idm.css new file mode 100644 index 0000000..5a87dba --- /dev/null +++ b/webui/src/styles/idm.css @@ -0,0 +1,100 @@ +.idm-page { + display: grid; + gap: 18px; + width: 100%; + max-width: 1480px; +} + +.idm-heading { + margin-bottom: 4px; +} + +.idm-toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.idm-layout { + display: grid; + grid-template-columns: minmax(300px, 420px) minmax(0, 1fr); + gap: 18px; + align-items: start; +} + +.idm-editor-stack { + display: grid; + gap: 18px; +} + +.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 { + display: grid; + gap: 10px; + grid-column: 1 / -1; +} + +.idm-check-list label { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text); + font-weight: 600; +} + +.idm-form-actions, +.idm-row-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.idm-row-actions { + justify-content: flex-end; +} + +.idm-identity { + display: grid; + gap: 2px; + min-width: 0; +} + +.idm-id { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + color: var(--muted); +} + +.idm-muted { + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.idm-empty { + color: var(--muted); +} + +@media (max-width: 1100px) { + .idm-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 900px) { + .idm-form-grid { + grid-template-columns: 1fr; + } +}