Sync GovOPlaN module state
This commit is contained in:
348
.gitignore
vendored
Normal file
348
.gitignore
vendored
Normal file
@@ -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/
|
||||||
74
README.md
74
README.md
@@ -15,6 +15,9 @@ vendor or protocol.
|
|||||||
|
|
||||||
- inbound synchronization from external identity-management systems
|
- inbound synchronization from external identity-management systems
|
||||||
- identity lifecycle import, update, disable, and reconciliation jobs
|
- 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,
|
- mapping external identities, accounts, groups, organizational units,
|
||||||
functions, and attributes to GovOPlaN identity, organization, access, and
|
functions, and attributes to GovOPlaN identity, organization, access, and
|
||||||
tenancy DTOs
|
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
|
ask access, tenancy, or future directory capabilities for normalized identity
|
||||||
facts.
|
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
|
## First Milestone
|
||||||
|
|
||||||
The first useful milestone is a read-only synchronization preview:
|
The first useful milestone is a read-only synchronization preview:
|
||||||
|
|||||||
36
package.json
Normal file
36
package.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
@@ -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"
|
||||||
1
src/govoplan_idm/__init__.py
Normal file
1
src/govoplan_idm/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""GovOPlaN identity management bridge module."""
|
||||||
1
src/govoplan_idm/backend/__init__.py
Normal file
1
src/govoplan_idm/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Backend integration for the GovOPlaN IDM module."""
|
||||||
1
src/govoplan_idm/backend/api/__init__.py
Normal file
1
src/govoplan_idm/backend/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""IDM API package."""
|
||||||
1
src/govoplan_idm/backend/api/v1/__init__.py
Normal file
1
src/govoplan_idm/backend/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""IDM API v1 package."""
|
||||||
544
src/govoplan_idm/backend/api/v1/routes.py
Normal file
544
src/govoplan_idm/backend/api/v1/routes.py
Normal file
@@ -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",
|
||||||
|
)
|
||||||
94
src/govoplan_idm/backend/api/v1/schemas.py
Normal file
94
src/govoplan_idm/backend/api/v1/schemas.py
Normal file
@@ -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
|
||||||
1
src/govoplan_idm/backend/db/__init__.py
Normal file
1
src/govoplan_idm/backend/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""IDM database models."""
|
||||||
55
src/govoplan_idm/backend/db/models.py
Normal file
55
src/govoplan_idm/backend/db/models.py
Normal file
@@ -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"]
|
||||||
93
src/govoplan_idm/backend/directory.py
Normal file
93
src/govoplan_idm/backend/directory.py
Normal file
@@ -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())
|
||||||
186
src/govoplan_idm/backend/manifest.py
Normal file
186
src/govoplan_idm/backend/manifest.py
Normal file
@@ -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
|
||||||
1
src/govoplan_idm/backend/migrations/__init__.py
Normal file
1
src/govoplan_idm/backend/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""IDM database migrations."""
|
||||||
@@ -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")
|
||||||
1
src/govoplan_idm/backend/migrations/versions/__init__.py
Normal file
1
src/govoplan_idm/backend/migrations/versions/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""IDM migration revisions."""
|
||||||
1
src/govoplan_idm/py.typed
Normal file
1
src/govoplan_idm/py.typed
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
31
webui/package.json
Normal file
31
webui/package.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
147
webui/src/api/idm.ts
Normal file
147
webui/src/api/idm.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
change_request_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IdmSettingsPayload = Partial<Pick<IdmSettings, "require_assignment_change_requests" | "audit_detail_level" | "change_retention_days" | "settings">>;
|
||||||
|
|
||||||
|
function post<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> {
|
||||||
|
return apiFetch<T>(settings, path, { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function patch<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> {
|
||||||
|
return apiFetch<T>(settings, path, { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrganizationModel(settings: ApiSettings): Promise<OrganizationModel> {
|
||||||
|
return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrganizationFunctionAssignments(settings: ApiSettings): Promise<OrganizationFunctionAssignmentList> {
|
||||||
|
return apiFetch<OrganizationFunctionAssignmentList>(settings, "/api/v1/idm/organization-function-assignments");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIdmSettings(settings: ApiSettings): Promise<IdmSettings> {
|
||||||
|
return apiFetch<IdmSettings>(settings, "/api/v1/idm/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchIdmSettings(settings: ApiSettings, payload: IdmSettingsPayload): Promise<IdmSettings> {
|
||||||
|
return patch(settings, "/api/v1/idm/settings", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchOrganizationIdentityOptions(settings: ApiSettings, query = "", limit = 50): Promise<IdentityListResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (trimmed) params.set("query", trimmed);
|
||||||
|
params.set("limit", String(limit));
|
||||||
|
return apiFetch<IdentityListResponse>(settings, `/api/v1/idm/organization-identities?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOrganizationFunctionAssignment(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: OrganizationFunctionAssignmentPayload
|
||||||
|
): Promise<OrganizationFunctionAssignmentItem> {
|
||||||
|
return post(settings, "/api/v1/idm/organization-function-assignments", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchOrganizationFunctionAssignment(
|
||||||
|
settings: ApiSettings,
|
||||||
|
id: string,
|
||||||
|
payload: Partial<OrganizationFunctionAssignmentPayload>
|
||||||
|
): Promise<OrganizationFunctionAssignmentItem> {
|
||||||
|
return patch(settings, `/api/v1/idm/organization-function-assignments/${encodeURIComponent(id)}`, payload);
|
||||||
|
}
|
||||||
730
webui/src/features/IdmPage.tsx
Normal file
730
webui/src/features/IdmPage.tsx
Normal file
@@ -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<IdmSettings, "require_assignment_change_requests" | "audit_detail_level" | "change_retention_days" | "settings"> {
|
||||||
|
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<T extends { id: string }>(items: T[]): Map<string, T> {
|
||||||
|
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 <span className="idm-id">{fallbackId}</span>;
|
||||||
|
const label = identity.display_name || identity.external_subject || identity.id;
|
||||||
|
return (
|
||||||
|
<span className="idm-identity">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="idm-id">{identity.id}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function functionLabel(item: OrganizationFunctionItem, unitById: ReadonlyMap<string, OrganizationUnitItem>): string {
|
||||||
|
const unitName = unitById.get(item.organization_unit_id)?.name;
|
||||||
|
return unitName ? `${item.name} (${unitName})` : item.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignmentOptionLabel(
|
||||||
|
item: OrganizationFunctionAssignmentItem,
|
||||||
|
identityById: ReadonlyMap<string, IdentityOption>,
|
||||||
|
functionById: ReadonlyMap<string, OrganizationFunctionItem>,
|
||||||
|
unitById: ReadonlyMap<string, OrganizationUnitItem>
|
||||||
|
): 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 <StatusBadge status={active ? "success" : "inactive"} label={active ? "i18n:govoplan-idm.active.7bd0e9f8" : "i18n:govoplan-idm.inactive.1baa5fba"} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<OrganizationModel>(EMPTY_MODEL);
|
||||||
|
const [assignments, setAssignments] = useState<OrganizationFunctionAssignmentItem[]>([]);
|
||||||
|
const [idmSettings, setIdmSettings] = useState<IdmSettings | null>(null);
|
||||||
|
const [identityOptions, setIdentityOptions] = useState<IdentityOption[]>([]);
|
||||||
|
const [identitySearch, setIdentitySearch] = useState("");
|
||||||
|
const [identityLoading, setIdentityLoading] = useState(false);
|
||||||
|
const [identityLookupAvailable, setIdentityLookupAvailable] = useState(true);
|
||||||
|
const [actingForSearch, setActingForSearch] = useState("");
|
||||||
|
const [actingForOptions, setActingForOptions] = useState<IdentityOption[]>([]);
|
||||||
|
const [actingForLoading, setActingForLoading] = useState(false);
|
||||||
|
const [assignmentDraft, setAssignmentDraft] = useState<AssignmentDraft>(() => emptyAssignmentDraft());
|
||||||
|
const [settingsDraft, setSettingsDraft] = useState<SettingsDraft>(() => settingsDraftFrom(null));
|
||||||
|
const [editingAssignmentId, setEditingAssignmentId] = useState<string | null>(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<string>();
|
||||||
|
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<unknown>, successMessage = ""): Promise<boolean> => {
|
||||||
|
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<boolean> => {
|
||||||
|
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<boolean> => {
|
||||||
|
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<boolean> => {
|
||||||
|
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<OrganizationFunctionAssignmentItem>[] = [
|
||||||
|
{
|
||||||
|
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 ? <span className="idm-id">{row.account_id}</span> : <span className="idm-empty">i18n:govoplan-idm.none.2baf5c66</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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) : <span className="idm-id">{row.function_id}</span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "unit",
|
||||||
|
header: "i18n:govoplan-idm.unit.a94c2fbd",
|
||||||
|
minWidth: 180,
|
||||||
|
render: (row) => unitById.get(row.organization_unit_id)?.name ?? <span className="idm-id">{row.organization_unit_id}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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) => (
|
||||||
|
<div className="idm-row-actions">
|
||||||
|
<Button type="button" variant="ghost" disabled={!canManage || busy} onClick={() => editAssignment(row)} title="i18n:govoplan-idm.edit.a5a0f3cc">
|
||||||
|
<Edit3 size={16} aria-hidden="true" /> i18n:govoplan-idm.edit.a5a0f3cc
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant={row.is_active ? "secondary" : "primary"} disabled={!canManage || busy} onClick={() => toggleAssignment(row)}>
|
||||||
|
{row.is_active ? "i18n:govoplan-idm.deactivate.585777c8" : "i18n:govoplan-idm.reactivate.e4871a43"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="content-pad idm-page">
|
||||||
|
<div className="page-heading split idm-heading">
|
||||||
|
<div>
|
||||||
|
<PageTitle loading={loading}>i18n:govoplan-idm.idm.61f4a7a2</PageTitle>
|
||||||
|
<p>i18n:govoplan-idm.identity_links_intro.45fed9dd</p>
|
||||||
|
</div>
|
||||||
|
<div className="idm-toolbar">
|
||||||
|
<Button type="button" onClick={() => void loadData()} disabled={loading || busy} title="i18n:govoplan-idm.reload.870ca3ec">
|
||||||
|
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-idm.reload.870ca3ec
|
||||||
|
</Button>
|
||||||
|
{hasDirtyDraft && (
|
||||||
|
<>
|
||||||
|
<Button type="button" onClick={() => void saveDrafts()} disabled={busy} title="i18n:govoplan-idm.save_drafts.32a0d60a">
|
||||||
|
<Save size={16} aria-hidden="true" /> i18n:govoplan-idm.save_drafts.32a0d60a
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" onClick={discardDrafts} disabled={busy} title="i18n:govoplan-idm.discard_drafts.c0a86816">
|
||||||
|
<XCircle size={16} aria-hidden="true" /> i18n:govoplan-idm.discard_drafts.c0a86816
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
|
{success && !error && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||||
|
{!canManage && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-idm.write_permission_required.c7dde7c6</DismissibleAlert>}
|
||||||
|
|
||||||
|
<LoadingFrame loading={loading || busy} label="i18n:govoplan-idm.loading_idm_assignments.0b1501bd">
|
||||||
|
<div className="idm-layout">
|
||||||
|
<div className="idm-editor-stack">
|
||||||
|
<Card title="i18n:govoplan-idm.assignment_editor.e20598e7">
|
||||||
|
<form className="idm-form-grid" onSubmit={(event) => void submitAssignment(event)}>
|
||||||
|
<FormField label="i18n:govoplan-idm.identity_search.d3460fcf">
|
||||||
|
<input
|
||||||
|
value={identitySearch}
|
||||||
|
placeholder="i18n:govoplan-idm.search_identities.88a9ef15"
|
||||||
|
disabled={!canSearchIdentities || busy}
|
||||||
|
onChange={(event) => setIdentitySearch(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="i18n:govoplan-idm.select_identity.91d31615">
|
||||||
|
<select value={assignmentDraft.identity_id} disabled={!canManage || busy} onChange={(event) => onIdentityChange(event.target.value)}>
|
||||||
|
<option value="">i18n:govoplan-idm.select_identity.91d31615</option>
|
||||||
|
{identitySelectOptions.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.display_name || item.external_subject || item.id}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="i18n:govoplan-idm.account.2b2936f8">
|
||||||
|
<select value={assignmentDraft.account_id} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, account_id: event.target.value })}>
|
||||||
|
<option value="">i18n:govoplan-idm.none.2baf5c66</option>
|
||||||
|
{accountIds.map((accountId) => <option key={accountId} value={accountId}>{accountId}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="i18n:govoplan-idm.select_function.2bec86e0">
|
||||||
|
<select value={assignmentDraft.function_id} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, function_id: event.target.value })}>
|
||||||
|
<option value="">i18n:govoplan-idm.select_function.2bec86e0</option>
|
||||||
|
{model.functions.map((item) => <option key={item.id} value={item.id}>{functionLabel(item, unitById)}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="i18n:govoplan-idm.source.d15b50c9">
|
||||||
|
<select value={assignmentDraft.source} disabled={!canManage || busy} onChange={(event) => onSourceChange(event.target.value)}>
|
||||||
|
{SOURCE_OPTIONS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{(assignmentDraft.source === "delegated" || assignmentDraft.source === "acting_for") && (
|
||||||
|
<FormField label="i18n:govoplan-idm.delegated_from_assignment_id.20d4a548">
|
||||||
|
<select value={assignmentDraft.delegated_from_assignment_id} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, delegated_from_assignment_id: event.target.value, acting_for_account_id: "" })}>
|
||||||
|
<option value="">i18n:govoplan-idm.none.2baf5c66</option>
|
||||||
|
{assignmentOptions.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{assignmentOptionLabel(item, identityOptionById, functionById, unitById)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
{assignmentDraft.source === "acting_for" && (
|
||||||
|
<>
|
||||||
|
<FormField label="i18n:govoplan-idm.acting_for_search.b7c526c7">
|
||||||
|
<input
|
||||||
|
value={actingForSearch}
|
||||||
|
placeholder="i18n:govoplan-idm.search_identities.88a9ef15"
|
||||||
|
disabled={!canSearchIdentities || busy}
|
||||||
|
onChange={(event) => setActingForSearch(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="i18n:govoplan-idm.acting_for_account_id.5d7ade5b">
|
||||||
|
<select value={assignmentDraft.acting_for_account_id} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, acting_for_account_id: event.target.value })}>
|
||||||
|
<option value="">i18n:govoplan-idm.select_account.982ee1ad</option>
|
||||||
|
{actingForAccountIds.map((accountId) => <option key={accountId} value={accountId}>{accountId}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="idm-check-list">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={assignmentDraft.applies_to_subunits} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, applies_to_subunits: event.target.checked })} />
|
||||||
|
<span>i18n:govoplan-idm.applies_to_subunits.2e31b50b</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={assignmentDraft.is_active} disabled={!canManage || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, is_active: event.target.checked })} />
|
||||||
|
<span>i18n:govoplan-idm.active.7bd0e9f8</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{!identityLookupAvailable && <p className="idm-muted wide">i18n:govoplan-idm.identity_lookup_unavailable.b76f7714</p>}
|
||||||
|
{identityLoading && <p className="idm-muted wide">i18n:govoplan-idm.loading_identities.f3b84693</p>}
|
||||||
|
{actingForLoading && <p className="idm-muted wide">i18n:govoplan-idm.loading_acting_for_accounts.c9894b1e</p>}
|
||||||
|
{!model.functions.length && <p className="idm-muted wide">i18n:govoplan-idm.no_functions_available.51ba08eb</p>}
|
||||||
|
<div className="idm-form-actions wide">
|
||||||
|
<Button type="submit" variant="primary" disabled={!canManage || busy}>
|
||||||
|
{editingAssignmentId ? "i18n:govoplan-idm.update_assignment.e20f52aa" : "i18n:govoplan-idm.add_assignment.08f2a0d5"}
|
||||||
|
</Button>
|
||||||
|
{editingAssignmentId && (
|
||||||
|
<Button type="button" variant="ghost" onClick={discardAssignmentDraft} disabled={busy}>i18n:govoplan-idm.cancel_edit.ea4781e0</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{canReadSettings && (
|
||||||
|
<Card title="i18n:govoplan-idm.idm_governance.6e4f3251">
|
||||||
|
<form className="idm-form-grid" onSubmit={(event) => { event.preventDefault(); void submitSettings(); }}>
|
||||||
|
<div className="idm-check-list wide">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settingsDraft.require_assignment_change_requests}
|
||||||
|
disabled={!canManageSettings || busy}
|
||||||
|
onChange={(event) => setSettingsDraft({ ...settingsDraft, require_assignment_change_requests: event.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>i18n:govoplan-idm.require_assignment_change_requests.697718a1</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<FormField label="i18n:govoplan-idm.audit_detail_level.eb2e6fd2">
|
||||||
|
<select
|
||||||
|
value={settingsDraft.audit_detail_level}
|
||||||
|
disabled={!canManageSettings || busy}
|
||||||
|
onChange={(event) => setSettingsDraft({ ...settingsDraft, audit_detail_level: event.target.value as SettingsDraft["audit_detail_level"] })}
|
||||||
|
>
|
||||||
|
<option value="summary">i18n:govoplan-idm.summary.f1c5b7ab</option>
|
||||||
|
<option value="standard">i18n:govoplan-idm.standard.6edc51aa</option>
|
||||||
|
<option value="full">i18n:govoplan-idm.full.7f021a14</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="i18n:govoplan-idm.change_retention_days.4a91f7d3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={settingsDraft.change_retention_days}
|
||||||
|
disabled={!canManageSettings || busy}
|
||||||
|
onChange={(event) => setSettingsDraft({ ...settingsDraft, change_retention_days: event.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="idm-form-actions wide">
|
||||||
|
<Button type="submit" variant="primary" disabled={!canManageSettings || busy || !hasDirtySettingsDraft}>
|
||||||
|
i18n:govoplan-idm.save_settings.4602c430
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card title="i18n:govoplan-idm.assignments.a0d19ec5">
|
||||||
|
<DataGrid
|
||||||
|
id="idm-organization-function-assignments"
|
||||||
|
rows={assignments}
|
||||||
|
columns={assignmentColumns}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
emptyText="i18n:govoplan-idm.no_assignments.41193ce8"
|
||||||
|
initialFilters={initialFunctionFilter ? { function: initialFunctionFilter } : undefined}
|
||||||
|
initialFit="container"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</LoadingFrame>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
webui/src/i18n/generatedTranslations.ts
Normal file
126
webui/src/i18n/generatedTranslations.ts
Normal file
@@ -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."
|
||||||
|
}
|
||||||
|
};
|
||||||
1
webui/src/index.ts
Normal file
1
webui/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { idmModule, default } from "./module";
|
||||||
72
webui/src/module.ts
Normal file
72
webui/src/module.ts
Normal file
@@ -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;
|
||||||
100
webui/src/styles/idm.css
Normal file
100
webui/src/styles/idm.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user