Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bcd2de587 | ||
|
|
85b5f80c59 | ||
|
|
6e9393c9c7 | ||
|
|
bd72a3f277 | ||
|
|
09a4b9ce60 | ||
|
|
2c83db3b49 | ||
|
|
bbf9c824f1 | ||
|
|
ad37b030e2 | ||
|
|
d8643174d7 | ||
|
|
6f6c45f6e2 | ||
|
|
820ea5eeab | ||
|
|
43feca0244 | ||
|
|
c14719d55a | ||
|
|
f025b0c25b | ||
|
|
d1c5738ca8 | ||
|
|
ebb3b82cf8 | ||
|
|
94c1d08519 | ||
|
|
15559a8fdf | ||
|
|
01c1f7e13a | ||
|
|
5a8138ea03 | ||
|
|
906879caf1 | ||
|
|
dd1937a3af | ||
|
|
a0c9e59c34 | ||
|
|
853125c80c | ||
|
|
d098e3e9dd | ||
|
|
d2e34b323a | ||
|
|
74652686ca | ||
|
|
b47f71916d | ||
|
|
b9b371a704 | ||
|
|
3b0e8bc600 | ||
|
|
05a10846a8 | ||
|
|
b09c1449b9 | ||
|
|
2751849af1 |
@@ -0,0 +1,270 @@
|
|||||||
|
name: Module Package Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
description: Existing protected version tag to publish
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-packages:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Select and validate protected release tag
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||||
|
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||||
|
case "$tag" in
|
||||||
|
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||||
|
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||||
|
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||||
|
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||||
|
echo "Release tag is not contained in main" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
git checkout --detach "$tag"
|
||||||
|
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||||
|
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||||
|
- name: Validate package versions
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
tag = os.environ["RELEASE_TAG"]
|
||||||
|
expected = tag.removeprefix("v")
|
||||||
|
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
if project.get("version") != expected:
|
||||||
|
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||||
|
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||||
|
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||||
|
webui = Path("webui/package.json")
|
||||||
|
if webui.is_file():
|
||||||
|
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||||
|
if package.get("version") != expected:
|
||||||
|
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||||
|
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||||
|
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||||
|
release = Path("webui/package.release.json")
|
||||||
|
if release.is_file():
|
||||||
|
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||||
|
if (
|
||||||
|
release_package.get("name") != package.get("name")
|
||||||
|
or release_package.get("version") != expected
|
||||||
|
):
|
||||||
|
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||||
|
PY
|
||||||
|
- name: Build immutable package artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||||
|
rm -rf dist .package-webui
|
||||||
|
python -m build --wheel --outdir dist
|
||||||
|
python -m twine check dist/*.whl
|
||||||
|
if [[ -f webui/package.json ]]; then
|
||||||
|
mkdir .package-webui
|
||||||
|
cp -a webui/. .package-webui/
|
||||||
|
rm -rf .package-webui/node_modules .package-webui/dist
|
||||||
|
if [[ -f .package-webui/package.release.json ]]; then
|
||||||
|
cp .package-webui/package.release.json .package-webui/package.json
|
||||||
|
fi
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = ".package-webui/package.json";
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||||
|
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||||
|
if (!name.startsWith("@govoplan/")) continue;
|
||||||
|
if (typeof specifier !== "string") {
|
||||||
|
throw new Error(`${group}.${name} must use a string version`);
|
||||||
|
}
|
||||||
|
const packageSlug = name.slice("@govoplan/".length);
|
||||||
|
if (!packageSlug.endsWith("-webui")) {
|
||||||
|
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||||
|
}
|
||||||
|
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||||
|
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const gitTag = specifier.match(
|
||||||
|
new RegExp(
|
||||||
|
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (gitTag) {
|
||||||
|
packageJson[group][name] = gitTag[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||||
|
throw new Error(
|
||||||
|
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete packageJson.private;
|
||||||
|
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
npm pkg delete private --prefix .package-webui
|
||||||
|
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||||
|
fi
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
artifacts = []
|
||||||
|
for path in sorted(Path("dist").iterdir()):
|
||||||
|
if path.suffix not in {".whl", ".tgz"}:
|
||||||
|
continue
|
||||||
|
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||||
|
payload = {
|
||||||
|
"schema_version": "1",
|
||||||
|
"repository": os.environ["GITEA_REPOSITORY"],
|
||||||
|
"tag": os.environ["RELEASE_TAG"],
|
||||||
|
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||||
|
"artifacts": artifacts,
|
||||||
|
}
|
||||||
|
Path("dist/package-artifacts.json").write_text(
|
||||||
|
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
- name: Retain package hash evidence
|
||||||
|
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||||
|
with:
|
||||||
|
name: module-packages-${{ gitea.ref_name }}
|
||||||
|
path: dist/package-artifacts.json
|
||||||
|
- name: Check immutable registry state
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||||
|
token = os.environ["PACKAGE_TOKEN"]
|
||||||
|
|
||||||
|
def should_publish(kind, name, version, path):
|
||||||
|
package_url = "/".join(
|
||||||
|
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||||
|
)
|
||||||
|
request = Request(
|
||||||
|
package_url,
|
||||||
|
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=30) as response:
|
||||||
|
files = json.load(response)
|
||||||
|
except HTTPError as exc:
|
||||||
|
if exc.code == 404:
|
||||||
|
print(f"{kind} package {name}=={version} is not published yet")
|
||||||
|
return True
|
||||||
|
raise
|
||||||
|
if not isinstance(files, list) or len(files) != 1:
|
||||||
|
raise SystemExit(
|
||||||
|
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||||
|
)
|
||||||
|
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
if files[0].get("sha256") != expected_sha256:
|
||||||
|
raise SystemExit(
|
||||||
|
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||||
|
)
|
||||||
|
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
wheels = tuple(Path("dist").glob("*.whl"))
|
||||||
|
if len(wheels) != 1:
|
||||||
|
raise SystemExit("release build must contain exactly one wheel")
|
||||||
|
publish_pypi = should_publish(
|
||||||
|
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||||
|
if len(tarballs) > 1:
|
||||||
|
raise SystemExit("release build must contain at most one npm package")
|
||||||
|
publish_npm = False
|
||||||
|
if tarballs:
|
||||||
|
webui = json.loads(
|
||||||
|
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
publish_npm = should_publish(
|
||||||
|
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||||
|
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||||
|
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||||
|
PY
|
||||||
|
- name: Publish wheel and WebUI package
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_USERNAME"
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||||
|
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||||
|
python -m twine upload --non-interactive \
|
||||||
|
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||||
|
dist/*.whl
|
||||||
|
else
|
||||||
|
echo "Exact wheel is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
|
shopt -s nullglob
|
||||||
|
webui_packages=(dist/*.tgz)
|
||||||
|
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||||
|
npmrc="$(mktemp)"
|
||||||
|
trap 'rm -f "$npmrc"' EXIT
|
||||||
|
chmod 600 "$npmrc"
|
||||||
|
printf '%s\n' \
|
||||||
|
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||||
|
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||||
|
> "$npmrc"
|
||||||
|
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||||
|
--ignore-scripts --access public \
|
||||||
|
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||||
|
elif (( ${#webui_packages[@]} )); then
|
||||||
|
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
+348
@@ -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/
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# GovOPlaN IDM Codex Guide
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This repository owns identity-to-organization function assignments, governed request/grant workflows, effective dates, and external directory reconciliation.
|
||||||
|
|
||||||
|
## Documentation Contract
|
||||||
|
|
||||||
|
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
|
||||||
|
- Keep feature content here; `govoplan-docs` projects it without importing IDM internals.
|
||||||
|
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- Identity owns people; Organizations owns structures and functions; Access maps resulting facts to authority.
|
||||||
|
- Preserve effective dates, provenance, approval evidence, and reconciliation state.
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# GovOPlaN IDM
|
||||||
|
|
||||||
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (platform).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
|
`govoplan-idm` is the planned integration module for external identity
|
||||||
|
management systems. It does not own GovOPlaN's internal identity, organization,
|
||||||
|
account, role, or tenant tables; those remain with `govoplan-identity`,
|
||||||
|
`govoplan-organizations`, `govoplan-access`, and `govoplan-tenancy`.
|
||||||
|
|
||||||
|
The module's purpose is to connect GovOPlaN to upstream identity providers and
|
||||||
|
directory-management processes without making the platform depend on a single
|
||||||
|
vendor or protocol.
|
||||||
|
|
||||||
|
## Boundary
|
||||||
|
|
||||||
|
`govoplan-idm` should own:
|
||||||
|
|
||||||
|
- inbound synchronization from external identity-management systems
|
||||||
|
- identity lifecycle import, update, disable, and reconciliation jobs
|
||||||
|
- identity-to-organization-function assignment links inside GovOPlaN
|
||||||
|
- typed business groups and effective-dated identity relationship links
|
||||||
|
- bridge views that combine identity and organization facts, such as identity
|
||||||
|
candidates for organization function assignments
|
||||||
|
- mapping external identities, accounts, groups, organizational units,
|
||||||
|
functions, and attributes to GovOPlaN identity, organization, access, and
|
||||||
|
tenancy DTOs
|
||||||
|
- provider-specific connectors for LDAP/AD, SCIM, OIDC profile claims, and
|
||||||
|
public-sector directory services once those targets are selected
|
||||||
|
- dry-run previews, conflict reports, audit events, and rollback metadata for
|
||||||
|
identity synchronization
|
||||||
|
|
||||||
|
It must not own:
|
||||||
|
|
||||||
|
- password authentication, sessions, API keys, or CSRF behavior
|
||||||
|
- GovOPlaN RBAC permission evaluation
|
||||||
|
- tenant lifecycle storage
|
||||||
|
- application-specific authorization rules
|
||||||
|
- portal citizen identities unless that flow is explicitly delegated to an
|
||||||
|
identity-trust or portal module
|
||||||
|
|
||||||
|
## Kernel Contract
|
||||||
|
|
||||||
|
The module should integrate through kernel capabilities and access-owned public
|
||||||
|
APIs:
|
||||||
|
|
||||||
|
- read/write identities through `govoplan-identity` capability contracts
|
||||||
|
- resolve organization units and functions through `govoplan-organizations`
|
||||||
|
- resolve tenants through `govoplan-tenancy`
|
||||||
|
- hand accepted role/right effects to `govoplan-access`
|
||||||
|
- emit synchronization and conflict events through the shared event contract
|
||||||
|
- write audit evidence through the audit sink capability
|
||||||
|
- expose admin routes through module route contributions
|
||||||
|
- contribute WebUI admin panels through module UI metadata
|
||||||
|
|
||||||
|
No feature module should import IDM internals directly. Feature modules should
|
||||||
|
ask access, tenancy, or future directory capabilities for normalized identity
|
||||||
|
facts.
|
||||||
|
|
||||||
|
`govoplan-idm` has hard module dependencies on `govoplan-identity` and
|
||||||
|
`govoplan-organizations`, because its core value is connecting those normalized
|
||||||
|
facts. Identity remains the owner of identities/accounts. Organizations remains
|
||||||
|
the owner of units/functions.
|
||||||
|
|
||||||
|
## Current Runtime API
|
||||||
|
|
||||||
|
The first runtime API exposes identity lookup and organization-function
|
||||||
|
assignment links:
|
||||||
|
|
||||||
|
- `GET /api/v1/idm/settings`
|
||||||
|
- `PATCH /api/v1/idm/settings`
|
||||||
|
- `GET /api/v1/idm/organization-identities`
|
||||||
|
- `GET /api/v1/idm/organization-function-assignments`
|
||||||
|
- `POST /api/v1/idm/organization-function-assignments`
|
||||||
|
- `PATCH /api/v1/idm/organization-function-assignments/{assignment_id}`
|
||||||
|
- `GET|POST /api/v1/idm/typed-groups`
|
||||||
|
- `PATCH /api/v1/idm/typed-groups/{group_id}`
|
||||||
|
- `GET|POST /api/v1/idm/relationships`
|
||||||
|
- `PATCH /api/v1/idm/relationships/{relationship_id}`
|
||||||
|
- `POST /api/v1/idm/relationships/{relationship_id}/revoke`
|
||||||
|
- `GET /api/v1/idm/typed-groups/{group_id}/memberships`
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Its interface archetypes, consequence classes, contextual-help contract, and
|
||||||
|
accessibility evidence are recorded in
|
||||||
|
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
Function requests and holder/authority initiated grants require a governed
|
||||||
|
change record before the effective assignment is created. The target journeys,
|
||||||
|
grant profiles, state model, and module boundaries are documented in
|
||||||
|
[Function assignment request and grant workflows](docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md).
|
||||||
|
|
||||||
|
Assignments with a future `valid_until` are resolved as ineffective immediately
|
||||||
|
after that boundary. When Celery beat and an IDM-capable worker are running, the
|
||||||
|
shared `govoplan.idm.expire_assignments` task also emits the corresponding
|
||||||
|
`idm.function_assignment.expired.v1` event. IDM records the event marker in the
|
||||||
|
same database transaction, making repeated sweeps idempotent.
|
||||||
|
|
||||||
|
Typed group and relationship behavior, including effective-time resolution and
|
||||||
|
the provider-neutral capability consumed by Distribution Lists, is documented
|
||||||
|
in [Typed groups and effective-dated relationships](docs/TYPED_RELATIONSHIPS.md).
|
||||||
|
|
||||||
|
## First Milestone
|
||||||
|
|
||||||
|
The first useful milestone is a read-only synchronization preview:
|
||||||
|
|
||||||
|
1. Configure one external directory source.
|
||||||
|
2. Fetch users/groups/organizational units into a transient snapshot.
|
||||||
|
3. Map the snapshot to GovOPlaN accounts, users, groups, and tenant bindings.
|
||||||
|
4. Show creates, updates, disables, conflicts, and skipped records.
|
||||||
|
5. Emit audit evidence for the preview run.
|
||||||
|
|
||||||
|
Applying changes should come later, behind dry-run approval, conflict policy,
|
||||||
|
rollback metadata, and maintenance-mode safeguards.
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Function Assignment Request And Grant Workflows
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Assigning an identity to an organization function is a governed business
|
||||||
|
change, not just an IDM table edit. IDM owns the request or grant record and the
|
||||||
|
resulting effective-dated assignment. Organizations owns units and function
|
||||||
|
definitions. Policy resolves who may initiate, clear, accept, reject, revoke,
|
||||||
|
or recover a change. Workflow Engine runs the selected, version-pinned process.
|
||||||
|
|
||||||
|
An assignment workflow never grants generic application permissions by itself.
|
||||||
|
Access may project an accepted function assignment into roles only through an
|
||||||
|
explicit function mapping.
|
||||||
|
|
||||||
|
## Two User Journeys
|
||||||
|
|
||||||
|
### Request A Function
|
||||||
|
|
||||||
|
An eligible user finds a function, sees whether it can be requested and why,
|
||||||
|
provides the required justification/evidence, and submits a request. The
|
||||||
|
effective policy determines whether the request goes to a current holder, a
|
||||||
|
clearing authority, both in sequence, or is unavailable because only an
|
||||||
|
authority may initiate the assignment.
|
||||||
|
|
||||||
|
The requester can inspect progress, respond to questions, withdraw while
|
||||||
|
permitted, and receive the final decision. No effective assignment exists until
|
||||||
|
all required approvals and any configured recipient acceptance are complete.
|
||||||
|
|
||||||
|
### Bestow A Function
|
||||||
|
|
||||||
|
An effective function holder or authority selects an eligible identity and
|
||||||
|
proposes a function assignment with scope, validity, delegation, and evidence.
|
||||||
|
The policy determines whether the holder may complete the grant, whether an
|
||||||
|
authority must clear it, or whether only the authority can initiate and grant
|
||||||
|
it. Recipient acceptance can be required or waived only by an explicit policy
|
||||||
|
with recorded provenance.
|
||||||
|
|
||||||
|
## Grant Profiles
|
||||||
|
|
||||||
|
The first policy profiles are:
|
||||||
|
|
||||||
|
- `holder_grant`: an effective holder may initiate and approve within the
|
||||||
|
configured scope and duration ceilings.
|
||||||
|
- `holder_with_authority_clearance`: a holder or eligible requester may
|
||||||
|
initiate, but the configured authority must clear the change.
|
||||||
|
- `authority_only`: only the configured authority may initiate and approve;
|
||||||
|
other users receive an unavailable action with an explanation.
|
||||||
|
|
||||||
|
Profiles may additionally configure self-request eligibility, recipient
|
||||||
|
acceptance, evidence requirements, separation of duties, quorum, expiry,
|
||||||
|
maximum validity, delegation limits, vacancy routing, and escalation. Function
|
||||||
|
or function-type configuration references a profile; Policy produces the
|
||||||
|
effective decision and provenance rather than IDM duplicating policy logic.
|
||||||
|
|
||||||
|
## IDM Aggregate
|
||||||
|
|
||||||
|
IDM persists one function-assignment change aggregate for both journeys:
|
||||||
|
|
||||||
|
- change ID, tenant, kind (`request` or `grant`), function, unit, candidate
|
||||||
|
identity/account, requested validity, and assignment source
|
||||||
|
- initiator, represented actor/function where applicable, policy profile and
|
||||||
|
decision revision
|
||||||
|
- pinned workflow definition/revision and workflow instance reference
|
||||||
|
- justification, evidence references, comments, and redacted process variables
|
||||||
|
- state and append-only state history
|
||||||
|
- resulting assignment ID, or rejection/withdrawal/expiry reason
|
||||||
|
- idempotency key and optimistic-concurrency revision
|
||||||
|
|
||||||
|
Candidate states are `draft`, `submitted`, `awaiting_holder`,
|
||||||
|
`awaiting_authority`, `awaiting_recipient`, `changes_requested`, `blocked`,
|
||||||
|
`approved`, `accepted`, `applied`, `rejected`, `withdrawn`, `expired`,
|
||||||
|
`cancelled`, and `failed_manual_review`. Not every profile uses every state.
|
||||||
|
|
||||||
|
The workflow instance coordinates the process, but the IDM change record is the
|
||||||
|
business source of truth. A workflow callback applies the assignment exactly
|
||||||
|
once after successful completion. Failed or outcome-unknown application is
|
||||||
|
reconciled without starting a second grant.
|
||||||
|
|
||||||
|
## Required Guarantees
|
||||||
|
|
||||||
|
- Every action rechecks current identity, holder, authority, function, and
|
||||||
|
Policy facts. Authorization captured at submission is evidence, not a future
|
||||||
|
permission grant.
|
||||||
|
- A holder who loses the source function cannot approve later unless Policy
|
||||||
|
explicitly permits continuity for an already-open case.
|
||||||
|
- Vacancy and unavailable authority routes produce a visible blocked/escalated
|
||||||
|
state rather than silently granting or losing the request.
|
||||||
|
- The proposer and required clearer cannot be the same actor where separation
|
||||||
|
of duties applies.
|
||||||
|
- Effective dates, revocation, expiry, delegation, and acting-for semantics use
|
||||||
|
the canonical IDM assignment contract.
|
||||||
|
- Notifications are emitted for durable waiting states and final outcomes when
|
||||||
|
Notifications is installed.
|
||||||
|
- Audit receives non-secret evidence for every transition when Audit is
|
||||||
|
installed.
|
||||||
|
- The direct administrative write path is either disabled for governed
|
||||||
|
functions or treated as an explicit emergency override with reason,
|
||||||
|
provenance, and equivalent evidence.
|
||||||
|
|
||||||
|
## Configuration And Runtime Contract
|
||||||
|
|
||||||
|
Function settings use `assignment_governance`. Tenant defaults may be supplied
|
||||||
|
through `settings.function_assignment_governance_defaults`; function values
|
||||||
|
override only the corresponding defaults. Supported keys include
|
||||||
|
`request_profile`, `grant_profile`, `authority_function_id`,
|
||||||
|
`recipient_acceptance_required`, `evidence_required`,
|
||||||
|
`separation_of_duties`, `quorum`, `maximum_validity_days`, and
|
||||||
|
`request_expiry_hours`. Missing or malformed profiles fail closed.
|
||||||
|
|
||||||
|
IDM exposes governed changes at
|
||||||
|
`/api/v1/idm/function-assignment-changes`. Mutations require a strong `If-Match`
|
||||||
|
precondition and the aggregate revision. Requests and grants pin the exact
|
||||||
|
Workflow definition revision and hash, retain append-only transition evidence,
|
||||||
|
support review change requests and responses, and apply an assignment exactly
|
||||||
|
once. Vacant holder or authority functions create a visible `blocked` state;
|
||||||
|
the lifecycle worker expires overdue open changes durably.
|
||||||
|
|
||||||
|
Direct administration remains available for independently deployed IDM. When
|
||||||
|
a function has an enabled governance profile, however, direct create or update
|
||||||
|
requires `idm:function_change:admin` plus an emergency override reason. IDM
|
||||||
|
stores the actor, time, reason, and evidence references with the assignment and
|
||||||
|
includes them in the normal Audit change record.
|
||||||
|
|
||||||
|
## Module Boundaries
|
||||||
|
|
||||||
|
- Organizations: function definitions and assignment-policy profile reference.
|
||||||
|
- IDM: request/grant aggregate, candidate validation, effective assignment, and
|
||||||
|
assignment lifecycle.
|
||||||
|
- Policy: effective initiator/approver/acceptance/quorum/expiry decisions and
|
||||||
|
explanations.
|
||||||
|
- Workflow Engine: versioned definition installation and process execution.
|
||||||
|
- Workflow: optional authoring, inspection, and override UI.
|
||||||
|
- Notifications: waiting-state and outcome notifications.
|
||||||
|
- Audit: immutable transition and assignment evidence.
|
||||||
|
- Access: optional projection of accepted function facts into roles/rights.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# IDM Interface Pattern Migration
|
||||||
|
|
||||||
|
This document records the bounded migration of IDM-owned WebUI surfaces to the
|
||||||
|
GovOPlaN interface pattern language. Identity owns people and account links,
|
||||||
|
Organizations owns functions and units, Access owns application authority, and
|
||||||
|
IDM owns effective identity-to-function facts and their governed lifecycle.
|
||||||
|
|
||||||
|
## Surface Inventory
|
||||||
|
|
||||||
|
| Surface | Archetype | Consequence class | Contract |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/idm` assignments | Repeated administration and governed relationship directory | Create, change, deactivate, delegate, or act for an assignment | Shared DataGrid/dialog/actions, accurate draft baseline, explicit permission and organization prerequisites, contextual field help |
|
||||||
|
| IDM governance settings | Effective tenant configuration | Require change evidence and alter audit retention | Shared card/form/toggles, dirty-state guard, write blocker, consequence help |
|
||||||
|
| Function request/grant list | Governed work queue | Start or inspect a function change | Shared grid/loading/status/action slot, localized state and workflow vocabulary |
|
||||||
|
| Request/grant editor | Guided consequential editor | Submit a governed assignment change | Shared segmented control/dialog/forms, guarded draft, effective dates, justification and evidence help |
|
||||||
|
| Function-change detail | Decision and provenance record | Approve, reject, accept, request changes, withdraw, respond, or recover | Shared confirmation, available-action contract, retained actor/policy/workflow/history evidence |
|
||||||
|
| `idm.action.view-function-assignments` | Contextual cross-module action | Navigate with function context | Declared capability surface, permission guard, no Organizations-private import |
|
||||||
|
|
||||||
|
## Consequence And Availability Rules
|
||||||
|
|
||||||
|
- Opening an existing assignment does not mark it dirty. The guard compares the
|
||||||
|
editor to the loaded baseline and protects only actual changes.
|
||||||
|
- Direct assignment changes require IDM write authority. Governed functions use
|
||||||
|
request/grant workflows for normal changes; direct edits are emergency
|
||||||
|
overrides with a retained reason and optional evidence references.
|
||||||
|
- Delegated and acting-for assignments remain tied to a valid source assignment
|
||||||
|
and the Organizations flags that permit those semantics.
|
||||||
|
- Assignment and governance settings do not create application permissions.
|
||||||
|
Access must separately map accepted institutional facts to assignable roles.
|
||||||
|
- Decisions are confirmed before execution. The resulting actor, comment,
|
||||||
|
policy decision, workflow revision, state transition, and evidence remain in
|
||||||
|
the governed record.
|
||||||
|
- Deactivation and expiry remove a fact from effective resolution while
|
||||||
|
retaining provenance and lifecycle evidence.
|
||||||
|
- Missing permission, identity search, and organization functions identify the
|
||||||
|
required action, responsible administrator, and destination.
|
||||||
|
|
||||||
|
## State And Accessibility Evidence
|
||||||
|
|
||||||
|
The module uses Core page/card/grid/dialog/loading/alert/status/action-blocker,
|
||||||
|
field-help, disabled-reason, confirmation, and unsaved-change controls. Shared
|
||||||
|
dialogs retain focus containment and return behavior; stable grid actions remain
|
||||||
|
keyboard reachable. Existing responsive CSS collapses summaries and histories
|
||||||
|
to one column at narrow widths.
|
||||||
|
|
||||||
|
English and German catalogues cover route metadata, assignment fields, governed
|
||||||
|
states, workflow steps, decisions, confirmations, and accessible labels. Dates
|
||||||
|
follow the selected platform locale. Manifest topics provide stable route,
|
||||||
|
field, blocker, workflow, and consequence references without importing optional
|
||||||
|
Policy, Audit, Notifications, Access, or Workflow Engine implementations.
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Typed groups and effective-dated relationships
|
||||||
|
|
||||||
|
IDM owns tenant-scoped business group facts and the effective-dated links from
|
||||||
|
identities to those groups. Identity lifecycle state remains owned by Identity;
|
||||||
|
an active group membership never activates an identity and never grants an
|
||||||
|
application permission.
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
The `idm.relationships` capability exposes immutable Core DTOs. Consumers can:
|
||||||
|
|
||||||
|
- list typed groups without importing IDM persistence models;
|
||||||
|
- resolve one or many identities to their current relationships;
|
||||||
|
- resolve one or many groups to current identities at a caller-supplied time;
|
||||||
|
- request a membership decision set that includes future, expired, revoked,
|
||||||
|
inactive-group, and inactive-identity exclusions;
|
||||||
|
- retain source provider, external resource, revision, typed properties, and
|
||||||
|
provenance data in downstream evidence.
|
||||||
|
|
||||||
|
Cross-tenant group references are rejected. The contract carries facts only and
|
||||||
|
does not imply a right, role, or permission.
|
||||||
|
|
||||||
|
## Persistence and lifecycle
|
||||||
|
|
||||||
|
`idm_typed_groups` stores the stable tenant/type/key identity and external source
|
||||||
|
reference. `idm_identity_relationships` stores one identity-to-group or
|
||||||
|
identity-to-identity link, its relationship kind, validity window, source,
|
||||||
|
properties, provenance, and optimistic revision.
|
||||||
|
|
||||||
|
Create, change, revoke, and elapsed-validity transitions emit versioned platform
|
||||||
|
events:
|
||||||
|
|
||||||
|
- `idm.typed_group.created.v1`
|
||||||
|
- `idm.typed_group.changed.v1`
|
||||||
|
- `idm.relationship.created.v1`
|
||||||
|
- `idm.relationship.changed.v1`
|
||||||
|
- `idm.relationship.revoked.v1`
|
||||||
|
- `idm.relationship.expired.v1`
|
||||||
|
|
||||||
|
The existing IDM lifecycle worker claims an elapsed relationship and records its
|
||||||
|
event marker in the same transaction. Repeated or concurrent sweeps therefore do
|
||||||
|
not publish duplicate expiry events.
|
||||||
|
|
||||||
|
## Distribution Lists
|
||||||
|
|
||||||
|
When Distribution Lists is enabled, an `idm_group` entry resolves through this
|
||||||
|
capability. Every effective identity becomes an internal-mail candidate when an
|
||||||
|
active linked account exists. Every rejected relationship remains visible in the
|
||||||
|
expansion evidence with a stable reason code. Distribution Lists stores only the
|
||||||
|
provider reference and frozen expansion evidence, not IDM records.
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/idm-webui",
|
||||||
|
"version": "0.1.18",
|
||||||
|
"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.18",
|
||||||
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^7.3.6"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "govoplan-idm"
|
||||||
|
version = "0.1.18"
|
||||||
|
description = "GovOPlaN identity management bridge module."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
authors = [{ name = "GovOPlaN" }]
|
||||||
|
dependencies = [
|
||||||
|
"govoplan-core>=0.1.18",
|
||||||
|
"govoplan-identity>=0.1.18",
|
||||||
|
"govoplan-organizations>=0.1.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[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"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""GovOPlaN identity management bridge module."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Backend integration for the GovOPlaN IDM module."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""IDM API package."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""IDM API v1 package."""
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from math import ceil
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
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, get_api_principal
|
||||||
|
from govoplan_core.core.access import CAPABILITY_AUDIT_RECORDER
|
||||||
|
from govoplan_core.core.concurrency import (
|
||||||
|
ConcurrencyError,
|
||||||
|
MissingPreconditionError,
|
||||||
|
RevisionConflictError,
|
||||||
|
assert_revision_precondition,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.identity import (
|
||||||
|
CAPABILITY_IDENTITY_DIRECTORY,
|
||||||
|
IdentityDirectory,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.organizations import (
|
||||||
|
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||||
|
OrganizationDirectory,
|
||||||
|
OrganizationFunctionRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.policy import (
|
||||||
|
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.runtime import get_registry
|
||||||
|
from govoplan_core.core.workflows import CAPABILITY_WORKFLOW_ORCHESTRATION
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_idm.backend.db.models import IdmFunctionAssignmentChange
|
||||||
|
from govoplan_idm.backend.function_assignment_changes import (
|
||||||
|
FunctionAssignmentChangeConflict,
|
||||||
|
FunctionAssignmentChangeUnavailable,
|
||||||
|
available_change_actions,
|
||||||
|
change_events,
|
||||||
|
create_function_assignment_change,
|
||||||
|
resolve_submission_capability,
|
||||||
|
transition_function_assignment_change,
|
||||||
|
visible_change_filter,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .schemas import (
|
||||||
|
FunctionAssignmentCapabilityItem,
|
||||||
|
FunctionAssignmentChangeActionRequest,
|
||||||
|
FunctionAssignmentChangeCreateRequest,
|
||||||
|
FunctionAssignmentChangeEventItem,
|
||||||
|
FunctionAssignmentChangeItem,
|
||||||
|
FunctionAssignmentChangeKind,
|
||||||
|
FunctionAssignmentChangeList,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/function-assignment-changes")
|
||||||
|
READ_SCOPES = (
|
||||||
|
"idm:function_change:read",
|
||||||
|
"idm:function_request:create",
|
||||||
|
"idm:function_grant:create",
|
||||||
|
"idm:function_change:decide",
|
||||||
|
"idm:function_change:admin",
|
||||||
|
"idm:organization_assignment:write",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_any(principal: ApiPrincipal, *scopes: str) -> None:
|
||||||
|
if not any(principal.has(scope) for scope in scopes):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Requires one of: {', '.join(scopes)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _registry():
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="The module registry is unavailable.",
|
||||||
|
)
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
def _organization_directory() -> OrganizationDirectory:
|
||||||
|
registry = _registry()
|
||||||
|
capability = registry.capability(CAPABILITY_ORGANIZATION_DIRECTORY)
|
||||||
|
if not isinstance(capability, OrganizationDirectory):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="The Organizations directory is unavailable.",
|
||||||
|
)
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_directory() -> IdentityDirectory:
|
||||||
|
registry = _registry()
|
||||||
|
capability = registry.capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||||
|
if not isinstance(capability, IdentityDirectory):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="The Identity directory is unavailable.",
|
||||||
|
)
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _function(function_id: str, tenant_id: str) -> OrganizationFunctionRef:
|
||||||
|
function = _organization_directory().get_function(function_id)
|
||||||
|
if function is None or function.tenant_id != tenant_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Organization function not found.",
|
||||||
|
)
|
||||||
|
return function
|
||||||
|
|
||||||
|
|
||||||
|
def _historical_function(
|
||||||
|
function_id: str,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> OrganizationFunctionRef | None:
|
||||||
|
try:
|
||||||
|
function = _organization_directory().get_function(function_id)
|
||||||
|
except HTTPException:
|
||||||
|
return None
|
||||||
|
if function is None or function.tenant_id != tenant_id:
|
||||||
|
return None
|
||||||
|
return function
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_candidate(identity_id: str, account_id: str | None) -> None:
|
||||||
|
directory = _identity_directory()
|
||||||
|
if directory.get_identity(identity_id) is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Identity not found.",
|
||||||
|
)
|
||||||
|
if account_id is not None and not any(
|
||||||
|
link.account_id == account_id
|
||||||
|
for link in directory.accounts_for_identity(identity_id)
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="Account is not linked to the selected identity.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _change_query(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
):
|
||||||
|
statement = select(IdmFunctionAssignmentChange).where(
|
||||||
|
IdmFunctionAssignmentChange.tenant_id == principal.tenant_id
|
||||||
|
)
|
||||||
|
visibility = visible_change_filter(session, principal)
|
||||||
|
return statement.where(visibility) if visibility is not None else statement
|
||||||
|
|
||||||
|
|
||||||
|
def _get_change(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
change_id: str,
|
||||||
|
) -> IdmFunctionAssignmentChange:
|
||||||
|
change = session.scalar(
|
||||||
|
_change_query(session, principal).where(
|
||||||
|
IdmFunctionAssignmentChange.id == change_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if change is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Function assignment change not found.",
|
||||||
|
)
|
||||||
|
return change
|
||||||
|
|
||||||
|
|
||||||
|
def _event_item(event) -> FunctionAssignmentChangeEventItem:
|
||||||
|
return FunctionAssignmentChangeEventItem(
|
||||||
|
id=event.id,
|
||||||
|
sequence=event.sequence,
|
||||||
|
action=event.action,
|
||||||
|
from_state=event.from_state,
|
||||||
|
to_state=event.to_state,
|
||||||
|
actor_account_id=event.actor_account_id,
|
||||||
|
actor_identity_id=event.actor_identity_id,
|
||||||
|
actor_assignment_id=event.actor_assignment_id,
|
||||||
|
comment=event.comment,
|
||||||
|
evidence=list(event.evidence),
|
||||||
|
policy_decision=dict(event.policy_decision),
|
||||||
|
workflow_step_id=event.workflow_step_id,
|
||||||
|
details=dict(event.details),
|
||||||
|
created_at=event.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _change_item(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
change: IdmFunctionAssignmentChange,
|
||||||
|
*,
|
||||||
|
include_events: bool,
|
||||||
|
) -> FunctionAssignmentChangeItem:
|
||||||
|
function = _historical_function(change.function_id, change.tenant_id)
|
||||||
|
if function is None:
|
||||||
|
actions, reason = [], "The referenced organization function is no longer available."
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
actions, reason = available_change_actions(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
registry=get_registry(),
|
||||||
|
change=change,
|
||||||
|
function=function,
|
||||||
|
)
|
||||||
|
except FunctionAssignmentChangeUnavailable as exc:
|
||||||
|
actions, reason = [], str(exc)
|
||||||
|
return FunctionAssignmentChangeItem(
|
||||||
|
id=change.id,
|
||||||
|
tenant_id=change.tenant_id,
|
||||||
|
kind=change.kind,
|
||||||
|
state=change.state,
|
||||||
|
profile=change.profile,
|
||||||
|
function_id=change.function_id,
|
||||||
|
organization_unit_id=change.organization_unit_id,
|
||||||
|
candidate_identity_id=change.candidate_identity_id,
|
||||||
|
candidate_account_id=change.candidate_account_id,
|
||||||
|
initiator_account_id=change.initiator_account_id,
|
||||||
|
initiator_identity_id=change.initiator_identity_id,
|
||||||
|
represented_assignment_id=change.represented_assignment_id,
|
||||||
|
justification=change.justification,
|
||||||
|
evidence=list(change.evidence),
|
||||||
|
requested_valid_from=change.requested_valid_from,
|
||||||
|
requested_valid_until=change.requested_valid_until,
|
||||||
|
applies_to_subunits=change.applies_to_subunits,
|
||||||
|
assignment_source=change.assignment_source,
|
||||||
|
required_steps=list(change.required_steps),
|
||||||
|
completed_steps=list(change.completed_steps),
|
||||||
|
policy_decision=dict(change.policy_decision),
|
||||||
|
workflow_definition_id=change.workflow_definition_id,
|
||||||
|
workflow_definition_revision_id=change.workflow_definition_revision_id,
|
||||||
|
workflow_definition_revision=change.workflow_definition_revision,
|
||||||
|
workflow_definition_hash=change.workflow_definition_hash,
|
||||||
|
workflow_instance_id=change.workflow_instance_id,
|
||||||
|
workflow_current_step_id=change.workflow_current_step_id,
|
||||||
|
resulting_assignment_id=change.resulting_assignment_id,
|
||||||
|
expires_at=change.expires_at,
|
||||||
|
outcome_reason=change.outcome_reason,
|
||||||
|
resource_revision=change.resource_revision,
|
||||||
|
etag=change.strong_etag,
|
||||||
|
metadata=dict(change.metadata_),
|
||||||
|
events=(
|
||||||
|
[
|
||||||
|
_event_item(event)
|
||||||
|
for event in change_events(session, change_id=change.id)
|
||||||
|
]
|
||||||
|
if include_events
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
available_actions=actions,
|
||||||
|
availability_reason=reason,
|
||||||
|
created_at=change.created_at,
|
||||||
|
updated_at=change.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_etag(response: Response, change: IdmFunctionAssignmentChange) -> None:
|
||||||
|
response.headers["ETag"] = change.strong_etag
|
||||||
|
|
||||||
|
|
||||||
|
def _record_change_audit(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
change: IdmFunctionAssignmentChange,
|
||||||
|
action: str,
|
||||||
|
from_state: str | None,
|
||||||
|
) -> None:
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None or not registry.has_capability(CAPABILITY_AUDIT_RECORDER):
|
||||||
|
return
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action=f"idm.function_assignment_change.{action}",
|
||||||
|
object_type="function_assignment_change",
|
||||||
|
object_id=change.id,
|
||||||
|
details={
|
||||||
|
"kind": change.kind,
|
||||||
|
"from_state": from_state,
|
||||||
|
"to_state": change.state,
|
||||||
|
"function_id": change.function_id,
|
||||||
|
"candidate_identity_id": change.candidate_identity_id,
|
||||||
|
"represented_assignment_id": change.represented_assignment_id,
|
||||||
|
"policy_decision": dict(change.policy_decision),
|
||||||
|
"workflow_definition_id": change.workflow_definition_id,
|
||||||
|
"workflow_definition_revision_id": change.workflow_definition_revision_id,
|
||||||
|
"workflow_instance_id": change.workflow_instance_id,
|
||||||
|
"evidence": list(change.evidence),
|
||||||
|
"resulting_assignment_id": change.resulting_assignment_id,
|
||||||
|
"resource_revision": change.resource_revision,
|
||||||
|
},
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mutation_error(exc: Exception) -> HTTPException:
|
||||||
|
if isinstance(exc, MissingPreconditionError):
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
|
||||||
|
detail=exc.as_dict(),
|
||||||
|
)
|
||||||
|
if isinstance(exc, RevisionConflictError):
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_412_PRECONDITION_FAILED,
|
||||||
|
detail=exc.as_dict(),
|
||||||
|
)
|
||||||
|
if isinstance(exc, ConcurrencyError):
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={"code": "concurrency_conflict", "message": str(exc)},
|
||||||
|
)
|
||||||
|
if isinstance(exc, FunctionAssignmentChangeConflict):
|
||||||
|
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/capability", response_model=FunctionAssignmentCapabilityItem)
|
||||||
|
def get_function_assignment_capability(
|
||||||
|
kind: FunctionAssignmentChangeKind,
|
||||||
|
function_id: str = Query(min_length=1, max_length=36),
|
||||||
|
candidate_identity_id: str | None = Query(default=None, max_length=36),
|
||||||
|
candidate_account_id: str | None = Query(default=None, max_length=36),
|
||||||
|
has_evidence: bool = False,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> FunctionAssignmentCapabilityItem:
|
||||||
|
_require_any(principal, *READ_SCOPES)
|
||||||
|
candidate_identity_id = candidate_identity_id or principal.identity_id
|
||||||
|
if candidate_identity_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="A candidate identity is required.",
|
||||||
|
)
|
||||||
|
function = _function(function_id, principal.tenant_id)
|
||||||
|
registry = _registry()
|
||||||
|
decision, reason = resolve_submission_capability(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
registry=registry,
|
||||||
|
kind=kind,
|
||||||
|
function=function,
|
||||||
|
candidate_identity_id=candidate_identity_id,
|
||||||
|
candidate_account_id=candidate_account_id,
|
||||||
|
has_evidence=has_evidence,
|
||||||
|
)
|
||||||
|
return FunctionAssignmentCapabilityItem(
|
||||||
|
kind=kind,
|
||||||
|
function_id=function_id,
|
||||||
|
available=bool(decision and decision.allowed),
|
||||||
|
reason=reason,
|
||||||
|
profile=decision.profile if decision else "unavailable",
|
||||||
|
required_steps=list(decision.required_steps) if decision else [],
|
||||||
|
requirements=list(decision.requirements) if decision else [],
|
||||||
|
authority_function_id=(decision.authority_function_id if decision else None),
|
||||||
|
evidence_required=bool(decision and decision.evidence_required),
|
||||||
|
recipient_acceptance_required=bool(
|
||||||
|
decision and decision.recipient_acceptance_required
|
||||||
|
),
|
||||||
|
maximum_validity_days=(decision.maximum_validity_days if decision else None),
|
||||||
|
workflow_available=registry.has_capability(CAPABILITY_WORKFLOW_ORCHESTRATION),
|
||||||
|
policy_available=registry.has_capability(
|
||||||
|
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=FunctionAssignmentChangeList)
|
||||||
|
def list_function_assignment_changes(
|
||||||
|
kind: FunctionAssignmentChangeKind | None = None,
|
||||||
|
state_filter: str | None = Query(default=None, alias="state", max_length=40),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=50, ge=1, le=200),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> FunctionAssignmentChangeList:
|
||||||
|
_require_any(principal, *READ_SCOPES)
|
||||||
|
statement = _change_query(session, principal)
|
||||||
|
if kind is not None:
|
||||||
|
statement = statement.where(IdmFunctionAssignmentChange.kind == kind)
|
||||||
|
if state_filter:
|
||||||
|
statement = statement.where(IdmFunctionAssignmentChange.state == state_filter)
|
||||||
|
total = int(
|
||||||
|
session.scalar(select(func.count()).select_from(statement.subquery())) or 0
|
||||||
|
)
|
||||||
|
rows = list(
|
||||||
|
session.scalars(
|
||||||
|
statement.order_by(
|
||||||
|
IdmFunctionAssignmentChange.updated_at.desc(),
|
||||||
|
IdmFunctionAssignmentChange.id.desc(),
|
||||||
|
)
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return FunctionAssignmentChangeList(
|
||||||
|
changes=[
|
||||||
|
_change_item(session, principal, change, include_events=False)
|
||||||
|
for change in rows
|
||||||
|
],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
pages=max(1, ceil(total / page_size)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{change_id}", response_model=FunctionAssignmentChangeItem)
|
||||||
|
def get_function_assignment_change(
|
||||||
|
change_id: str,
|
||||||
|
response: Response,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> FunctionAssignmentChangeItem:
|
||||||
|
_require_any(principal, *READ_SCOPES)
|
||||||
|
change = _get_change(session, principal, change_id)
|
||||||
|
_set_etag(response, change)
|
||||||
|
return _change_item(session, principal, change, include_events=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=FunctionAssignmentChangeItem, status_code=201)
|
||||||
|
def create_governed_function_assignment_change(
|
||||||
|
payload: FunctionAssignmentChangeCreateRequest,
|
||||||
|
response: Response,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> FunctionAssignmentChangeItem:
|
||||||
|
required = (
|
||||||
|
"idm:function_request:create"
|
||||||
|
if payload.kind == "request"
|
||||||
|
else "idm:function_grant:create"
|
||||||
|
)
|
||||||
|
_require_any(
|
||||||
|
principal,
|
||||||
|
required,
|
||||||
|
"idm:function_change:admin",
|
||||||
|
"idm:organization_assignment:write",
|
||||||
|
)
|
||||||
|
_validate_candidate(payload.candidate_identity_id, payload.candidate_account_id)
|
||||||
|
try:
|
||||||
|
change, replayed = create_function_assignment_change(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
registry=_registry(),
|
||||||
|
function=_function(payload.function_id, principal.tenant_id),
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
if not replayed:
|
||||||
|
_record_change_audit(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
change=change,
|
||||||
|
action="created",
|
||||||
|
from_state=None,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
FunctionAssignmentChangeConflict,
|
||||||
|
FunctionAssignmentChangeUnavailable,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _mutation_error(exc) from exc
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="The function assignment change conflicts with existing data.",
|
||||||
|
) from exc
|
||||||
|
session.refresh(change)
|
||||||
|
response.status_code = status.HTTP_200_OK if replayed else status.HTTP_201_CREATED
|
||||||
|
_set_etag(response, change)
|
||||||
|
return _change_item(session, principal, change, include_events=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{change_id}/actions", response_model=FunctionAssignmentChangeItem)
|
||||||
|
def act_on_function_assignment_change(
|
||||||
|
change_id: str,
|
||||||
|
payload: FunctionAssignmentChangeActionRequest,
|
||||||
|
response: Response,
|
||||||
|
if_match: str | None = Header(default=None, alias="If-Match"),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> FunctionAssignmentChangeItem:
|
||||||
|
change = _get_change(session, principal, change_id)
|
||||||
|
from_state = change.state
|
||||||
|
if payload.action == "recover":
|
||||||
|
_require_any(principal, "idm:function_change:admin")
|
||||||
|
elif payload.action not in {"withdraw", "respond"}:
|
||||||
|
_require_any(
|
||||||
|
principal,
|
||||||
|
"idm:function_change:decide",
|
||||||
|
"idm:function_change:admin",
|
||||||
|
"idm:organization_assignment:write",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
assert_revision_precondition(
|
||||||
|
if_match,
|
||||||
|
resource_type="idm_function_assignment_change",
|
||||||
|
resource_id=change.id,
|
||||||
|
submitted_base_revision=payload.base_revision,
|
||||||
|
)
|
||||||
|
change = transition_function_assignment_change(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
registry=_registry(),
|
||||||
|
change=change,
|
||||||
|
function=_function(change.function_id, change.tenant_id),
|
||||||
|
action=payload.action,
|
||||||
|
base_revision=payload.base_revision,
|
||||||
|
comment=payload.comment,
|
||||||
|
evidence=payload.evidence,
|
||||||
|
)
|
||||||
|
_record_change_audit(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
change=change,
|
||||||
|
action=payload.action,
|
||||||
|
from_state=from_state,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (
|
||||||
|
ConcurrencyError,
|
||||||
|
FunctionAssignmentChangeConflict,
|
||||||
|
FunctionAssignmentChangeUnavailable,
|
||||||
|
) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _mutation_error(exc) from exc
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="The function assignment transition conflicts with existing data.",
|
||||||
|
) from exc
|
||||||
|
session.refresh(change)
|
||||||
|
_set_etag(response, change)
|
||||||
|
return _change_item(session, principal, change, include_events=True)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, require_any_scope
|
||||||
|
from govoplan_core.core.events import (
|
||||||
|
EventActorRef,
|
||||||
|
EventObjectRef,
|
||||||
|
EventTenantRef,
|
||||||
|
PlatformEvent,
|
||||||
|
emit_platform_event,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.identity import (
|
||||||
|
CAPABILITY_IDENTITY_DIRECTORY,
|
||||||
|
IdentityDirectory,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.idm import (
|
||||||
|
CAPABILITY_IDM_RELATIONSHIPS,
|
||||||
|
IdentityRelationshipDecisionRef,
|
||||||
|
IdentityRelationshipRef,
|
||||||
|
IdmRelationshipDirectory,
|
||||||
|
TypedGroupRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||||
|
from govoplan_core.core.runtime import get_registry
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||||
|
from govoplan_idm.backend.db.models import (
|
||||||
|
IdmIdentityRelationship,
|
||||||
|
IdmTypedGroup,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .schemas import (
|
||||||
|
IdentityRelationshipCreateRequest,
|
||||||
|
IdentityRelationshipDecisionItem,
|
||||||
|
IdentityRelationshipItem,
|
||||||
|
IdentityRelationshipList,
|
||||||
|
IdentityRelationshipRevokeRequest,
|
||||||
|
IdentityRelationshipUpdateRequest,
|
||||||
|
TypedGroupCreateRequest,
|
||||||
|
TypedGroupItem,
|
||||||
|
TypedGroupList,
|
||||||
|
TypedGroupMembershipResolutionItem,
|
||||||
|
TypedGroupUpdateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
RELATIONSHIP_READ_SCOPES = (
|
||||||
|
"idm:relationship:read",
|
||||||
|
"idm:relationship:write",
|
||||||
|
)
|
||||||
|
RELATIONSHIP_WRITE_SCOPES = ("idm:relationship:write",)
|
||||||
|
|
||||||
|
|
||||||
|
def _not_found(label: str) -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"{label} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _invalid(message: str) -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _conflict(message: str) -> HTTPException:
|
||||||
|
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=message)
|
||||||
|
|
||||||
|
|
||||||
|
def _typed_group_item(item: IdmTypedGroup) -> TypedGroupItem:
|
||||||
|
return TypedGroupItem.model_validate(
|
||||||
|
{column.name: getattr(item, column.name) for column in item.__table__.columns}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _relationship_item(item: IdmIdentityRelationship) -> IdentityRelationshipItem:
|
||||||
|
return IdentityRelationshipItem.model_validate(
|
||||||
|
{column.name: getattr(item, column.name) for column in item.__table__.columns}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _typed_group_ref_item(item: TypedGroupRef) -> TypedGroupItem:
|
||||||
|
return TypedGroupItem(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
key=item.key,
|
||||||
|
name=item.name,
|
||||||
|
group_type=item.group_type,
|
||||||
|
description=item.description,
|
||||||
|
status=item.status,
|
||||||
|
source_provider=item.source_provider,
|
||||||
|
source_resource_type=item.source_resource_type,
|
||||||
|
source_resource_id=item.source_resource_id,
|
||||||
|
source_revision=item.source_revision,
|
||||||
|
properties=dict(item.properties),
|
||||||
|
provenance=dict(item.provenance),
|
||||||
|
revision=item.revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _relationship_ref_item(
|
||||||
|
item: IdentityRelationshipRef,
|
||||||
|
) -> IdentityRelationshipItem:
|
||||||
|
return IdentityRelationshipItem(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
relationship_kind=item.relationship_kind,
|
||||||
|
subject_identity_id=item.subject_identity_id,
|
||||||
|
target_group_id=item.target_group_id,
|
||||||
|
related_identity_id=item.related_identity_id,
|
||||||
|
role=item.role,
|
||||||
|
valid_from=item.valid_from,
|
||||||
|
valid_until=item.valid_until,
|
||||||
|
status=item.status,
|
||||||
|
revoked_at=item.revoked_at,
|
||||||
|
revoked_by=item.revoked_by,
|
||||||
|
revocation_reason=item.revocation_reason,
|
||||||
|
source_provider=item.source_provider,
|
||||||
|
source_resource_type=item.source_resource_type,
|
||||||
|
source_resource_id=item.source_resource_id,
|
||||||
|
source_revision=item.source_revision,
|
||||||
|
properties=dict(item.properties),
|
||||||
|
provenance=dict(item.provenance),
|
||||||
|
revision=item.revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _decision_item(
|
||||||
|
item: IdentityRelationshipDecisionRef,
|
||||||
|
) -> IdentityRelationshipDecisionItem:
|
||||||
|
return IdentityRelationshipDecisionItem(
|
||||||
|
relationship=_relationship_ref_item(item.relationship),
|
||||||
|
included=item.included,
|
||||||
|
code=item.code,
|
||||||
|
explanation=item.explanation,
|
||||||
|
identity_status=item.identity_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_row(session: Session, model, item_id: str, tenant_id: str, label: str):
|
||||||
|
item = session.get(model, item_id)
|
||||||
|
if item is None or item.tenant_id != tenant_id:
|
||||||
|
raise _not_found(label)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_directory() -> IdentityDirectory:
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Identity directory is unavailable",
|
||||||
|
)
|
||||||
|
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||||
|
if not isinstance(capability, IdentityDirectory):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}",
|
||||||
|
)
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _relationship_directory() -> IdmRelationshipDirectory:
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None or not registry.has_capability(CAPABILITY_IDM_RELATIONSHIPS):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="IDM relationship directory is unavailable",
|
||||||
|
)
|
||||||
|
capability = registry.require_capability(CAPABILITY_IDM_RELATIONSHIPS)
|
||||||
|
if not isinstance(capability, IdmRelationshipDirectory):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Invalid capability: {CAPABILITY_IDM_RELATIONSHIPS}",
|
||||||
|
)
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_identity(identity_id: str, *, label: str = "Identity") -> None:
|
||||||
|
if _identity_directory().get_identity(identity_id) is None:
|
||||||
|
raise _not_found(label)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_target_shape(
|
||||||
|
target_group_id: str | None,
|
||||||
|
related_identity_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
if (target_group_id is None) == (related_identity_id is None):
|
||||||
|
raise _invalid(
|
||||||
|
"A relationship must target exactly one typed group or related identity."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_window(valid_from: datetime | None, valid_until: datetime | None) -> None:
|
||||||
|
start = ensure_aware_utc(valid_from)
|
||||||
|
end = ensure_aware_utc(valid_until)
|
||||||
|
if start is not None and end is not None and end <= start:
|
||||||
|
raise _invalid("Relationship end must be after its start.")
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_change(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
payload: dict[str, object],
|
||||||
|
subject_identity_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type=event_type,
|
||||||
|
module_id="idm",
|
||||||
|
payload=payload,
|
||||||
|
actor=EventActorRef(type="account", id=principal.account_id),
|
||||||
|
tenant=EventTenantRef(id=principal.tenant_id),
|
||||||
|
subject=(
|
||||||
|
EventObjectRef(type="identity", id=subject_identity_id)
|
||||||
|
if subject_identity_id is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
resource=EventObjectRef(type=resource_type, id=resource_id),
|
||||||
|
classification="internal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _commit(session: Session, principal: ApiPrincipal, item, *, resource_type: str):
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
source_module="idm",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=item.id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _conflict("The IDM relationship conflicts with existing data.") from exc
|
||||||
|
session.refresh(item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/typed-groups", response_model=TypedGroupList)
|
||||||
|
def list_typed_groups(
|
||||||
|
query: str | None = Query(default=None, max_length=255),
|
||||||
|
group_type: str | None = Query(default=None, max_length=80),
|
||||||
|
include_inactive: bool = False,
|
||||||
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_READ_SCOPES)),
|
||||||
|
) -> TypedGroupList:
|
||||||
|
statement = session.query(IdmTypedGroup).filter(
|
||||||
|
IdmTypedGroup.tenant_id == principal.tenant_id
|
||||||
|
)
|
||||||
|
if not include_inactive:
|
||||||
|
statement = statement.filter(IdmTypedGroup.status == "active")
|
||||||
|
if group_type:
|
||||||
|
statement = statement.filter(IdmTypedGroup.group_type == group_type)
|
||||||
|
if query:
|
||||||
|
statement = statement.filter(IdmTypedGroup.name.ilike(f"%{query.strip()}%"))
|
||||||
|
total = statement.count()
|
||||||
|
rows = (
|
||||||
|
statement.order_by(IdmTypedGroup.name.asc(), IdmTypedGroup.id.asc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return TypedGroupList(groups=[_typed_group_item(item) for item in rows], total=total)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/typed-groups",
|
||||||
|
response_model=TypedGroupItem,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_typed_group(
|
||||||
|
payload: TypedGroupCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)),
|
||||||
|
) -> TypedGroupItem:
|
||||||
|
item = IdmTypedGroup(
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
**payload.model_dump(),
|
||||||
|
status="active",
|
||||||
|
revision=1,
|
||||||
|
)
|
||||||
|
session.add(item)
|
||||||
|
try:
|
||||||
|
session.flush()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _conflict("A typed group with this type and key already exists.") from exc
|
||||||
|
_emit_change(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
event_type="idm.typed_group.created.v1",
|
||||||
|
resource_type="typed_group",
|
||||||
|
resource_id=item.id,
|
||||||
|
payload={"group_type": item.group_type, "key": item.key, "revision": 1},
|
||||||
|
)
|
||||||
|
return _typed_group_item(
|
||||||
|
_commit(session, principal, item, resource_type="typed_group")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/typed-groups/{group_id}", response_model=TypedGroupItem)
|
||||||
|
def update_typed_group(
|
||||||
|
group_id: str,
|
||||||
|
payload: TypedGroupUpdateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)),
|
||||||
|
) -> TypedGroupItem:
|
||||||
|
item = _tenant_row(
|
||||||
|
session, IdmTypedGroup, group_id, principal.tenant_id, "Typed group"
|
||||||
|
)
|
||||||
|
if item.revision != payload.base_revision:
|
||||||
|
raise _conflict("The typed group changed since it was loaded.")
|
||||||
|
values = payload.model_dump(exclude_unset=True, exclude={"base_revision"})
|
||||||
|
for key, value in values.items():
|
||||||
|
setattr(item, key, value)
|
||||||
|
item.revision += 1
|
||||||
|
session.flush()
|
||||||
|
_emit_change(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
event_type="idm.typed_group.changed.v1",
|
||||||
|
resource_type="typed_group",
|
||||||
|
resource_id=item.id,
|
||||||
|
payload={"group_type": item.group_type, "key": item.key, "revision": item.revision},
|
||||||
|
)
|
||||||
|
return _typed_group_item(
|
||||||
|
_commit(session, principal, item, resource_type="typed_group")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/relationships", response_model=IdentityRelationshipList)
|
||||||
|
def list_identity_relationships(
|
||||||
|
identity_id: str | None = Query(default=None, max_length=36),
|
||||||
|
group_id: str | None = Query(default=None, max_length=36),
|
||||||
|
relationship_kind: str | None = Query(default=None, max_length=80),
|
||||||
|
include_revoked: bool = False,
|
||||||
|
limit: int = Query(default=500, ge=1, le=1000),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_READ_SCOPES)),
|
||||||
|
) -> IdentityRelationshipList:
|
||||||
|
statement = session.query(IdmIdentityRelationship).filter(
|
||||||
|
IdmIdentityRelationship.tenant_id == principal.tenant_id
|
||||||
|
)
|
||||||
|
if identity_id:
|
||||||
|
statement = statement.filter(
|
||||||
|
IdmIdentityRelationship.subject_identity_id == identity_id
|
||||||
|
)
|
||||||
|
if group_id:
|
||||||
|
statement = statement.filter(IdmIdentityRelationship.target_group_id == group_id)
|
||||||
|
if relationship_kind:
|
||||||
|
statement = statement.filter(
|
||||||
|
IdmIdentityRelationship.relationship_kind == relationship_kind
|
||||||
|
)
|
||||||
|
if not include_revoked:
|
||||||
|
statement = statement.filter(IdmIdentityRelationship.status == "active")
|
||||||
|
total = statement.count()
|
||||||
|
rows = (
|
||||||
|
statement.order_by(
|
||||||
|
IdmIdentityRelationship.created_at.asc(),
|
||||||
|
IdmIdentityRelationship.id.asc(),
|
||||||
|
)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return IdentityRelationshipList(
|
||||||
|
relationships=[_relationship_item(item) for item in rows], total=total
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/relationships",
|
||||||
|
response_model=IdentityRelationshipItem,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_identity_relationship(
|
||||||
|
payload: IdentityRelationshipCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)),
|
||||||
|
) -> IdentityRelationshipItem:
|
||||||
|
_ensure_target_shape(payload.target_group_id, payload.related_identity_id)
|
||||||
|
_ensure_window(payload.valid_from, payload.valid_until)
|
||||||
|
_ensure_identity(payload.subject_identity_id)
|
||||||
|
if payload.related_identity_id:
|
||||||
|
_ensure_identity(payload.related_identity_id, label="Related identity")
|
||||||
|
if payload.target_group_id:
|
||||||
|
_tenant_row(
|
||||||
|
session,
|
||||||
|
IdmTypedGroup,
|
||||||
|
payload.target_group_id,
|
||||||
|
principal.tenant_id,
|
||||||
|
"Typed group",
|
||||||
|
)
|
||||||
|
item = IdmIdentityRelationship(
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
**payload.model_dump(),
|
||||||
|
status="active",
|
||||||
|
revision=1,
|
||||||
|
)
|
||||||
|
session.add(item)
|
||||||
|
session.flush()
|
||||||
|
_emit_change(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
event_type="idm.relationship.created.v1",
|
||||||
|
resource_type="identity_relationship",
|
||||||
|
resource_id=item.id,
|
||||||
|
subject_identity_id=item.subject_identity_id,
|
||||||
|
payload={
|
||||||
|
"relationship_kind": item.relationship_kind,
|
||||||
|
"target_group_id": item.target_group_id,
|
||||||
|
"related_identity_id": item.related_identity_id,
|
||||||
|
"revision": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return _relationship_item(
|
||||||
|
_commit(session, principal, item, resource_type="identity_relationship")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/relationships/{relationship_id}", response_model=IdentityRelationshipItem)
|
||||||
|
def update_identity_relationship(
|
||||||
|
relationship_id: str,
|
||||||
|
payload: IdentityRelationshipUpdateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)),
|
||||||
|
) -> IdentityRelationshipItem:
|
||||||
|
item = _tenant_row(
|
||||||
|
session,
|
||||||
|
IdmIdentityRelationship,
|
||||||
|
relationship_id,
|
||||||
|
principal.tenant_id,
|
||||||
|
"Identity relationship",
|
||||||
|
)
|
||||||
|
if item.status == "revoked":
|
||||||
|
raise _conflict("A revoked relationship cannot be changed.")
|
||||||
|
if item.revision != payload.base_revision:
|
||||||
|
raise _conflict("The identity relationship changed since it was loaded.")
|
||||||
|
values = payload.model_dump(exclude_unset=True, exclude={"base_revision"})
|
||||||
|
if values.get("target_group_id") is not None:
|
||||||
|
values["related_identity_id"] = None
|
||||||
|
if values.get("related_identity_id") is not None:
|
||||||
|
values["target_group_id"] = None
|
||||||
|
target_group_id = values.get("target_group_id", item.target_group_id)
|
||||||
|
related_identity_id = values.get("related_identity_id", item.related_identity_id)
|
||||||
|
_ensure_target_shape(target_group_id, related_identity_id)
|
||||||
|
valid_from = values.get("valid_from", item.valid_from)
|
||||||
|
valid_until = values.get("valid_until", item.valid_until)
|
||||||
|
_ensure_window(valid_from, valid_until)
|
||||||
|
if target_group_id:
|
||||||
|
_tenant_row(
|
||||||
|
session,
|
||||||
|
IdmTypedGroup,
|
||||||
|
target_group_id,
|
||||||
|
principal.tenant_id,
|
||||||
|
"Typed group",
|
||||||
|
)
|
||||||
|
if related_identity_id:
|
||||||
|
_ensure_identity(related_identity_id, label="Related identity")
|
||||||
|
for key, value in values.items():
|
||||||
|
setattr(item, key, value)
|
||||||
|
item.revision += 1
|
||||||
|
if item.valid_until is None or ensure_aware_utc(item.valid_until) > utc_now():
|
||||||
|
item.expired_event_at = None
|
||||||
|
session.flush()
|
||||||
|
_emit_change(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
event_type="idm.relationship.changed.v1",
|
||||||
|
resource_type="identity_relationship",
|
||||||
|
resource_id=item.id,
|
||||||
|
subject_identity_id=item.subject_identity_id,
|
||||||
|
payload={"relationship_kind": item.relationship_kind, "revision": item.revision},
|
||||||
|
)
|
||||||
|
return _relationship_item(
|
||||||
|
_commit(session, principal, item, resource_type="identity_relationship")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/relationships/{relationship_id}/revoke",
|
||||||
|
response_model=IdentityRelationshipItem,
|
||||||
|
)
|
||||||
|
def revoke_identity_relationship(
|
||||||
|
relationship_id: str,
|
||||||
|
payload: IdentityRelationshipRevokeRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)),
|
||||||
|
) -> IdentityRelationshipItem:
|
||||||
|
item = _tenant_row(
|
||||||
|
session,
|
||||||
|
IdmIdentityRelationship,
|
||||||
|
relationship_id,
|
||||||
|
principal.tenant_id,
|
||||||
|
"Identity relationship",
|
||||||
|
)
|
||||||
|
if item.revision != payload.base_revision:
|
||||||
|
raise _conflict("The identity relationship changed since it was loaded.")
|
||||||
|
if item.status == "revoked":
|
||||||
|
return _relationship_item(item)
|
||||||
|
item.status = "revoked"
|
||||||
|
item.revoked_at = utc_now()
|
||||||
|
item.revoked_by = principal.account_id
|
||||||
|
item.revocation_reason = payload.reason
|
||||||
|
item.revision += 1
|
||||||
|
session.flush()
|
||||||
|
_emit_change(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
event_type="idm.relationship.revoked.v1",
|
||||||
|
resource_type="identity_relationship",
|
||||||
|
resource_id=item.id,
|
||||||
|
subject_identity_id=item.subject_identity_id,
|
||||||
|
payload={
|
||||||
|
"relationship_kind": item.relationship_kind,
|
||||||
|
"reason": payload.reason,
|
||||||
|
"revision": item.revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return _relationship_item(
|
||||||
|
_commit(session, principal, item, resource_type="identity_relationship")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/typed-groups/{group_id}/memberships",
|
||||||
|
response_model=TypedGroupMembershipResolutionItem,
|
||||||
|
)
|
||||||
|
def resolve_typed_group_memberships(
|
||||||
|
group_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
relationship_kind: list[str] = Query(default=["member"]),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_READ_SCOPES)),
|
||||||
|
) -> TypedGroupMembershipResolutionItem:
|
||||||
|
_tenant_row(
|
||||||
|
session, IdmTypedGroup, group_id, principal.tenant_id, "Typed group"
|
||||||
|
)
|
||||||
|
resolved = _relationship_directory().resolve_typed_group_memberships(
|
||||||
|
(group_id,),
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
effective_at=effective_at,
|
||||||
|
relationship_kinds=tuple(relationship_kind),
|
||||||
|
)[group_id]
|
||||||
|
return TypedGroupMembershipResolutionItem(
|
||||||
|
group=_typed_group_ref_item(resolved.group),
|
||||||
|
effective_at=resolved.effective_at,
|
||||||
|
decisions=[_decision_item(item) for item in resolved.decisions],
|
||||||
|
identity_ids=list(resolved.identity_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -0,0 +1,766 @@
|
|||||||
|
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.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.principal_cache import invalidate_auth_principals
|
||||||
|
from govoplan_core.core.identity import (
|
||||||
|
CAPABILITY_IDENTITY_DIRECTORY,
|
||||||
|
CAPABILITY_IDENTITY_SEARCH,
|
||||||
|
IdentityDirectory,
|
||||||
|
IdentityRef,
|
||||||
|
IdentitySearchProvider,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.organizations import (
|
||||||
|
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||||
|
OrganizationDirectory,
|
||||||
|
OrganizationFunctionRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.runtime import get_registry
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_core.security.time import utc_now
|
||||||
|
from govoplan_idm.backend.assignment_transitions import (
|
||||||
|
AssignmentMutationPlan,
|
||||||
|
AssignmentTransitionError,
|
||||||
|
assignment_is_expired,
|
||||||
|
lifecycle_event_types,
|
||||||
|
plan_assignment_update,
|
||||||
|
validate_assignment_shape,
|
||||||
|
validate_assignment_source_rules,
|
||||||
|
)
|
||||||
|
from govoplan_idm.backend.assignment_events import emit_assignment_event
|
||||||
|
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
|
||||||
|
|
||||||
|
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"
|
||||||
|
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:
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=getattr(item, "tenant_id", None),
|
||||||
|
source_module="idm",
|
||||||
|
resource_type=item.__class__.__name__,
|
||||||
|
resource_id=str(getattr(item, "id", getattr(item, "tenant_id", "system"))),
|
||||||
|
)
|
||||||
|
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 _flush_assignment(
|
||||||
|
session: Session,
|
||||||
|
item: IdmOrganizationFunctionAssignment,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
session.flush()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _conflict(
|
||||||
|
"The IDM assignment conflicts with an existing assignment."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _commit_assignment_transaction(
|
||||||
|
session: Session,
|
||||||
|
item: IdmOrganizationFunctionAssignment,
|
||||||
|
) -> None:
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
source_module="idm",
|
||||||
|
resource_type="organization_function_assignment",
|
||||||
|
resource_id=item.id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _conflict(
|
||||||
|
"The IDM assignment conflicts with an existing assignment."
|
||||||
|
) from exc
|
||||||
|
session.refresh(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 _identity_directory() -> IdentityDirectory:
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Identity directory is unavailable",
|
||||||
|
)
|
||||||
|
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||||
|
if not isinstance(capability, IdentityDirectory):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}",
|
||||||
|
)
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_search() -> IdentitySearchProvider:
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_SEARCH):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Identity search is unavailable",
|
||||||
|
)
|
||||||
|
capability = registry.require_capability(CAPABILITY_IDENTITY_SEARCH)
|
||||||
|
if not isinstance(capability, IdentitySearchProvider):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Invalid capability: {CAPABILITY_IDENTITY_SEARCH}",
|
||||||
|
)
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _organization_directory() -> OrganizationDirectory:
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None or not registry.has_capability(CAPABILITY_ORGANIZATION_DIRECTORY):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Organization directory is unavailable",
|
||||||
|
)
|
||||||
|
capability = registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY)
|
||||||
|
if not isinstance(capability, OrganizationDirectory):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Invalid capability: {CAPABILITY_ORGANIZATION_DIRECTORY}",
|
||||||
|
)
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _organization_function(function_id: str, tenant_id: str) -> OrganizationFunctionRef:
|
||||||
|
function = _organization_directory().get_function(function_id)
|
||||||
|
if function is None or function.tenant_id != tenant_id:
|
||||||
|
raise _not_found("Organization function")
|
||||||
|
return function
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_identity(identity_id: str) -> None:
|
||||||
|
if _identity_directory().get_identity(identity_id) is None:
|
||||||
|
raise _not_found("Identity")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_account_link(identity_id: str, account_id: str | None) -> None:
|
||||||
|
if account_id is None:
|
||||||
|
return
|
||||||
|
links = _identity_directory().accounts_for_identity(identity_id)
|
||||||
|
if not any(link.account_id == account_id for link in links):
|
||||||
|
raise _invalid("Account is not linked to the selected identity.")
|
||||||
|
|
||||||
|
|
||||||
|
def _account_linked_to_identity(identity_id: str, account_id: str) -> bool:
|
||||||
|
return any(link.account_id == account_id for link in _identity_directory().accounts_for_identity(identity_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_assignment_workflow(
|
||||||
|
session: Session,
|
||||||
|
item: IdmOrganizationFunctionAssignment,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
validate_assignment_shape(item)
|
||||||
|
function = _organization_function(item.function_id, tenant_id)
|
||||||
|
base = _assignment_source_assignment(session, item, tenant_id=tenant_id)
|
||||||
|
validate_assignment_source_rules(
|
||||||
|
item,
|
||||||
|
function=function,
|
||||||
|
base=base,
|
||||||
|
account_linked_to_identity=_account_linked_to_identity,
|
||||||
|
)
|
||||||
|
except AssignmentTransitionError as exc:
|
||||||
|
raise _invalid(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_assignment_update(
|
||||||
|
session: Session,
|
||||||
|
item: IdmOrganizationFunctionAssignment,
|
||||||
|
payload: OrganizationFunctionAssignmentUpdateRequest,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> AssignmentMutationPlan:
|
||||||
|
values = payload.model_dump(
|
||||||
|
exclude_unset=True,
|
||||||
|
exclude={
|
||||||
|
"change_request_id",
|
||||||
|
"governance_override_reason",
|
||||||
|
"governance_override_evidence",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
organization_unit_id: str | None = None
|
||||||
|
if "function_id" in values and values["function_id"] is not None:
|
||||||
|
function = _organization_function(str(values["function_id"]), tenant_id)
|
||||||
|
organization_unit_id = function.organization_unit_id
|
||||||
|
try:
|
||||||
|
plan = plan_assignment_update(
|
||||||
|
item,
|
||||||
|
values,
|
||||||
|
organization_unit_id=organization_unit_id,
|
||||||
|
)
|
||||||
|
except AssignmentTransitionError as exc:
|
||||||
|
raise _invalid(str(exc)) from exc
|
||||||
|
|
||||||
|
if plan.after.identity_id != plan.before.identity_id:
|
||||||
|
_ensure_identity(plan.after.identity_id)
|
||||||
|
_ensure_account_link(plan.after.identity_id, plan.after.account_id)
|
||||||
|
_ensure_assignment_workflow(session, plan.after, tenant_id=tenant_id) # type: ignore[arg-type]
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def _assignment_source_assignment(
|
||||||
|
session: Session,
|
||||||
|
item: IdmOrganizationFunctionAssignment,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> IdmOrganizationFunctionAssignment | None:
|
||||||
|
if item.delegated_from_assignment_id is None:
|
||||||
|
return None
|
||||||
|
base = _get_tenant_row(session, IdmOrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
|
||||||
|
if not base.is_active:
|
||||||
|
raise _invalid("Delegation source assignment must be active.")
|
||||||
|
if base.function_id != item.function_id:
|
||||||
|
raise _invalid("Delegated and acting-for assignments must use the same organization function as the source assignment.")
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _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( # type: ignore[attr-defined]
|
||||||
|
mode="json",
|
||||||
|
exclude={
|
||||||
|
"change_request_id",
|
||||||
|
"governance_override_reason",
|
||||||
|
"governance_override_evidence",
|
||||||
|
},
|
||||||
|
exclude_unset=True,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_assignment_event(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
item: OrganizationFunctionAssignmentItem,
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
) -> None:
|
||||||
|
emit_assignment_event(
|
||||||
|
session,
|
||||||
|
item,
|
||||||
|
event_type=event_type,
|
||||||
|
actor_type="account",
|
||||||
|
actor_id=principal.account_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_governance_override(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
function: OrganizationFunctionRef,
|
||||||
|
payload: object,
|
||||||
|
settings: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
governance = function.settings.get("assignment_governance")
|
||||||
|
if not isinstance(governance, dict) or not any(
|
||||||
|
str(governance.get(key) or "unavailable").strip().casefold() != "unavailable"
|
||||||
|
for key in ("request_profile", "grant_profile")
|
||||||
|
):
|
||||||
|
return settings
|
||||||
|
if not principal.has("idm:function_change:admin"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=(
|
||||||
|
"Direct assignment to a governed function requires function "
|
||||||
|
"change recovery permission."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
reason = str(getattr(payload, "governance_override_reason", None) or "").strip()
|
||||||
|
if not reason:
|
||||||
|
raise _invalid(
|
||||||
|
"Direct assignment to a governed function requires an emergency override reason."
|
||||||
|
)
|
||||||
|
evidence = [
|
||||||
|
str(item).strip()
|
||||||
|
for item in getattr(payload, "governance_override_evidence", ())
|
||||||
|
if str(item).strip()
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
**settings,
|
||||||
|
"governance_override": {
|
||||||
|
"reason": reason,
|
||||||
|
"evidence": evidence,
|
||||||
|
"actor_account_id": principal.account_id,
|
||||||
|
"recorded_at": utc_now().isoformat(),
|
||||||
|
"function_id": function.id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
session.flush()
|
||||||
|
result = _settings_item(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"),
|
||||||
|
)
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_module="idm",
|
||||||
|
resource_type="tenant_settings",
|
||||||
|
resource_id=tenant_id,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/organization-function-assignments", response_model=OrganizationFunctionAssignmentList)
|
||||||
|
def list_organization_function_assignments(
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=500, ge=1, le=1000),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope(*IDM_ASSIGNMENT_READ_SCOPES)),
|
||||||
|
) -> OrganizationFunctionAssignmentList:
|
||||||
|
tenant_id = _tenant_id(principal)
|
||||||
|
query = (
|
||||||
|
session.query(IdmOrganizationFunctionAssignment)
|
||||||
|
.filter(IdmOrganizationFunctionAssignment.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
total = query.order_by(None).count()
|
||||||
|
pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
assignments = (
|
||||||
|
query.order_by(
|
||||||
|
IdmOrganizationFunctionAssignment.created_at.asc(),
|
||||||
|
IdmOrganizationFunctionAssignment.id.asc(),
|
||||||
|
)
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return OrganizationFunctionAssignmentList(
|
||||||
|
assignments=[_assignment_item(item) for item in assignments],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
pages=pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/organization-function-assignments", response_model=OrganizationFunctionAssignmentItem, status_code=status.HTTP_201_CREATED)
|
||||||
|
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(payload.identity_id)
|
||||||
|
_ensure_account_link(payload.identity_id, payload.account_id)
|
||||||
|
function = _organization_function(payload.function_id, tenant_id)
|
||||||
|
item_settings = _apply_governance_override(
|
||||||
|
principal,
|
||||||
|
function,
|
||||||
|
payload,
|
||||||
|
dict(payload.settings),
|
||||||
|
)
|
||||||
|
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=item_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)
|
||||||
|
_flush_assignment(session, item)
|
||||||
|
result = _assignment_item(item)
|
||||||
|
after = result.model_dump(mode="json")
|
||||||
|
_record_assignment_change_applied(session, principal, approval=approval, target={**target, "resource_id": item.id}, before=None, after=after)
|
||||||
|
_record_assignment_audit(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="idm.organization_assignment.created",
|
||||||
|
resource_type="idm_organization_function_assignment",
|
||||||
|
resource_id=item.id,
|
||||||
|
before=None,
|
||||||
|
after=after,
|
||||||
|
)
|
||||||
|
_publish_assignment_event(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
result,
|
||||||
|
event_type="idm.function_assignment.created.v1",
|
||||||
|
)
|
||||||
|
now = utc_now()
|
||||||
|
if assignment_is_expired(item, now=now):
|
||||||
|
item.expired_event_at = now
|
||||||
|
_publish_assignment_event(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
_assignment_item(item),
|
||||||
|
event_type="idm.function_assignment.expired.v1",
|
||||||
|
)
|
||||||
|
_commit_assignment_transaction(session, item)
|
||||||
|
return _assignment_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
plan = _plan_assignment_update(
|
||||||
|
session,
|
||||||
|
item,
|
||||||
|
payload,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
plan.apply(item)
|
||||||
|
function = _organization_function(item.function_id, tenant_id)
|
||||||
|
item.settings = _apply_governance_override(
|
||||||
|
principal,
|
||||||
|
function,
|
||||||
|
payload,
|
||||||
|
dict(item.settings),
|
||||||
|
)
|
||||||
|
now = utc_now()
|
||||||
|
event_types = lifecycle_event_types(
|
||||||
|
plan.before,
|
||||||
|
plan.after,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
if "idm.function_assignment.expired.v1" in event_types:
|
||||||
|
item.expired_event_at = now
|
||||||
|
elif not assignment_is_expired(item, now=now):
|
||||||
|
item.expired_event_at = None
|
||||||
|
_flush_assignment(session, item)
|
||||||
|
result = _assignment_item(item)
|
||||||
|
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=item.id,
|
||||||
|
before=before,
|
||||||
|
after=after,
|
||||||
|
)
|
||||||
|
for event_type in event_types:
|
||||||
|
_publish_assignment_event(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
result,
|
||||||
|
event_type=event_type,
|
||||||
|
)
|
||||||
|
_commit_assignment_transaction(session, item)
|
||||||
|
return _assignment_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@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 session, principal
|
||||||
|
identities = _identity_search().search_identities(
|
||||||
|
query,
|
||||||
|
include_inactive=include_inactive,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
return OrganizationIdentityCandidateList(
|
||||||
|
identities=[_identity_candidate(identity) for identity in identities]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_candidate(identity: IdentityRef) -> OrganizationIdentityCandidate:
|
||||||
|
return OrganizationIdentityCandidate(
|
||||||
|
id=identity.id,
|
||||||
|
display_name=identity.display_name,
|
||||||
|
external_subject=identity.external_subject,
|
||||||
|
source=identity.source,
|
||||||
|
primary_account_id=identity.primary_account_id,
|
||||||
|
account_ids=list(identity.account_ids),
|
||||||
|
status=identity.status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
from .function_changes import router as function_changes_router # noqa: E402
|
||||||
|
from .relationships import router as relationships_router # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
router.include_router(function_changes_router)
|
||||||
|
router.include_router(relationships_router)
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
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
|
||||||
|
expired_event_at: datetime | None = None
|
||||||
|
is_active: bool
|
||||||
|
settings: dict[str, Any]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationFunctionAssignmentList(BaseModel):
|
||||||
|
assignments: list[OrganizationFunctionAssignmentItem]
|
||||||
|
total: int = 0
|
||||||
|
page: int = 1
|
||||||
|
page_size: int = 500
|
||||||
|
pages: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
governance_override_reason: str | None = Field(default=None, max_length=4_000)
|
||||||
|
governance_override_evidence: list[str] = Field(
|
||||||
|
default_factory=list, max_length=100
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
governance_override_reason: str | None = Field(default=None, max_length=4_000)
|
||||||
|
governance_override_evidence: list[str] = Field(
|
||||||
|
default_factory=list, max_length=100
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class TypedGroupItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
key: str
|
||||||
|
name: str
|
||||||
|
group_type: str
|
||||||
|
description: str | None = None
|
||||||
|
status: Literal["active", "inactive"]
|
||||||
|
source_provider: str
|
||||||
|
source_resource_type: str | None = None
|
||||||
|
source_resource_id: str | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
properties: dict[str, Any]
|
||||||
|
provenance: dict[str, Any]
|
||||||
|
revision: int
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TypedGroupList(BaseModel):
|
||||||
|
groups: list[TypedGroupItem]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class TypedGroupCreateRequest(BaseModel):
|
||||||
|
key: str = Field(min_length=1, max_length=120)
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
group_type: str = Field(min_length=1, max_length=80)
|
||||||
|
description: str | None = Field(default=None, max_length=8_000)
|
||||||
|
source_provider: str = Field(default="local", min_length=1, max_length=80)
|
||||||
|
source_resource_type: str | None = Field(default=None, max_length=120)
|
||||||
|
source_resource_id: str | None = Field(default=None, max_length=255)
|
||||||
|
source_revision: str | None = Field(default=None, max_length=255)
|
||||||
|
properties: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class TypedGroupUpdateRequest(BaseModel):
|
||||||
|
base_revision: int = Field(ge=1)
|
||||||
|
key: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
group_type: str | None = Field(default=None, min_length=1, max_length=80)
|
||||||
|
description: str | None = Field(default=None, max_length=8_000)
|
||||||
|
status: Literal["active", "inactive"] | None = None
|
||||||
|
source_provider: str | None = Field(default=None, min_length=1, max_length=80)
|
||||||
|
source_resource_type: str | None = Field(default=None, max_length=120)
|
||||||
|
source_resource_id: str | None = Field(default=None, max_length=255)
|
||||||
|
source_revision: str | None = Field(default=None, max_length=255)
|
||||||
|
properties: dict[str, Any] | None = None
|
||||||
|
provenance: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityRelationshipItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
relationship_kind: str
|
||||||
|
subject_identity_id: str
|
||||||
|
target_group_id: str | None = None
|
||||||
|
related_identity_id: str | None = None
|
||||||
|
role: str | None = None
|
||||||
|
valid_from: datetime | None = None
|
||||||
|
valid_until: datetime | None = None
|
||||||
|
status: Literal["active", "revoked"]
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
revoked_by: str | None = None
|
||||||
|
revocation_reason: str | None = None
|
||||||
|
expired_event_at: datetime | None = None
|
||||||
|
source_provider: str
|
||||||
|
source_resource_type: str | None = None
|
||||||
|
source_resource_id: str | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
properties: dict[str, Any]
|
||||||
|
provenance: dict[str, Any]
|
||||||
|
revision: int
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityRelationshipList(BaseModel):
|
||||||
|
relationships: list[IdentityRelationshipItem]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityRelationshipCreateRequest(BaseModel):
|
||||||
|
relationship_kind: str = Field(min_length=1, max_length=80)
|
||||||
|
subject_identity_id: str = Field(min_length=1, max_length=36)
|
||||||
|
target_group_id: str | None = Field(default=None, max_length=36)
|
||||||
|
related_identity_id: str | None = Field(default=None, max_length=36)
|
||||||
|
role: str | None = Field(default=None, max_length=120)
|
||||||
|
valid_from: datetime | None = None
|
||||||
|
valid_until: datetime | None = None
|
||||||
|
source_provider: str = Field(default="local", min_length=1, max_length=80)
|
||||||
|
source_resource_type: str | None = Field(default=None, max_length=120)
|
||||||
|
source_resource_id: str | None = Field(default=None, max_length=255)
|
||||||
|
source_revision: str | None = Field(default=None, max_length=255)
|
||||||
|
properties: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityRelationshipUpdateRequest(BaseModel):
|
||||||
|
base_revision: int = Field(ge=1)
|
||||||
|
relationship_kind: str | None = Field(default=None, min_length=1, max_length=80)
|
||||||
|
target_group_id: str | None = Field(default=None, max_length=36)
|
||||||
|
related_identity_id: str | None = Field(default=None, max_length=36)
|
||||||
|
role: str | None = Field(default=None, max_length=120)
|
||||||
|
valid_from: datetime | None = None
|
||||||
|
valid_until: datetime | None = None
|
||||||
|
source_provider: str | None = Field(default=None, min_length=1, max_length=80)
|
||||||
|
source_resource_type: str | None = Field(default=None, max_length=120)
|
||||||
|
source_resource_id: str | None = Field(default=None, max_length=255)
|
||||||
|
source_revision: str | None = Field(default=None, max_length=255)
|
||||||
|
properties: dict[str, Any] | None = None
|
||||||
|
provenance: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityRelationshipRevokeRequest(BaseModel):
|
||||||
|
base_revision: int = Field(ge=1)
|
||||||
|
reason: str = Field(min_length=1, max_length=8_000)
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityRelationshipDecisionItem(BaseModel):
|
||||||
|
relationship: IdentityRelationshipItem
|
||||||
|
included: bool
|
||||||
|
code: str
|
||||||
|
explanation: str
|
||||||
|
identity_status: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TypedGroupMembershipResolutionItem(BaseModel):
|
||||||
|
group: TypedGroupItem
|
||||||
|
effective_at: datetime
|
||||||
|
decisions: list[IdentityRelationshipDecisionItem]
|
||||||
|
identity_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
FunctionAssignmentChangeKind = Literal["request", "grant"]
|
||||||
|
FunctionAssignmentChangeAction = Literal[
|
||||||
|
"approve",
|
||||||
|
"reject",
|
||||||
|
"accept",
|
||||||
|
"request_changes",
|
||||||
|
"respond",
|
||||||
|
"withdraw",
|
||||||
|
"recover",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionAssignmentChangeCreateRequest(BaseModel):
|
||||||
|
kind: FunctionAssignmentChangeKind
|
||||||
|
function_id: str = Field(min_length=1, max_length=36)
|
||||||
|
candidate_identity_id: str = Field(min_length=1, max_length=36)
|
||||||
|
candidate_account_id: str | None = Field(default=None, max_length=36)
|
||||||
|
justification: str = Field(min_length=1, max_length=8_000)
|
||||||
|
evidence: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
requested_valid_from: datetime | None = None
|
||||||
|
requested_valid_until: datetime | None = None
|
||||||
|
applies_to_subunits: bool = False
|
||||||
|
assignment_source: Literal["governance", "delegated"] = "governance"
|
||||||
|
represented_assignment_id: str | None = Field(default=None, max_length=36)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionAssignmentChangeActionRequest(BaseModel):
|
||||||
|
action: FunctionAssignmentChangeAction
|
||||||
|
base_revision: int = Field(ge=1)
|
||||||
|
comment: str | None = Field(default=None, max_length=4_000)
|
||||||
|
evidence: list[str] = Field(default_factory=list, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionAssignmentChangeEventItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
sequence: int
|
||||||
|
action: str
|
||||||
|
from_state: str | None = None
|
||||||
|
to_state: str
|
||||||
|
actor_account_id: str | None = None
|
||||||
|
actor_identity_id: str | None = None
|
||||||
|
actor_assignment_id: str | None = None
|
||||||
|
comment: str | None = None
|
||||||
|
evidence: list[str]
|
||||||
|
policy_decision: dict[str, Any]
|
||||||
|
workflow_step_id: str | None = None
|
||||||
|
details: dict[str, Any]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionAssignmentChangeItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
kind: FunctionAssignmentChangeKind
|
||||||
|
state: str
|
||||||
|
profile: str
|
||||||
|
function_id: str
|
||||||
|
organization_unit_id: str
|
||||||
|
candidate_identity_id: str
|
||||||
|
candidate_account_id: str | None = None
|
||||||
|
initiator_account_id: str
|
||||||
|
initiator_identity_id: str | None = None
|
||||||
|
represented_assignment_id: str | None = None
|
||||||
|
justification: str
|
||||||
|
evidence: list[str]
|
||||||
|
requested_valid_from: datetime | None = None
|
||||||
|
requested_valid_until: datetime | None = None
|
||||||
|
applies_to_subunits: bool
|
||||||
|
assignment_source: str
|
||||||
|
required_steps: list[str]
|
||||||
|
completed_steps: list[str]
|
||||||
|
policy_decision: dict[str, Any]
|
||||||
|
workflow_definition_id: str | None = None
|
||||||
|
workflow_definition_revision_id: str | None = None
|
||||||
|
workflow_definition_revision: int | None = None
|
||||||
|
workflow_definition_hash: str | None = None
|
||||||
|
workflow_instance_id: str | None = None
|
||||||
|
workflow_current_step_id: str | None = None
|
||||||
|
resulting_assignment_id: str | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
outcome_reason: str | None = None
|
||||||
|
resource_revision: int
|
||||||
|
etag: str
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
events: list[FunctionAssignmentChangeEventItem] = Field(default_factory=list)
|
||||||
|
available_actions: list[str] = Field(default_factory=list)
|
||||||
|
availability_reason: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionAssignmentChangeList(BaseModel):
|
||||||
|
changes: list[FunctionAssignmentChangeItem]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionAssignmentCapabilityItem(BaseModel):
|
||||||
|
kind: FunctionAssignmentChangeKind
|
||||||
|
function_id: str
|
||||||
|
available: bool
|
||||||
|
reason: str | None = None
|
||||||
|
profile: str = "unavailable"
|
||||||
|
required_steps: list[str] = Field(default_factory=list)
|
||||||
|
requirements: list[str] = Field(default_factory=list)
|
||||||
|
authority_function_id: str | None = None
|
||||||
|
evidence_required: bool = False
|
||||||
|
recipient_acceptance_required: bool = False
|
||||||
|
maximum_validity_days: int | None = None
|
||||||
|
workflow_available: bool = False
|
||||||
|
policy_available: bool = False
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.events import (
|
||||||
|
EventActorRef,
|
||||||
|
EventObjectRef,
|
||||||
|
EventTenantRef,
|
||||||
|
PlatformEvent,
|
||||||
|
emit_platform_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AssignmentEventSource(Protocol):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
identity_id: str
|
||||||
|
account_id: str | None
|
||||||
|
function_id: str
|
||||||
|
organization_unit_id: str
|
||||||
|
applies_to_subunits: bool
|
||||||
|
source: str
|
||||||
|
delegated_from_assignment_id: str | None
|
||||||
|
acting_for_account_id: str | None
|
||||||
|
valid_from: datetime | None
|
||||||
|
valid_until: datetime | None
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
def emit_assignment_event(
|
||||||
|
session: Session,
|
||||||
|
item: AssignmentEventSource,
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
actor_type: str,
|
||||||
|
actor_id: str | None = None,
|
||||||
|
occurred_at: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
event_options = {"occurred_at": occurred_at} if occurred_at else {}
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type=event_type,
|
||||||
|
module_id="idm",
|
||||||
|
payload={
|
||||||
|
"identity_id": item.identity_id,
|
||||||
|
"account_id": item.account_id,
|
||||||
|
"function_id": item.function_id,
|
||||||
|
"organization_unit_id": item.organization_unit_id,
|
||||||
|
"applies_to_subunits": item.applies_to_subunits,
|
||||||
|
"source": item.source,
|
||||||
|
"delegated_from_assignment_id": (
|
||||||
|
item.delegated_from_assignment_id
|
||||||
|
),
|
||||||
|
"acting_for_account_id": item.acting_for_account_id,
|
||||||
|
"valid_from": (
|
||||||
|
item.valid_from.isoformat()
|
||||||
|
if item.valid_from is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"valid_until": (
|
||||||
|
item.valid_until.isoformat()
|
||||||
|
if item.valid_until is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"is_active": item.is_active,
|
||||||
|
},
|
||||||
|
actor=EventActorRef(type=actor_type, id=actor_id),
|
||||||
|
tenant=EventTenantRef(id=item.tenant_id),
|
||||||
|
subject=EventObjectRef(
|
||||||
|
type="organization_function",
|
||||||
|
id=item.function_id,
|
||||||
|
),
|
||||||
|
resource=EventObjectRef(
|
||||||
|
type="organization_function_assignment",
|
||||||
|
id=item.id,
|
||||||
|
),
|
||||||
|
classification="internal",
|
||||||
|
**event_options,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["emit_assignment_event"]
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.events import (
|
||||||
|
EventActorRef,
|
||||||
|
EventObjectRef,
|
||||||
|
EventTenantRef,
|
||||||
|
PlatformEvent,
|
||||||
|
emit_platform_event,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||||
|
from govoplan_core.core.notifications import (
|
||||||
|
NotificationDispatchRequest,
|
||||||
|
notification_dispatch_provider,
|
||||||
|
)
|
||||||
|
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||||
|
from govoplan_idm.backend.assignment_events import emit_assignment_event
|
||||||
|
from govoplan_idm.backend.db.models import (
|
||||||
|
IdmFunctionAssignmentChange,
|
||||||
|
IdmFunctionAssignmentChangeEvent,
|
||||||
|
IdmIdentityRelationship,
|
||||||
|
IdmOrganizationFunctionAssignment,
|
||||||
|
)
|
||||||
|
from govoplan_idm.backend.function_assignment_changes import OPEN_STATES
|
||||||
|
|
||||||
|
|
||||||
|
class SqlIdmAssignmentLifecycle:
|
||||||
|
"""Claim and publish elapsed assignments exactly once per validity window."""
|
||||||
|
|
||||||
|
def __init__(self, *, registry: object | None = None) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def process_expired(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise TypeError("IDM assignment lifecycle requires a SQLAlchemy session")
|
||||||
|
if limit < 1 or limit > 1000:
|
||||||
|
raise ValueError("IDM assignment expiry limit must be between 1 and 1000")
|
||||||
|
now = ensure_aware_utc(effective_at) or utc_now()
|
||||||
|
query = session.query(IdmOrganizationFunctionAssignment).filter(
|
||||||
|
IdmOrganizationFunctionAssignment.is_active.is_(True),
|
||||||
|
IdmOrganizationFunctionAssignment.valid_until.is_not(None),
|
||||||
|
IdmOrganizationFunctionAssignment.valid_until <= now,
|
||||||
|
IdmOrganizationFunctionAssignment.expired_event_at.is_(None),
|
||||||
|
)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.filter(
|
||||||
|
IdmOrganizationFunctionAssignment.tenant_id == tenant_id
|
||||||
|
)
|
||||||
|
candidates = (
|
||||||
|
query.order_by(
|
||||||
|
IdmOrganizationFunctionAssignment.valid_until.asc(),
|
||||||
|
IdmOrganizationFunctionAssignment.id.asc(),
|
||||||
|
)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
expired_ids: list[str] = []
|
||||||
|
touched_tenants: set[str] = set()
|
||||||
|
for item in candidates:
|
||||||
|
claimed = (
|
||||||
|
session.query(IdmOrganizationFunctionAssignment)
|
||||||
|
.filter(
|
||||||
|
IdmOrganizationFunctionAssignment.id == item.id,
|
||||||
|
IdmOrganizationFunctionAssignment.is_active.is_(True),
|
||||||
|
IdmOrganizationFunctionAssignment.valid_until.is_not(None),
|
||||||
|
IdmOrganizationFunctionAssignment.valid_until <= now,
|
||||||
|
IdmOrganizationFunctionAssignment.expired_event_at.is_(None),
|
||||||
|
)
|
||||||
|
.update(
|
||||||
|
{IdmOrganizationFunctionAssignment.expired_event_at: now},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if claimed != 1:
|
||||||
|
continue
|
||||||
|
session.refresh(item)
|
||||||
|
emit_assignment_event(
|
||||||
|
session,
|
||||||
|
item,
|
||||||
|
event_type="idm.function_assignment.expired.v1",
|
||||||
|
actor_type="system",
|
||||||
|
occurred_at=now,
|
||||||
|
)
|
||||||
|
expired_ids.append(item.id)
|
||||||
|
touched_tenants.add(item.tenant_id)
|
||||||
|
|
||||||
|
for touched_tenant_id in sorted(touched_tenants):
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=touched_tenant_id,
|
||||||
|
source_module="idm",
|
||||||
|
resource_type="organization_function_assignment_expiry",
|
||||||
|
resource_id=touched_tenant_id,
|
||||||
|
)
|
||||||
|
expired_change_ids = self._expire_open_changes(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=now,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
expired_relationship_ids = self._expire_relationships(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=now,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"selected": len(candidates),
|
||||||
|
"expired": len(expired_ids),
|
||||||
|
"assignment_ids": expired_ids,
|
||||||
|
"expired_changes": len(expired_change_ids),
|
||||||
|
"change_ids": expired_change_ids,
|
||||||
|
"expired_relationships": len(expired_relationship_ids),
|
||||||
|
"relationship_ids": expired_relationship_ids,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _expire_relationships(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None,
|
||||||
|
effective_at: datetime,
|
||||||
|
limit: int,
|
||||||
|
) -> list[str]:
|
||||||
|
query = session.query(IdmIdentityRelationship).filter(
|
||||||
|
IdmIdentityRelationship.status == "active",
|
||||||
|
IdmIdentityRelationship.valid_until.is_not(None),
|
||||||
|
IdmIdentityRelationship.valid_until <= effective_at,
|
||||||
|
IdmIdentityRelationship.expired_event_at.is_(None),
|
||||||
|
)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.filter(IdmIdentityRelationship.tenant_id == tenant_id)
|
||||||
|
candidates = (
|
||||||
|
query.order_by(
|
||||||
|
IdmIdentityRelationship.valid_until.asc(),
|
||||||
|
IdmIdentityRelationship.id.asc(),
|
||||||
|
)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
expired_ids: list[str] = []
|
||||||
|
for item in candidates:
|
||||||
|
claimed = (
|
||||||
|
session.query(IdmIdentityRelationship)
|
||||||
|
.filter(
|
||||||
|
IdmIdentityRelationship.id == item.id,
|
||||||
|
IdmIdentityRelationship.status == "active",
|
||||||
|
IdmIdentityRelationship.valid_until.is_not(None),
|
||||||
|
IdmIdentityRelationship.valid_until <= effective_at,
|
||||||
|
IdmIdentityRelationship.expired_event_at.is_(None),
|
||||||
|
)
|
||||||
|
.update(
|
||||||
|
{
|
||||||
|
IdmIdentityRelationship.expired_event_at: effective_at,
|
||||||
|
IdmIdentityRelationship.revision: (
|
||||||
|
IdmIdentityRelationship.revision + 1
|
||||||
|
),
|
||||||
|
},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if claimed != 1:
|
||||||
|
continue
|
||||||
|
session.refresh(item)
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type="idm.relationship.expired.v1",
|
||||||
|
module_id="idm",
|
||||||
|
payload={
|
||||||
|
"relationship_kind": item.relationship_kind,
|
||||||
|
"target_group_id": item.target_group_id,
|
||||||
|
"related_identity_id": item.related_identity_id,
|
||||||
|
"revision": item.revision,
|
||||||
|
},
|
||||||
|
actor=EventActorRef(type="system"),
|
||||||
|
tenant=EventTenantRef(id=item.tenant_id),
|
||||||
|
subject=EventObjectRef(
|
||||||
|
type="identity", id=item.subject_identity_id
|
||||||
|
),
|
||||||
|
resource=EventObjectRef(
|
||||||
|
type="identity_relationship", id=item.id
|
||||||
|
),
|
||||||
|
classification="internal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
source_module="idm",
|
||||||
|
resource_type="identity_relationship_expiry",
|
||||||
|
resource_id=item.id,
|
||||||
|
)
|
||||||
|
expired_ids.append(item.id)
|
||||||
|
return expired_ids
|
||||||
|
|
||||||
|
def _expire_open_changes(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None,
|
||||||
|
effective_at: datetime,
|
||||||
|
limit: int,
|
||||||
|
) -> list[str]:
|
||||||
|
query = session.query(IdmFunctionAssignmentChange).filter(
|
||||||
|
IdmFunctionAssignmentChange.state.in_(OPEN_STATES),
|
||||||
|
IdmFunctionAssignmentChange.expires_at.is_not(None),
|
||||||
|
IdmFunctionAssignmentChange.expires_at <= effective_at,
|
||||||
|
)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.filter(IdmFunctionAssignmentChange.tenant_id == tenant_id)
|
||||||
|
candidates = (
|
||||||
|
query.order_by(
|
||||||
|
IdmFunctionAssignmentChange.expires_at.asc(),
|
||||||
|
IdmFunctionAssignmentChange.id.asc(),
|
||||||
|
)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
expired_ids: list[str] = []
|
||||||
|
for change in candidates:
|
||||||
|
previous_state = change.state
|
||||||
|
claimed = (
|
||||||
|
session.query(IdmFunctionAssignmentChange)
|
||||||
|
.filter(
|
||||||
|
IdmFunctionAssignmentChange.id == change.id,
|
||||||
|
IdmFunctionAssignmentChange.state == previous_state,
|
||||||
|
IdmFunctionAssignmentChange.expires_at.is_not(None),
|
||||||
|
IdmFunctionAssignmentChange.expires_at <= effective_at,
|
||||||
|
)
|
||||||
|
.update(
|
||||||
|
{
|
||||||
|
IdmFunctionAssignmentChange.state: "expired",
|
||||||
|
IdmFunctionAssignmentChange.outcome_reason: (
|
||||||
|
"The governed function assignment change expired."
|
||||||
|
),
|
||||||
|
IdmFunctionAssignmentChange.resource_revision: (
|
||||||
|
IdmFunctionAssignmentChange.resource_revision + 1
|
||||||
|
),
|
||||||
|
},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if claimed != 1:
|
||||||
|
continue
|
||||||
|
session.refresh(change)
|
||||||
|
sequence = (
|
||||||
|
int(
|
||||||
|
session.scalar(
|
||||||
|
session.query(
|
||||||
|
func.max(IdmFunctionAssignmentChangeEvent.sequence)
|
||||||
|
)
|
||||||
|
.filter(IdmFunctionAssignmentChangeEvent.change_id == change.id)
|
||||||
|
.statement
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
+ 1
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
IdmFunctionAssignmentChangeEvent(
|
||||||
|
tenant_id=change.tenant_id,
|
||||||
|
change_id=change.id,
|
||||||
|
sequence=sequence,
|
||||||
|
action="expired",
|
||||||
|
from_state=previous_state,
|
||||||
|
to_state="expired",
|
||||||
|
policy_decision=dict(change.policy_decision),
|
||||||
|
details={"effective_at": effective_at.isoformat()},
|
||||||
|
created_at=effective_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type="idm.function_change.expired.v1",
|
||||||
|
module_id="idm",
|
||||||
|
payload={
|
||||||
|
"kind": change.kind,
|
||||||
|
"state": change.state,
|
||||||
|
"function_id": change.function_id,
|
||||||
|
"candidate_identity_id": change.candidate_identity_id,
|
||||||
|
"resource_revision": change.resource_revision,
|
||||||
|
},
|
||||||
|
actor=EventActorRef(type="system"),
|
||||||
|
tenant=EventTenantRef(id=change.tenant_id),
|
||||||
|
subject=EventObjectRef(
|
||||||
|
type="organization_function",
|
||||||
|
id=change.function_id,
|
||||||
|
),
|
||||||
|
resource=EventObjectRef(
|
||||||
|
type="function_assignment_change",
|
||||||
|
id=change.id,
|
||||||
|
),
|
||||||
|
classification="internal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._notify_expiry(session, change)
|
||||||
|
expired_ids.append(change.id)
|
||||||
|
return expired_ids
|
||||||
|
|
||||||
|
def _notify_expiry(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
change: IdmFunctionAssignmentChange,
|
||||||
|
) -> None:
|
||||||
|
provider = notification_dispatch_provider(self._registry)
|
||||||
|
if provider is None:
|
||||||
|
return
|
||||||
|
recipient_ids = {
|
||||||
|
change.initiator_account_id,
|
||||||
|
change.candidate_account_id,
|
||||||
|
}
|
||||||
|
for account_id in sorted(item for item in recipient_ids if item):
|
||||||
|
provider.enqueue_notification(
|
||||||
|
session,
|
||||||
|
NotificationDispatchRequest(
|
||||||
|
tenant_id=change.tenant_id,
|
||||||
|
source_module="idm",
|
||||||
|
source_resource_type="function_assignment_change",
|
||||||
|
source_resource_id=change.id,
|
||||||
|
event_kind="function_assignment_change.expired",
|
||||||
|
recipient_type="account",
|
||||||
|
recipient_id=account_id,
|
||||||
|
subject="Function assignment change expired",
|
||||||
|
body_text=(
|
||||||
|
"The governed function assignment change expired "
|
||||||
|
"before all required decisions were completed."
|
||||||
|
),
|
||||||
|
action_url=f"/idm?change={change.id}",
|
||||||
|
payload={"change_id": change.id, "state": "expired"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["SqlIdmAssignmentLifecycle"]
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from govoplan_core.core.organizations import OrganizationFunctionRef
|
||||||
|
|
||||||
|
|
||||||
|
ASSIGNMENT_SOURCES = {
|
||||||
|
"direct",
|
||||||
|
"delegated",
|
||||||
|
"acting_for",
|
||||||
|
"directory",
|
||||||
|
"governance",
|
||||||
|
"system",
|
||||||
|
}
|
||||||
|
MUTABLE_ASSIGNMENT_FIELDS = (
|
||||||
|
"identity_id",
|
||||||
|
"account_id",
|
||||||
|
"function_id",
|
||||||
|
"applies_to_subunits",
|
||||||
|
"source",
|
||||||
|
"delegated_from_assignment_id",
|
||||||
|
"acting_for_account_id",
|
||||||
|
"valid_from",
|
||||||
|
"valid_until",
|
||||||
|
"is_active",
|
||||||
|
"settings",
|
||||||
|
)
|
||||||
|
REQUIRED_UPDATE_FIELDS = {
|
||||||
|
"identity_id": "Identity is required.",
|
||||||
|
"function_id": "Function is required.",
|
||||||
|
"applies_to_subunits": "Subunit applicability cannot be empty.",
|
||||||
|
"source": "Assignment source is required.",
|
||||||
|
"is_active": "Active state cannot be empty.",
|
||||||
|
"settings": "Settings cannot be empty.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AssignmentTransitionError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AssignmentLike(Protocol):
|
||||||
|
id: str
|
||||||
|
identity_id: str
|
||||||
|
account_id: str | None
|
||||||
|
function_id: str
|
||||||
|
organization_unit_id: str
|
||||||
|
applies_to_subunits: bool
|
||||||
|
source: str
|
||||||
|
delegated_from_assignment_id: str | None
|
||||||
|
acting_for_account_id: str | None
|
||||||
|
valid_from: datetime | None
|
||||||
|
valid_until: datetime | None
|
||||||
|
is_active: bool
|
||||||
|
settings: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AssignmentSnapshot:
|
||||||
|
id: str
|
||||||
|
identity_id: str
|
||||||
|
account_id: str | None
|
||||||
|
function_id: str
|
||||||
|
organization_unit_id: str
|
||||||
|
applies_to_subunits: bool
|
||||||
|
source: str
|
||||||
|
delegated_from_assignment_id: str | None
|
||||||
|
acting_for_account_id: str | None
|
||||||
|
valid_from: datetime | None
|
||||||
|
valid_until: datetime | None
|
||||||
|
is_active: bool
|
||||||
|
settings: dict[str, Any]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_assignment(cls, item: AssignmentLike) -> AssignmentSnapshot:
|
||||||
|
return cls(
|
||||||
|
id=item.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,
|
||||||
|
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,
|
||||||
|
is_active=item.is_active,
|
||||||
|
settings=dict(item.settings or {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AssignmentMutationPlan:
|
||||||
|
before: AssignmentSnapshot
|
||||||
|
after: AssignmentSnapshot
|
||||||
|
changed_fields: tuple[str, ...]
|
||||||
|
|
||||||
|
def apply(self, item: AssignmentLike) -> None:
|
||||||
|
for field in self.changed_fields:
|
||||||
|
setattr(item, field, getattr(self.after, field))
|
||||||
|
|
||||||
|
|
||||||
|
def plan_assignment_update(
|
||||||
|
item: AssignmentLike,
|
||||||
|
values: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
organization_unit_id: str | None = None,
|
||||||
|
) -> AssignmentMutationPlan:
|
||||||
|
before = AssignmentSnapshot.from_assignment(item)
|
||||||
|
updates: dict[str, object] = {}
|
||||||
|
for field in MUTABLE_ASSIGNMENT_FIELDS:
|
||||||
|
if field not in values:
|
||||||
|
continue
|
||||||
|
value = values[field]
|
||||||
|
message = REQUIRED_UPDATE_FIELDS.get(field)
|
||||||
|
if value is None and message is not None:
|
||||||
|
raise AssignmentTransitionError(message)
|
||||||
|
updates[field] = value
|
||||||
|
if "function_id" in updates:
|
||||||
|
if organization_unit_id is None:
|
||||||
|
raise AssignmentTransitionError(
|
||||||
|
"The organization unit for the selected function is required."
|
||||||
|
)
|
||||||
|
updates["organization_unit_id"] = organization_unit_id
|
||||||
|
if "settings" in updates:
|
||||||
|
updates["settings"] = dict(updates["settings"] or {}) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
after = replace(before, **updates)
|
||||||
|
validate_assignment_shape(after)
|
||||||
|
changed_fields = tuple(
|
||||||
|
field
|
||||||
|
for field in (*MUTABLE_ASSIGNMENT_FIELDS, "organization_unit_id")
|
||||||
|
if getattr(before, field) != getattr(after, field)
|
||||||
|
)
|
||||||
|
return AssignmentMutationPlan(
|
||||||
|
before=before,
|
||||||
|
after=after,
|
||||||
|
changed_fields=changed_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_assignment_shape(item: AssignmentLike) -> None:
|
||||||
|
if item.source not in ASSIGNMENT_SOURCES:
|
||||||
|
raise AssignmentTransitionError("Assignment source is not supported.")
|
||||||
|
if (
|
||||||
|
item.valid_from is not None
|
||||||
|
and item.valid_until is not None
|
||||||
|
and _comparable_datetime(item.valid_until)
|
||||||
|
<= _comparable_datetime(item.valid_from)
|
||||||
|
):
|
||||||
|
raise AssignmentTransitionError("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 AssignmentTransitionError(
|
||||||
|
"A function assignment cannot delegate from itself."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_assignment_source_rules(
|
||||||
|
item: AssignmentLike,
|
||||||
|
*,
|
||||||
|
function: OrganizationFunctionRef,
|
||||||
|
base: AssignmentLike | None,
|
||||||
|
account_linked_to_identity: Callable[[str, str], bool],
|
||||||
|
) -> None:
|
||||||
|
if item.source == "delegated":
|
||||||
|
_validate_delegated_assignment(item, function=function, base=base)
|
||||||
|
return
|
||||||
|
if item.source == "acting_for":
|
||||||
|
_validate_acting_for_assignment(
|
||||||
|
item,
|
||||||
|
function=function,
|
||||||
|
base=base,
|
||||||
|
account_linked_to_identity=account_linked_to_identity,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if item.delegated_from_assignment_id is not None:
|
||||||
|
raise AssignmentTransitionError(
|
||||||
|
"Only delegated or acting-for assignments can reference a source assignment."
|
||||||
|
)
|
||||||
|
if item.acting_for_account_id is not None:
|
||||||
|
raise AssignmentTransitionError(
|
||||||
|
"Only acting-for assignments can set an acting-for account."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def lifecycle_event_types(
|
||||||
|
before: AssignmentSnapshot,
|
||||||
|
after: AssignmentSnapshot,
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
events = ["idm.function_assignment.changed.v1"]
|
||||||
|
if before.is_active and not after.is_active:
|
||||||
|
events.append("idm.function_assignment.revoked.v1")
|
||||||
|
before_expired = _is_expired(before, now=now)
|
||||||
|
after_expired = _is_expired(after, now=now)
|
||||||
|
if after_expired and (
|
||||||
|
not before_expired or before.valid_until != after.valid_until
|
||||||
|
):
|
||||||
|
events.append("idm.function_assignment.expired.v1")
|
||||||
|
return tuple(events)
|
||||||
|
|
||||||
|
|
||||||
|
def assignment_is_expired(
|
||||||
|
item: AssignmentLike | AssignmentSnapshot,
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
) -> bool:
|
||||||
|
return _is_expired(item, now=now)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_expired(
|
||||||
|
item: AssignmentLike | AssignmentSnapshot,
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
) -> bool:
|
||||||
|
return bool(
|
||||||
|
item.is_active
|
||||||
|
and item.valid_until is not None
|
||||||
|
and _comparable_datetime(item.valid_until) <= _comparable_datetime(now)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_delegated_assignment(
|
||||||
|
item: AssignmentLike,
|
||||||
|
*,
|
||||||
|
function: OrganizationFunctionRef,
|
||||||
|
base: AssignmentLike | None,
|
||||||
|
) -> None:
|
||||||
|
if base is None:
|
||||||
|
raise AssignmentTransitionError(
|
||||||
|
"Delegated assignments require a source assignment."
|
||||||
|
)
|
||||||
|
if not function.delegable:
|
||||||
|
raise AssignmentTransitionError(
|
||||||
|
"This organization function does not allow delegation."
|
||||||
|
)
|
||||||
|
if item.acting_for_account_id is not None:
|
||||||
|
raise AssignmentTransitionError(
|
||||||
|
"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 AssignmentTransitionError(
|
||||||
|
"A delegated assignment must target another identity or account."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_acting_for_assignment(
|
||||||
|
item: AssignmentLike,
|
||||||
|
*,
|
||||||
|
function: OrganizationFunctionRef,
|
||||||
|
base: AssignmentLike | None,
|
||||||
|
account_linked_to_identity: Callable[[str, str], bool],
|
||||||
|
) -> None:
|
||||||
|
if base is None:
|
||||||
|
raise AssignmentTransitionError(
|
||||||
|
"Acting-for assignments require a source assignment."
|
||||||
|
)
|
||||||
|
if not function.act_in_place_allowed:
|
||||||
|
raise AssignmentTransitionError(
|
||||||
|
"This organization function does not allow acting in place."
|
||||||
|
)
|
||||||
|
if item.acting_for_account_id is None:
|
||||||
|
raise AssignmentTransitionError(
|
||||||
|
"Acting-for assignments require an acting-for account."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
base.account_id is not None
|
||||||
|
and item.acting_for_account_id != base.account_id
|
||||||
|
):
|
||||||
|
raise AssignmentTransitionError(
|
||||||
|
"Acting-for account must match the source assignment account."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
base.account_id is None
|
||||||
|
and not account_linked_to_identity(
|
||||||
|
base.identity_id,
|
||||||
|
item.acting_for_account_id,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise AssignmentTransitionError(
|
||||||
|
"Acting-for account must belong to the source assignment identity."
|
||||||
|
)
|
||||||
|
if item.account_id == item.acting_for_account_id:
|
||||||
|
raise AssignmentTransitionError(
|
||||||
|
"The acting account and acting-for account must be different."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _comparable_datetime(value: datetime) -> datetime:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ASSIGNMENT_SOURCES",
|
||||||
|
"AssignmentMutationPlan",
|
||||||
|
"AssignmentSnapshot",
|
||||||
|
"AssignmentTransitionError",
|
||||||
|
"assignment_is_expired",
|
||||||
|
"lifecycle_event_types",
|
||||||
|
"plan_assignment_update",
|
||||||
|
"validate_assignment_shape",
|
||||||
|
"validate_assignment_source_rules",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""IDM database models."""
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
JSON,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from govoplan_core.core.concurrency import strong_resource_etag
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_idm_org_function_assignments_expiry_due",
|
||||||
|
"is_active",
|
||||||
|
"expired_event_at",
|
||||||
|
"valid_until",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
expired_event_at: 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 IdmTypedGroup(Base, TimestampMixin):
|
||||||
|
__tablename__ = "idm_typed_groups"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"group_type",
|
||||||
|
"key",
|
||||||
|
name="uq_idm_typed_groups_tenant_type_key",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_idm_typed_groups_tenant_status_name",
|
||||||
|
"tenant_id",
|
||||||
|
"status",
|
||||||
|
"name",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
group_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="active", nullable=False)
|
||||||
|
source_provider: Mapped[str] = mapped_column(String(80), default="local", nullable=False)
|
||||||
|
source_resource_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
source_resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
source_revision: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
properties: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strong_etag(self) -> str:
|
||||||
|
return strong_resource_etag("idm_typed_group", self.id, self.revision)
|
||||||
|
|
||||||
|
|
||||||
|
class IdmIdentityRelationship(Base, TimestampMixin):
|
||||||
|
__tablename__ = "idm_identity_relationships"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"((target_group_id IS NOT NULL AND related_identity_id IS NULL) OR "
|
||||||
|
"(target_group_id IS NULL AND related_identity_id IS NOT NULL))",
|
||||||
|
name="ck_idm_relationship_exactly_one_target",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from",
|
||||||
|
name="ck_idm_relationship_valid_window",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_idm_relationships_tenant_subject_effective",
|
||||||
|
"tenant_id",
|
||||||
|
"subject_identity_id",
|
||||||
|
"status",
|
||||||
|
"valid_from",
|
||||||
|
"valid_until",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_idm_relationships_tenant_group_effective",
|
||||||
|
"tenant_id",
|
||||||
|
"target_group_id",
|
||||||
|
"status",
|
||||||
|
"valid_from",
|
||||||
|
"valid_until",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_idm_relationships_expiry_due",
|
||||||
|
"status",
|
||||||
|
"expired_event_at",
|
||||||
|
"valid_until",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
relationship_kind: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||||
|
subject_identity_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("identity_identities.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
target_group_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("idm_typed_groups.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
related_identity_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("identity_identities.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
role: Mapped[str | None] = mapped_column(String(120), nullable=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)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="active", nullable=False)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
revoked_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
revocation_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
expired_event_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
source_provider: Mapped[str] = mapped_column(String(80), default="local", nullable=False)
|
||||||
|
source_resource_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
source_resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
source_revision: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
properties: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strong_etag(self) -> str:
|
||||||
|
return strong_resource_etag("idm_identity_relationship", self.id, self.revision)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class IdmFunctionAssignmentChange(Base, TimestampMixin):
|
||||||
|
__tablename__ = "idm_function_assignment_changes"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"kind",
|
||||||
|
"initiator_account_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_idm_function_assignment_change_idempotency",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_idm_function_assignment_changes_tenant_state",
|
||||||
|
"tenant_id",
|
||||||
|
"state",
|
||||||
|
"updated_at",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_idm_function_assignment_changes_candidate",
|
||||||
|
"tenant_id",
|
||||||
|
"candidate_identity_id",
|
||||||
|
"state",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_idm_function_assignment_changes_expiry",
|
||||||
|
"state",
|
||||||
|
"expires_at",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
state: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
|
profile: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||||
|
function_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("organizations_functions.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
organization_unit_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), nullable=False, index=True
|
||||||
|
)
|
||||||
|
candidate_identity_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("identity_identities.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
candidate_account_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True, index=True
|
||||||
|
)
|
||||||
|
initiator_account_id: Mapped[str] = mapped_column(
|
||||||
|
String(36), nullable=False, index=True
|
||||||
|
)
|
||||||
|
initiator_identity_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True, index=True
|
||||||
|
)
|
||||||
|
represented_assignment_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("idm_organization_function_assignments.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
justification: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
evidence: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
requested_valid_from: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
requested_valid_until: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
applies_to_subunits: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, nullable=False
|
||||||
|
)
|
||||||
|
assignment_source: Mapped[str] = mapped_column(
|
||||||
|
String(50), default="governance", nullable=False
|
||||||
|
)
|
||||||
|
required_steps: Mapped[list[str]] = mapped_column(
|
||||||
|
JSON, default=list, nullable=False
|
||||||
|
)
|
||||||
|
completed_steps: Mapped[list[str]] = mapped_column(
|
||||||
|
JSON, default=list, nullable=False
|
||||||
|
)
|
||||||
|
policy_decision: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
)
|
||||||
|
workflow_definition_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True, index=True
|
||||||
|
)
|
||||||
|
workflow_definition_revision_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True
|
||||||
|
)
|
||||||
|
workflow_definition_revision: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, nullable=True
|
||||||
|
)
|
||||||
|
workflow_definition_hash: Mapped[str | None] = mapped_column(
|
||||||
|
String(64), nullable=True
|
||||||
|
)
|
||||||
|
workflow_instance_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True, unique=True
|
||||||
|
)
|
||||||
|
workflow_current_step_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True
|
||||||
|
)
|
||||||
|
resulting_assignment_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("idm_organization_function_assignments.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
outcome_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
metadata_: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
"metadata", JSON, default=dict, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strong_etag(self) -> str:
|
||||||
|
return strong_resource_etag(
|
||||||
|
"idm_function_assignment_change",
|
||||||
|
self.id,
|
||||||
|
self.resource_revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IdmFunctionAssignmentChangeEvent(Base):
|
||||||
|
__tablename__ = "idm_function_assignment_change_events"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"change_id",
|
||||||
|
"sequence",
|
||||||
|
name="uq_idm_function_assignment_change_event_sequence",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_idm_function_assignment_change_events_tenant_change",
|
||||||
|
"tenant_id",
|
||||||
|
"change_id",
|
||||||
|
"sequence",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
change_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("idm_function_assignment_changes.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
from_state: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||||
|
to_state: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
|
actor_account_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True, index=True
|
||||||
|
)
|
||||||
|
actor_identity_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
actor_assignment_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
evidence: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
policy_decision: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSON, default=dict, nullable=False
|
||||||
|
)
|
||||||
|
workflow_step_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
|
details: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"IdmFunctionAssignmentChange",
|
||||||
|
"IdmFunctionAssignmentChangeEvent",
|
||||||
|
"IdmIdentityRelationship",
|
||||||
|
"IdmOrganizationFunctionAssignment",
|
||||||
|
"IdmTenantSettings",
|
||||||
|
"IdmTypedGroup",
|
||||||
|
"new_uuid",
|
||||||
|
]
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from govoplan_core.core.identity import IdentityDirectory
|
||||||
|
from govoplan_core.core.idm import (
|
||||||
|
IdmDirectory,
|
||||||
|
OrganizationFunctionAssignmentRef,
|
||||||
|
OrganizationFunctionIncumbencyRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.organizations import OrganizationDirectory
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
from govoplan_core.security.time import utc_now
|
||||||
|
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
|
||||||
|
|
||||||
|
|
||||||
|
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 __init__(self, *, identities: IdentityDirectory, organizations: OrganizationDirectory) -> None:
|
||||||
|
self._identities = identities
|
||||||
|
self._organizations = organizations
|
||||||
|
|
||||||
|
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,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
||||||
|
return tuple(
|
||||||
|
self.organization_function_assignments_for_identities(
|
||||||
|
(identity_id,),
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=effective_at,
|
||||||
|
).get(identity_id, ())
|
||||||
|
)
|
||||||
|
|
||||||
|
def organization_function_assignments_for_account(
|
||||||
|
self,
|
||||||
|
account_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
||||||
|
return tuple(
|
||||||
|
self.organization_function_assignments_for_accounts(
|
||||||
|
(account_id,),
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=effective_at,
|
||||||
|
).get(account_id, ())
|
||||||
|
)
|
||||||
|
|
||||||
|
def organization_function_assignments_for_identities(
|
||||||
|
self,
|
||||||
|
identity_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> dict[str, tuple[OrganizationFunctionAssignmentRef, ...]]:
|
||||||
|
requested = tuple(dict.fromkeys(identity_ids))
|
||||||
|
result: dict[str, list[OrganizationFunctionAssignmentRef]] = {
|
||||||
|
identity_id: [] for identity_id in requested
|
||||||
|
}
|
||||||
|
if not requested:
|
||||||
|
return {}
|
||||||
|
with get_database().session() as session:
|
||||||
|
items = self._effective_assignment_items(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=effective_at or utc_now(),
|
||||||
|
identity_ids=requested,
|
||||||
|
)
|
||||||
|
for item in items:
|
||||||
|
result[item.identity_id].append(_assignment_ref(item))
|
||||||
|
return {
|
||||||
|
identity_id: tuple(assignments)
|
||||||
|
for identity_id, assignments in result.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def organization_function_assignments_for_accounts(
|
||||||
|
self,
|
||||||
|
account_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> dict[str, tuple[OrganizationFunctionAssignmentRef, ...]]:
|
||||||
|
requested = tuple(dict.fromkeys(account_ids))
|
||||||
|
result: dict[str, list[OrganizationFunctionAssignmentRef]] = {
|
||||||
|
account_id: [] for account_id in requested
|
||||||
|
}
|
||||||
|
if not requested:
|
||||||
|
return {}
|
||||||
|
identities = self._identities.identities_for_accounts(requested)
|
||||||
|
identity_ids_by_account: dict[str, set[str]] = {
|
||||||
|
account_id: set() for account_id in requested
|
||||||
|
}
|
||||||
|
requested_set = set(requested)
|
||||||
|
for identity in identities:
|
||||||
|
linked_accounts = set(identity.account_ids)
|
||||||
|
if identity.primary_account_id:
|
||||||
|
linked_accounts.add(identity.primary_account_id)
|
||||||
|
for account_id in linked_accounts & requested_set:
|
||||||
|
identity_ids_by_account[account_id].add(identity.id)
|
||||||
|
identity_ids = tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
identity_id
|
||||||
|
for values in identity_ids_by_account.values()
|
||||||
|
for identity_id in values
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not identity_ids:
|
||||||
|
return {
|
||||||
|
account_id: tuple(assignments)
|
||||||
|
for account_id, assignments in result.items()
|
||||||
|
}
|
||||||
|
with get_database().session() as session:
|
||||||
|
items = self._effective_assignment_items(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=effective_at or utc_now(),
|
||||||
|
identity_ids=identity_ids,
|
||||||
|
)
|
||||||
|
for account_id, linked_identity_ids in identity_ids_by_account.items():
|
||||||
|
result[account_id].extend(
|
||||||
|
_assignment_ref(item)
|
||||||
|
for item in items
|
||||||
|
if item.identity_id in linked_identity_ids
|
||||||
|
and item.account_id in {None, account_id}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
account_id: tuple(assignments)
|
||||||
|
for account_id, assignments in result.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def organization_function_assignments_for_function(
|
||||||
|
self,
|
||||||
|
function_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
||||||
|
function = self._organizations.get_function(function_id)
|
||||||
|
if function is None:
|
||||||
|
return ()
|
||||||
|
if tenant_id is not None and function.tenant_id != tenant_id:
|
||||||
|
raise ValueError("Organization function belongs to another tenant.")
|
||||||
|
resolved_tenant_id = tenant_id or function.tenant_id
|
||||||
|
return self.organization_function_incumbencies(
|
||||||
|
(function_id,),
|
||||||
|
tenant_id=resolved_tenant_id,
|
||||||
|
effective_at=effective_at,
|
||||||
|
)[function_id].assignments
|
||||||
|
|
||||||
|
def organization_function_incumbencies(
|
||||||
|
self,
|
||||||
|
function_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> dict[str, OrganizationFunctionIncumbencyRef]:
|
||||||
|
requested = tuple(dict.fromkeys(function_ids))
|
||||||
|
functions = {}
|
||||||
|
for function_id in requested:
|
||||||
|
function = self._organizations.get_function(function_id)
|
||||||
|
if function is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Organization function does not exist: {function_id}"
|
||||||
|
)
|
||||||
|
if function.tenant_id != tenant_id:
|
||||||
|
raise ValueError(
|
||||||
|
"Organization function belongs to another tenant."
|
||||||
|
)
|
||||||
|
functions[function_id] = function
|
||||||
|
if not requested:
|
||||||
|
return {}
|
||||||
|
with get_database().session() as session:
|
||||||
|
items = self._effective_assignment_items(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=effective_at or utc_now(),
|
||||||
|
function_ids=requested,
|
||||||
|
)
|
||||||
|
assignments: dict[str, list[OrganizationFunctionAssignmentRef]] = {
|
||||||
|
function_id: [] for function_id in requested
|
||||||
|
}
|
||||||
|
for item in items:
|
||||||
|
assignments[item.function_id].append(_assignment_ref(item))
|
||||||
|
return {
|
||||||
|
function_id: OrganizationFunctionIncumbencyRef(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
function_id=function_id,
|
||||||
|
assignments=tuple(assignments[function_id]),
|
||||||
|
function_active=functions[function_id].status == "active",
|
||||||
|
)
|
||||||
|
for function_id in requested
|
||||||
|
}
|
||||||
|
|
||||||
|
def _effective_assignment_items(
|
||||||
|
self,
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
effective_at: datetime,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
identity_ids: Sequence[str] = (),
|
||||||
|
function_ids: Sequence[str] = (),
|
||||||
|
) -> tuple[IdmOrganizationFunctionAssignment, ...]:
|
||||||
|
query = (
|
||||||
|
session.query(IdmOrganizationFunctionAssignment)
|
||||||
|
.filter(
|
||||||
|
IdmOrganizationFunctionAssignment.is_active.is_(True),
|
||||||
|
or_(
|
||||||
|
IdmOrganizationFunctionAssignment.valid_from.is_(None),
|
||||||
|
IdmOrganizationFunctionAssignment.valid_from <= effective_at,
|
||||||
|
),
|
||||||
|
or_(
|
||||||
|
IdmOrganizationFunctionAssignment.valid_until.is_(None),
|
||||||
|
IdmOrganizationFunctionAssignment.valid_until > effective_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(IdmOrganizationFunctionAssignment.created_at.asc())
|
||||||
|
)
|
||||||
|
if identity_ids:
|
||||||
|
query = query.filter(
|
||||||
|
IdmOrganizationFunctionAssignment.identity_id.in_(identity_ids),
|
||||||
|
)
|
||||||
|
if function_ids:
|
||||||
|
query = query.filter(
|
||||||
|
IdmOrganizationFunctionAssignment.function_id.in_(function_ids),
|
||||||
|
)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.filter(
|
||||||
|
IdmOrganizationFunctionAssignment.tenant_id == tenant_id
|
||||||
|
)
|
||||||
|
items = query.all()
|
||||||
|
source_ids = {
|
||||||
|
item.delegated_from_assignment_id
|
||||||
|
for item in items
|
||||||
|
if item.source in {"delegated", "acting_for"}
|
||||||
|
and item.delegated_from_assignment_id is not None
|
||||||
|
}
|
||||||
|
effective_sources = (
|
||||||
|
session.query(IdmOrganizationFunctionAssignment)
|
||||||
|
.filter(
|
||||||
|
IdmOrganizationFunctionAssignment.id.in_(source_ids),
|
||||||
|
IdmOrganizationFunctionAssignment.is_active.is_(True),
|
||||||
|
or_(
|
||||||
|
IdmOrganizationFunctionAssignment.valid_from.is_(None),
|
||||||
|
IdmOrganizationFunctionAssignment.valid_from <= effective_at,
|
||||||
|
),
|
||||||
|
or_(
|
||||||
|
IdmOrganizationFunctionAssignment.valid_until.is_(None),
|
||||||
|
IdmOrganizationFunctionAssignment.valid_until > effective_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
if source_ids
|
||||||
|
else ()
|
||||||
|
)
|
||||||
|
effective_sources_by_id = {
|
||||||
|
item.id: item for item in effective_sources
|
||||||
|
}
|
||||||
|
function_cache = {}
|
||||||
|
active_items: list[IdmOrganizationFunctionAssignment] = []
|
||||||
|
for item in items:
|
||||||
|
if item.source in {"delegated", "acting_for"}:
|
||||||
|
source = effective_sources_by_id.get(
|
||||||
|
item.delegated_from_assignment_id or ""
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
source is None
|
||||||
|
or source.tenant_id != item.tenant_id
|
||||||
|
or source.function_id != item.function_id
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if item.function_id not in function_cache:
|
||||||
|
function_cache[item.function_id] = (
|
||||||
|
self._organizations.get_function(item.function_id)
|
||||||
|
)
|
||||||
|
function = function_cache[item.function_id]
|
||||||
|
if (
|
||||||
|
function is None
|
||||||
|
or function.status != "active"
|
||||||
|
or function.tenant_id != item.tenant_id
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
active_items.append(item)
|
||||||
|
return tuple(active_items)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,528 @@
|
|||||||
|
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.identity import (
|
||||||
|
CAPABILITY_IDENTITY_DIRECTORY,
|
||||||
|
CAPABILITY_IDENTITY_SEARCH,
|
||||||
|
IdentityDirectory,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.idm import (
|
||||||
|
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
|
||||||
|
CAPABILITY_IDM_DIRECTORY,
|
||||||
|
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||||
|
CAPABILITY_IDM_RELATIONSHIPS,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
||||||
|
from govoplan_core.core.policy import (
|
||||||
|
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.workflows import CAPABILITY_WORKFLOW_ORCHESTRATION
|
||||||
|
from govoplan_core.core.organizations import (
|
||||||
|
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||||
|
OrganizationDirectory,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
DocumentationCondition,
|
||||||
|
DocumentationLink,
|
||||||
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleInterfaceRequirement,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
PermissionDefinition,
|
||||||
|
RoleTemplate,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||||
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_idm.backend.db import models as idm_models # noqa: F401 - populate metadata
|
||||||
|
from govoplan_idm.backend.workflow_definitions import (
|
||||||
|
function_assignment_workflow_definitions,
|
||||||
|
)
|
||||||
|
from govoplan_idm.backend.search_source import create_idm_search_source
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_VERSION = "0.1.18"
|
||||||
|
|
||||||
|
IDM_READ_SCOPES = (
|
||||||
|
"idm:organization_assignment:read",
|
||||||
|
"idm:organization_assignment:write",
|
||||||
|
"idm:settings:read",
|
||||||
|
"idm:function_change:read",
|
||||||
|
"idm:function_request:create",
|
||||||
|
"idm:function_grant:create",
|
||||||
|
"idm:function_change:decide",
|
||||||
|
"idm:function_change:admin",
|
||||||
|
"idm:relationship:read",
|
||||||
|
"idm:relationship:write",
|
||||||
|
"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.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"idm:function_change:read",
|
||||||
|
"View function assignment changes",
|
||||||
|
"View governed function requests, grants, decisions, and outcomes.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"idm:function_request:create",
|
||||||
|
"Request organization functions",
|
||||||
|
"Request assignment to an eligible organization function.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"idm:function_grant:create",
|
||||||
|
"Propose organization function grants",
|
||||||
|
"Bestow an organization function through its governed grant profile.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"idm:function_change:decide",
|
||||||
|
"Decide function assignment changes",
|
||||||
|
"Approve, reject, or accept governed function assignment changes when eligible.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"idm:function_change:admin",
|
||||||
|
"Recover function assignment changes",
|
||||||
|
"Inspect and recover blocked or failed function assignment workflows.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"idm:relationship:read",
|
||||||
|
"View typed identity relationships",
|
||||||
|
"View typed groups and effective-dated identity relationships.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"idm:relationship:write",
|
||||||
|
"Manage typed identity relationships",
|
||||||
|
"Create, change, revoke, and synchronize typed groups and identity relationships.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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",
|
||||||
|
"idm:function_change:read",
|
||||||
|
"idm:function_request:create",
|
||||||
|
"idm:function_grant:create",
|
||||||
|
"idm:function_change:decide",
|
||||||
|
"idm:function_change:admin",
|
||||||
|
"idm:relationship:read",
|
||||||
|
"idm:relationship:write",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
RoleTemplate(
|
||||||
|
slug="idm_function_participant",
|
||||||
|
name="IDM function participant",
|
||||||
|
description="Request functions and participate in governed assignment decisions.",
|
||||||
|
permissions=(
|
||||||
|
"idm:function_change:read",
|
||||||
|
"idm:function_request:create",
|
||||||
|
"idm:function_change:decide",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _route_factory(context: ModuleContext):
|
||||||
|
del context
|
||||||
|
from govoplan_idm.backend.api.v1.routes import router
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _idm_directory(context: ModuleContext) -> object:
|
||||||
|
from govoplan_idm.backend.directory import SqlIdmDirectory
|
||||||
|
|
||||||
|
identities = context.registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||||
|
organizations = context.registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY)
|
||||||
|
if not isinstance(identities, IdentityDirectory):
|
||||||
|
raise RuntimeError(f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
|
||||||
|
if not isinstance(organizations, OrganizationDirectory):
|
||||||
|
raise RuntimeError(f"Invalid capability: {CAPABILITY_ORGANIZATION_DIRECTORY}")
|
||||||
|
return SqlIdmDirectory(identities=identities, organizations=organizations)
|
||||||
|
|
||||||
|
|
||||||
|
def _assignment_lifecycle(context: ModuleContext) -> object:
|
||||||
|
from govoplan_idm.backend.assignment_lifecycle import (
|
||||||
|
SqlIdmAssignmentLifecycle,
|
||||||
|
)
|
||||||
|
|
||||||
|
return SqlIdmAssignmentLifecycle(registry=context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _relationship_directory(context: ModuleContext) -> object:
|
||||||
|
from govoplan_idm.backend.relationships import SqlIdmRelationshipDirectory
|
||||||
|
|
||||||
|
identities = context.registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||||
|
if not isinstance(identities, IdentityDirectory):
|
||||||
|
raise RuntimeError(f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
|
||||||
|
return SqlIdmRelationshipDirectory(identities=identities)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = ModuleManifest(
|
||||||
|
id="idm",
|
||||||
|
name="IDM",
|
||||||
|
version=MODULE_VERSION,
|
||||||
|
dependencies=("identity", "organizations"),
|
||||||
|
optional_dependencies=(
|
||||||
|
"access",
|
||||||
|
"audit",
|
||||||
|
"notifications",
|
||||||
|
"policy",
|
||||||
|
"workflow_engine",
|
||||||
|
"search",
|
||||||
|
),
|
||||||
|
optional_capabilities=(
|
||||||
|
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||||
|
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE,
|
||||||
|
CAPABILITY_WORKFLOW_ORCHESTRATION,
|
||||||
|
),
|
||||||
|
required_capabilities=(
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
CAPABILITY_IDENTITY_DIRECTORY,
|
||||||
|
CAPABILITY_IDENTITY_SEARCH,
|
||||||
|
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||||
|
),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||||
|
version="0.1.8",
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
|
||||||
|
version=MODULE_VERSION,
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_IDM_RELATIONSHIPS,
|
||||||
|
version="1.0.0",
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name="idm.function_assignment_changes",
|
||||||
|
version="1.0.0",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
requires_interfaces=(
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="search.source",
|
||||||
|
version_min="1.0.0",
|
||||||
|
version_max_exclusive="2.0.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
permissions=PERMISSIONS,
|
||||||
|
role_templates=ROLE_TEMPLATES,
|
||||||
|
route_factory=_route_factory,
|
||||||
|
search_sources=(
|
||||||
|
SearchSourceProviderRegistration(
|
||||||
|
id="idm.directory",
|
||||||
|
factory=create_idm_search_source,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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),),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="idm.action.view-function-assignments",
|
||||||
|
module_id="idm",
|
||||||
|
kind="action",
|
||||||
|
label="View function assignments",
|
||||||
|
order=40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
idm_models.IdmFunctionAssignmentChange,
|
||||||
|
idm_models.IdmFunctionAssignmentChangeEvent,
|
||||||
|
idm_models.IdmTypedGroup,
|
||||||
|
idm_models.IdmIdentityRelationship,
|
||||||
|
label="IDM",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE: _assignment_lifecycle,
|
||||||
|
CAPABILITY_IDM_DIRECTORY: _idm_directory,
|
||||||
|
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS: _idm_directory,
|
||||||
|
CAPABILITY_IDM_RELATIONSHIPS: _relationship_directory,
|
||||||
|
},
|
||||||
|
workflow_definitions=function_assignment_workflow_definitions(
|
||||||
|
module_version=MODULE_VERSION,
|
||||||
|
),
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="idm.search.directory",
|
||||||
|
title="Search authorized IDM records",
|
||||||
|
summary="Expose typed groups, effective-dated relationships, and organization-function assignments to permission-aware platform Search.",
|
||||||
|
body=(
|
||||||
|
"When Search is installed, IDM contributes bounded directory and assignment metadata without "
|
||||||
|
"copying unrestricted provenance payloads. Every result is tenant-bounded and rechecks the current "
|
||||||
|
"assignment or relationship read authority. Committed IDM lifecycle events update the derived "
|
||||||
|
"index, while an operator rebuild reconciles records created before Search was enabled."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("tenant_admin", "access_admin", "operator"),
|
||||||
|
related_modules=("search", "identity", "organizations"),
|
||||||
|
order=25,
|
||||||
|
),
|
||||||
|
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. "
|
||||||
|
"A periodic worker emits one expiry event when a future-dated assignment elapses; the marker and event are committed together so retries remain idempotent. "
|
||||||
|
"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"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="IDM workspace",
|
||||||
|
href="/idm",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="IDM settings API",
|
||||||
|
href="/api/v1/idm/settings",
|
||||||
|
kind="api",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"idm.governance.settings",
|
||||||
|
"idm.assignment.change-request",
|
||||||
|
"idm.assignment.emergency-override",
|
||||||
|
"idm.function-change.request",
|
||||||
|
"idm.function-change.grant",
|
||||||
|
"idm.function-change.decision",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"governance_settings": "Changes whether direct assignment mutations require approved change evidence.",
|
||||||
|
"emergency_override": "Bypasses the normal governed request or grant path and requires retained reason and evidence.",
|
||||||
|
"function_decision": "Advances or terminates a governed change and retains actor, comment, policy, and workflow evidence.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order=27,
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="idm.reference.typed-relationships",
|
||||||
|
title="Typed groups and effective relationships",
|
||||||
|
summary="IDM keeps business group membership separate from identity lifecycle status.",
|
||||||
|
body=(
|
||||||
|
"Typed groups and identity relationships are tenant-scoped, effective-dated facts. "
|
||||||
|
"Current, future, expired, and revoked links remain explainable, including external "
|
||||||
|
"directory source revisions and provenance. Consumers such as Distribution Lists use "
|
||||||
|
"the IDM relationship capability and never infer application permissions from membership."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("tenant_admin", "operator", "module_admin"),
|
||||||
|
related_modules=("identity", "organizations", "dist_lists"),
|
||||||
|
order=28,
|
||||||
|
),
|
||||||
|
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. "
|
||||||
|
"The assignment workspace uses the available application width so governance controls and assignment data remain visible together."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("tenant_admin", "access_admin", "operator", "user"),
|
||||||
|
related_modules=("identity", "organizations", "access"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(any_scopes=IDM_READ_SCOPES),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="IDM assignments",
|
||||||
|
href="/idm",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Organization function assignments API",
|
||||||
|
href="/api/v1/idm/organization-function-assignments",
|
||||||
|
kind="api",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": [
|
||||||
|
"idm.route.assignments",
|
||||||
|
"idm.action.view-function-assignments",
|
||||||
|
"idm.blocker.permission",
|
||||||
|
"idm.blocker.no-functions",
|
||||||
|
"idm.blocker.identity-search",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
order=28,
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="idm.reference.fields-and-consequences",
|
||||||
|
title="IDM assignment fields and consequences",
|
||||||
|
summary=(
|
||||||
|
"Reference for direct assignments, delegation, acting-for, "
|
||||||
|
"effective dates, governed changes, evidence, and retention."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"Identity and account select who receives the institutional fact; "
|
||||||
|
"function and unit are owned by Organizations. Source distinguishes "
|
||||||
|
"direct, delegated, acting-for, directory, governance, and system facts. "
|
||||||
|
"Delegation and acting-for require a valid source assignment and the "
|
||||||
|
"corresponding function permission. Subunit scope broadens the fact's "
|
||||||
|
"organizational reach. Deactivation and expiry preserve provenance while "
|
||||||
|
"removing the assignment from effective resolution. Governed request and "
|
||||||
|
"grant decisions retain actor, policy, workflow revision, comments, and "
|
||||||
|
"evidence. An emergency override is not the normal process and must carry "
|
||||||
|
"an explicit reason. An IDM assignment alone never grants application "
|
||||||
|
"permissions; Access requires an explicit mapping."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("tenant_admin", "access_admin", "operator", "user"),
|
||||||
|
related_modules=("identity", "organizations", "access", "policy", "audit", "workflow_engine"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Function assignment workflows",
|
||||||
|
href="docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"idm.field.identity",
|
||||||
|
"idm.field.account",
|
||||||
|
"idm.field.function",
|
||||||
|
"idm.field.source",
|
||||||
|
"idm.field.delegation",
|
||||||
|
"idm.field.acting-for",
|
||||||
|
"idm.field.subunits",
|
||||||
|
"idm.field.effective-dates",
|
||||||
|
"idm.field.justification",
|
||||||
|
"idm.field.evidence",
|
||||||
|
"idm.field.retention",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"assignment": "Changes the effective institutional function fact consumed by optional downstream capabilities.",
|
||||||
|
"deactivate_or_expire": "Removes the fact from effective resolution while retaining provenance and lifecycle evidence.",
|
||||||
|
"delegation": "Creates a bounded derived assignment that remains tied to its source assignment.",
|
||||||
|
"acting_for": "Allows a bounded account to act in place of a source assignment when Organizations permits it.",
|
||||||
|
"retention": "Changes how long detailed assignment-change evidence remains available.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order=29,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
architecture=declared_module_architecture(
|
||||||
|
layer="institutional_foundation",
|
||||||
|
kind="foundation",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
documentation_ref="docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",
|
||||||
|
test_ref="tests/test_assignment_workflow.py",
|
||||||
|
known_limits=("External directory provisioning and all authority-specific grant workflows are not reference-ready.",),
|
||||||
|
owned_concepts=("function assignment", "assignment delegation", "acting-for assignment", "assignment request", "typed group", "identity relationship"),
|
||||||
|
non_owned_concepts=("identity", "organization function", "application role", "workflow runtime"),
|
||||||
|
recovery_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md"),
|
||||||
|
security_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md"),
|
||||||
|
operations_docs=("README.md",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest() -> ModuleManifest:
|
||||||
|
return manifest
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""IDM database migrations."""
|
||||||
+139
@@ -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")
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
"""Track emitted assignment expiry events.
|
||||||
|
|
||||||
|
Revision ID: 9a0b1c2d3e4f
|
||||||
|
Revises: 8f9a0b1c2d3e
|
||||||
|
Create Date: 2026-07-31 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "9a0b1c2d3e4f"
|
||||||
|
down_revision = "8f9a0b1c2d3e"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"idm_organization_function_assignments",
|
||||||
|
sa.Column("expired_event_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_idm_org_function_assignments_expiry_due",
|
||||||
|
"idm_organization_function_assignments",
|
||||||
|
["is_active", "expired_event_at", "valid_until"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"ix_idm_org_function_assignments_expiry_due",
|
||||||
|
table_name="idm_organization_function_assignments",
|
||||||
|
)
|
||||||
|
op.drop_column(
|
||||||
|
"idm_organization_function_assignments",
|
||||||
|
"expired_event_at",
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""IDM migration revisions."""
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""v0.1.7 idm baseline
|
||||||
|
|
||||||
|
Revision ID: 8f9a0b1c2d3e
|
||||||
|
Revises: None
|
||||||
|
Create Date: 2026-07-11 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = '8f9a0b1c2d3e'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = ('5c6d7e8f9a10', '6d7e8f9a0b1c')
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table('idm_tenant_settings',
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('require_assignment_change_requests', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('audit_detail_level', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('change_retention_days', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('settings', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('tenant_id', name=op.f('pk_idm_tenant_settings'))
|
||||||
|
)
|
||||||
|
op.create_table('idm_organization_function_assignments',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('identity_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('account_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('function_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('organization_unit_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('applies_to_subunits', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('source', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('delegated_from_assignment_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('acting_for_account_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('settings', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['delegated_from_assignment_id'], ['idm_organization_function_assignments.id'], name=op.f('fk_idm_organization_function_assignments_delegated_from_assignment_id_idm_organization_function_assignments'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['function_id'], ['organizations_functions.id'], name=op.f('fk_idm_organization_function_assignments_function_id_organizations_functions'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['identity_id'], ['identity_identities.id'], name=op.f('fk_idm_organization_function_assignments_identity_id_identity_identities'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['organization_unit_id'], ['organizations_units.id'], name=op.f('fk_idm_organization_function_assignments_organization_unit_id_organizations_units'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_idm_organization_function_assignments')),
|
||||||
|
sa.UniqueConstraint('tenant_id', 'identity_id', 'function_id', 'organization_unit_id', name='uq_idm_org_function_assignments_identity_scope')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_idm_organization_function_assignments_account_id'), 'idm_organization_function_assignments', ['account_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_idm_organization_function_assignments_acting_for_account_id'), 'idm_organization_function_assignments', ['acting_for_account_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_idm_organization_function_assignments_delegated_from_assignment_id'), 'idm_organization_function_assignments', ['delegated_from_assignment_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_idm_organization_function_assignments_function_id'), 'idm_organization_function_assignments', ['function_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_idm_organization_function_assignments_identity_id'), 'idm_organization_function_assignments', ['identity_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_idm_organization_function_assignments_organization_unit_id'), 'idm_organization_function_assignments', ['organization_unit_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_idm_organization_function_assignments_tenant_id'), 'idm_organization_function_assignments', ['tenant_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('idm_organization_function_assignments')
|
||||||
|
op.drop_table('idm_tenant_settings')
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
"""Track emitted assignment expiry events.
|
||||||
|
|
||||||
|
Revision ID: 9a0b1c2d3e4f
|
||||||
|
Revises: 8f9a0b1c2d3e
|
||||||
|
Create Date: 2026-07-31 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "9a0b1c2d3e4f"
|
||||||
|
down_revision = "8f9a0b1c2d3e"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"idm_organization_function_assignments",
|
||||||
|
sa.Column("expired_event_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_idm_org_function_assignments_expiry_due",
|
||||||
|
"idm_organization_function_assignments",
|
||||||
|
["is_active", "expired_event_at", "valid_until"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"ix_idm_org_function_assignments_expiry_due",
|
||||||
|
table_name="idm_organization_function_assignments",
|
||||||
|
)
|
||||||
|
op.drop_column(
|
||||||
|
"idm_organization_function_assignments",
|
||||||
|
"expired_event_at",
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""IDM migration revisions."""
|
||||||
+226
@@ -0,0 +1,226 @@
|
|||||||
|
"""Add governed function assignment change aggregates.
|
||||||
|
|
||||||
|
Revision ID: a0b1c2d3e4f5
|
||||||
|
Revises: 9a0b1c2d3e4f
|
||||||
|
Create Date: 2026-07-31 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "a0b1c2d3e4f5"
|
||||||
|
down_revision = "9a0b1c2d3e4f"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"idm_function_assignment_changes",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("kind", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("state", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("profile", sa.String(length=60), nullable=False),
|
||||||
|
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("organization_unit_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("candidate_identity_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("candidate_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("initiator_account_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("initiator_identity_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("represented_assignment_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("justification", sa.Text(), nullable=False),
|
||||||
|
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("requested_valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("requested_valid_until", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("applies_to_subunits", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("assignment_source", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("required_steps", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("completed_steps", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("policy_decision", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("workflow_definition_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"workflow_definition_revision_id", sa.String(length=36), nullable=True
|
||||||
|
),
|
||||||
|
sa.Column("workflow_definition_revision", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("workflow_definition_hash", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("workflow_instance_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("workflow_current_step_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("resulting_assignment_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("outcome_reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("resource_revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("metadata", 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(
|
||||||
|
["candidate_identity_id"],
|
||||||
|
["identity_identities.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_idm_function_assignment_changes_candidate_identity_id_identity_identities"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["function_id"],
|
||||||
|
["organizations_functions.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_idm_function_assignment_changes_function_id_organizations_functions"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["represented_assignment_id"],
|
||||||
|
["idm_organization_function_assignments.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_idm_function_assignment_changes_represented_assignment_id_idm_assignments"
|
||||||
|
),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["resulting_assignment_id"],
|
||||||
|
["idm_organization_function_assignments.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_idm_function_assignment_changes_resulting_assignment_id_idm_assignments"
|
||||||
|
),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_idm_function_assignment_changes")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"kind",
|
||||||
|
"initiator_account_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_idm_function_assignment_change_idempotency",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"workflow_instance_id",
|
||||||
|
name=op.f("uq_idm_function_assignment_changes_workflow_instance_id"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for name, columns in (
|
||||||
|
("ix_idm_function_assignment_changes_tenant_id", ["tenant_id"]),
|
||||||
|
("ix_idm_function_assignment_changes_kind", ["kind"]),
|
||||||
|
("ix_idm_function_assignment_changes_state", ["state"]),
|
||||||
|
("ix_idm_function_assignment_changes_function_id", ["function_id"]),
|
||||||
|
(
|
||||||
|
"ix_idm_function_assignment_changes_organization_unit_id",
|
||||||
|
["organization_unit_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_idm_function_assignment_changes_candidate_identity_id",
|
||||||
|
["candidate_identity_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_idm_function_assignment_changes_candidate_account_id",
|
||||||
|
["candidate_account_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_idm_function_assignment_changes_initiator_account_id",
|
||||||
|
["initiator_account_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_idm_function_assignment_changes_initiator_identity_id",
|
||||||
|
["initiator_identity_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_idm_function_assignment_changes_represented_assignment_id",
|
||||||
|
["represented_assignment_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_idm_function_assignment_changes_workflow_definition_id",
|
||||||
|
["workflow_definition_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_idm_function_assignment_changes_resulting_assignment_id",
|
||||||
|
["resulting_assignment_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_idm_function_assignment_changes_tenant_state",
|
||||||
|
["tenant_id", "state", "updated_at"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_idm_function_assignment_changes_candidate",
|
||||||
|
["tenant_id", "candidate_identity_id", "state"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_idm_function_assignment_changes_expiry",
|
||||||
|
["state", "expires_at"],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
op.create_index(name, "idm_function_assignment_changes", columns, unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"idm_function_assignment_change_events",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("change_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("action", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("from_state", sa.String(length=40), nullable=True),
|
||||||
|
sa.Column("to_state", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("actor_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("actor_identity_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("actor_assignment_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("comment", sa.Text(), nullable=True),
|
||||||
|
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("policy_decision", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("workflow_step_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("details", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["change_id"],
|
||||||
|
["idm_function_assignment_changes.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_idm_function_assignment_change_events_change_id_idm_function_assignment_changes"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id", name=op.f("pk_idm_function_assignment_change_events")
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"change_id",
|
||||||
|
"sequence",
|
||||||
|
name="uq_idm_function_assignment_change_event_sequence",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_idm_function_assignment_change_events_tenant_id",
|
||||||
|
"idm_function_assignment_change_events",
|
||||||
|
["tenant_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_idm_function_assignment_change_events_change_id",
|
||||||
|
"idm_function_assignment_change_events",
|
||||||
|
["change_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_idm_function_assignment_change_events_action",
|
||||||
|
"idm_function_assignment_change_events",
|
||||||
|
["action"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_idm_function_assignment_change_events_actor_account_id",
|
||||||
|
"idm_function_assignment_change_events",
|
||||||
|
["actor_account_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_idm_function_assignment_change_events_tenant_change",
|
||||||
|
"idm_function_assignment_change_events",
|
||||||
|
["tenant_id", "change_id", "sequence"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("idm_function_assignment_change_events")
|
||||||
|
op.drop_table("idm_function_assignment_changes")
|
||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
"""Add typed IDM groups and effective-dated relationships.
|
||||||
|
|
||||||
|
Revision ID: b1c2d3e4f5a6
|
||||||
|
Revises: a0b1c2d3e4f5
|
||||||
|
Create Date: 2026-08-02 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "b1c2d3e4f5a6"
|
||||||
|
down_revision = "a0b1c2d3e4f5"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"idm_typed_groups",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("key", sa.String(length=120), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("group_type", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("source_provider", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("source_resource_type", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("source_resource_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("source_revision", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("properties", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_idm_typed_groups")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"group_type",
|
||||||
|
"key",
|
||||||
|
name="uq_idm_typed_groups_tenant_type_key",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_idm_typed_groups_tenant_id"),
|
||||||
|
"idm_typed_groups",
|
||||||
|
["tenant_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_idm_typed_groups_group_type"),
|
||||||
|
"idm_typed_groups",
|
||||||
|
["group_type"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_idm_typed_groups_tenant_status_name",
|
||||||
|
"idm_typed_groups",
|
||||||
|
["tenant_id", "status", "name"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"idm_identity_relationships",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("relationship_kind", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("subject_identity_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("target_group_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("related_identity_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("role", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("revoked_by", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("revocation_reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("expired_event_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("source_provider", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("source_resource_type", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("source_resource_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("source_revision", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("properties", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"((target_group_id IS NOT NULL AND related_identity_id IS NULL) OR "
|
||||||
|
"(target_group_id IS NULL AND related_identity_id IS NOT NULL))",
|
||||||
|
name="ck_idm_relationship_exactly_one_target",
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from",
|
||||||
|
name="ck_idm_relationship_valid_window",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["subject_identity_id"],
|
||||||
|
["identity_identities.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_idm_identity_relationships_subject_identity_id_identity_identities"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["related_identity_id"],
|
||||||
|
["identity_identities.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_idm_identity_relationships_related_identity_id_identity_identities"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["target_group_id"],
|
||||||
|
["idm_typed_groups.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_idm_identity_relationships_target_group_id_idm_typed_groups"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id", name=op.f("pk_idm_identity_relationships")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for name, columns in (
|
||||||
|
("ix_idm_identity_relationships_tenant_id", ["tenant_id"]),
|
||||||
|
("ix_idm_identity_relationships_relationship_kind", ["relationship_kind"]),
|
||||||
|
("ix_idm_identity_relationships_subject_identity_id", ["subject_identity_id"]),
|
||||||
|
("ix_idm_identity_relationships_target_group_id", ["target_group_id"]),
|
||||||
|
("ix_idm_identity_relationships_related_identity_id", ["related_identity_id"]),
|
||||||
|
(
|
||||||
|
"ix_idm_relationships_tenant_subject_effective",
|
||||||
|
["tenant_id", "subject_identity_id", "status", "valid_from", "valid_until"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_idm_relationships_tenant_group_effective",
|
||||||
|
["tenant_id", "target_group_id", "status", "valid_from", "valid_until"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_idm_relationships_expiry_due",
|
||||||
|
["status", "expired_event_at", "valid_until"],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
name,
|
||||||
|
"idm_identity_relationships",
|
||||||
|
columns,
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("idm_identity_relationships")
|
||||||
|
op.drop_table("idm_typed_groups")
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from govoplan_core.core.identity import IdentityDirectory
|
||||||
|
from govoplan_core.core.idm import (
|
||||||
|
IdentityRelationshipDecisionRef,
|
||||||
|
IdentityRelationshipRef,
|
||||||
|
IdmRelationshipDirectory,
|
||||||
|
TypedGroupMembershipResolutionRef,
|
||||||
|
TypedGroupRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||||
|
from govoplan_idm.backend.db.models import (
|
||||||
|
IdmIdentityRelationship,
|
||||||
|
IdmTypedGroup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def typed_group_ref(item: IdmTypedGroup) -> TypedGroupRef:
|
||||||
|
return TypedGroupRef(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
key=item.key,
|
||||||
|
name=item.name,
|
||||||
|
group_type=item.group_type,
|
||||||
|
description=item.description,
|
||||||
|
status=item.status, # type: ignore[arg-type]
|
||||||
|
source_provider=item.source_provider,
|
||||||
|
source_resource_type=item.source_resource_type,
|
||||||
|
source_resource_id=item.source_resource_id,
|
||||||
|
source_revision=item.source_revision,
|
||||||
|
properties=dict(item.properties),
|
||||||
|
provenance=dict(item.provenance),
|
||||||
|
revision=item.revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def identity_relationship_ref(
|
||||||
|
item: IdmIdentityRelationship,
|
||||||
|
) -> IdentityRelationshipRef:
|
||||||
|
return IdentityRelationshipRef(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
relationship_kind=item.relationship_kind,
|
||||||
|
subject_identity_id=item.subject_identity_id,
|
||||||
|
target_group_id=item.target_group_id,
|
||||||
|
related_identity_id=item.related_identity_id,
|
||||||
|
role=item.role,
|
||||||
|
valid_from=item.valid_from,
|
||||||
|
valid_until=item.valid_until,
|
||||||
|
status=item.status, # type: ignore[arg-type]
|
||||||
|
revoked_at=item.revoked_at,
|
||||||
|
revoked_by=item.revoked_by,
|
||||||
|
revocation_reason=item.revocation_reason,
|
||||||
|
source_provider=item.source_provider,
|
||||||
|
source_resource_type=item.source_resource_type,
|
||||||
|
source_resource_id=item.source_resource_id,
|
||||||
|
source_revision=item.source_revision,
|
||||||
|
properties=dict(item.properties),
|
||||||
|
provenance=dict(item.provenance),
|
||||||
|
revision=item.revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SqlIdmRelationshipDirectory(IdmRelationshipDirectory):
|
||||||
|
def __init__(self, *, identities: IdentityDirectory) -> None:
|
||||||
|
self._identities = identities
|
||||||
|
|
||||||
|
def get_typed_group(
|
||||||
|
self,
|
||||||
|
group_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
) -> TypedGroupRef | None:
|
||||||
|
with get_database().session() as session:
|
||||||
|
item = session.get(IdmTypedGroup, group_id)
|
||||||
|
if item is None:
|
||||||
|
return None
|
||||||
|
if tenant_id is not None and item.tenant_id != tenant_id:
|
||||||
|
raise ValueError("Typed group belongs to another tenant.")
|
||||||
|
return typed_group_ref(item)
|
||||||
|
|
||||||
|
def list_typed_groups(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
query: str | None = None,
|
||||||
|
group_types: Sequence[str] = (),
|
||||||
|
include_inactive: bool = False,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> tuple[TypedGroupRef, ...]:
|
||||||
|
if limit < 1 or limit > 1000:
|
||||||
|
raise ValueError("Typed-group limit must be between 1 and 1000.")
|
||||||
|
with get_database().session() as session:
|
||||||
|
statement = session.query(IdmTypedGroup).filter(
|
||||||
|
IdmTypedGroup.tenant_id == tenant_id
|
||||||
|
)
|
||||||
|
if not include_inactive:
|
||||||
|
statement = statement.filter(IdmTypedGroup.status == "active")
|
||||||
|
if group_types:
|
||||||
|
statement = statement.filter(
|
||||||
|
IdmTypedGroup.group_type.in_(tuple(dict.fromkeys(group_types)))
|
||||||
|
)
|
||||||
|
if query and query.strip():
|
||||||
|
pattern = f"%{query.strip()}%"
|
||||||
|
statement = statement.filter(
|
||||||
|
or_(
|
||||||
|
IdmTypedGroup.name.ilike(pattern),
|
||||||
|
IdmTypedGroup.key.ilike(pattern),
|
||||||
|
IdmTypedGroup.description.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
items = (
|
||||||
|
statement.order_by(
|
||||||
|
IdmTypedGroup.name.asc(),
|
||||||
|
IdmTypedGroup.id.asc(),
|
||||||
|
)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return tuple(typed_group_ref(item) for item in items)
|
||||||
|
|
||||||
|
def identity_relationships_for_identity(
|
||||||
|
self,
|
||||||
|
identity_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
relationship_kinds: Sequence[str] = (),
|
||||||
|
) -> tuple[IdentityRelationshipRef, ...]:
|
||||||
|
return tuple(
|
||||||
|
self.identity_relationships_for_identities(
|
||||||
|
(identity_id,),
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=effective_at,
|
||||||
|
relationship_kinds=relationship_kinds,
|
||||||
|
).get(identity_id, ())
|
||||||
|
)
|
||||||
|
|
||||||
|
def identity_relationships_for_identities(
|
||||||
|
self,
|
||||||
|
identity_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
relationship_kinds: Sequence[str] = (),
|
||||||
|
) -> dict[str, tuple[IdentityRelationshipRef, ...]]:
|
||||||
|
requested = tuple(dict.fromkeys(identity_ids))
|
||||||
|
result: dict[str, list[IdentityRelationshipRef]] = {
|
||||||
|
identity_id: [] for identity_id in requested
|
||||||
|
}
|
||||||
|
if not requested:
|
||||||
|
return {}
|
||||||
|
with get_database().session() as session:
|
||||||
|
items = self._effective_relationships(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=effective_at or utc_now(),
|
||||||
|
identity_ids=requested,
|
||||||
|
relationship_kinds=relationship_kinds,
|
||||||
|
)
|
||||||
|
for item in items:
|
||||||
|
result[item.subject_identity_id].append(
|
||||||
|
identity_relationship_ref(item)
|
||||||
|
)
|
||||||
|
return {key: tuple(value) for key, value in result.items()}
|
||||||
|
|
||||||
|
def identity_relationships_for_group(
|
||||||
|
self,
|
||||||
|
group_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
relationship_kinds: Sequence[str] = (),
|
||||||
|
) -> tuple[IdentityRelationshipRef, ...]:
|
||||||
|
return tuple(
|
||||||
|
self.identity_relationships_for_groups(
|
||||||
|
(group_id,),
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=effective_at,
|
||||||
|
relationship_kinds=relationship_kinds,
|
||||||
|
).get(group_id, ())
|
||||||
|
)
|
||||||
|
|
||||||
|
def identity_relationships_for_groups(
|
||||||
|
self,
|
||||||
|
group_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
relationship_kinds: Sequence[str] = (),
|
||||||
|
) -> dict[str, tuple[IdentityRelationshipRef, ...]]:
|
||||||
|
requested = tuple(dict.fromkeys(group_ids))
|
||||||
|
result: dict[str, list[IdentityRelationshipRef]] = {
|
||||||
|
group_id: [] for group_id in requested
|
||||||
|
}
|
||||||
|
if not requested:
|
||||||
|
return {}
|
||||||
|
with get_database().session() as session:
|
||||||
|
self._validate_group_tenants(session, requested, tenant_id)
|
||||||
|
items = self._effective_relationships(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=effective_at or utc_now(),
|
||||||
|
group_ids=requested,
|
||||||
|
relationship_kinds=relationship_kinds,
|
||||||
|
)
|
||||||
|
for item in items:
|
||||||
|
if item.target_group_id is not None:
|
||||||
|
result[item.target_group_id].append(
|
||||||
|
identity_relationship_ref(item)
|
||||||
|
)
|
||||||
|
return {key: tuple(value) for key, value in result.items()}
|
||||||
|
|
||||||
|
def resolve_typed_group_memberships(
|
||||||
|
self,
|
||||||
|
group_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
relationship_kinds: Sequence[str] = ("member",),
|
||||||
|
) -> dict[str, TypedGroupMembershipResolutionRef]:
|
||||||
|
requested = tuple(dict.fromkeys(group_ids))
|
||||||
|
if not requested:
|
||||||
|
return {}
|
||||||
|
moment = ensure_aware_utc(effective_at) or utc_now()
|
||||||
|
with get_database().session() as session:
|
||||||
|
groups = self._validate_group_tenants(session, requested, tenant_id)
|
||||||
|
items = (
|
||||||
|
session.query(IdmIdentityRelationship)
|
||||||
|
.filter(
|
||||||
|
IdmIdentityRelationship.tenant_id == tenant_id,
|
||||||
|
IdmIdentityRelationship.target_group_id.in_(requested),
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
IdmIdentityRelationship.created_at.asc(),
|
||||||
|
IdmIdentityRelationship.id.asc(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if relationship_kinds:
|
||||||
|
items = items.filter(
|
||||||
|
IdmIdentityRelationship.relationship_kind.in_(
|
||||||
|
tuple(dict.fromkeys(relationship_kinds))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = items.all()
|
||||||
|
|
||||||
|
identities = {
|
||||||
|
identity_id: self._identities.get_identity(identity_id)
|
||||||
|
for identity_id in dict.fromkeys(
|
||||||
|
item.subject_identity_id for item in rows
|
||||||
|
)
|
||||||
|
}
|
||||||
|
decisions: dict[str, list[IdentityRelationshipDecisionRef]] = {
|
||||||
|
group_id: [] for group_id in requested
|
||||||
|
}
|
||||||
|
for item in rows:
|
||||||
|
group = groups[item.target_group_id or ""]
|
||||||
|
identity = identities[item.subject_identity_id]
|
||||||
|
included, code, explanation = _membership_decision(
|
||||||
|
item,
|
||||||
|
group=group,
|
||||||
|
identity_status=identity.status if identity is not None else None,
|
||||||
|
effective_at=moment,
|
||||||
|
)
|
||||||
|
decisions[group.id].append(
|
||||||
|
IdentityRelationshipDecisionRef(
|
||||||
|
relationship=identity_relationship_ref(item),
|
||||||
|
included=included,
|
||||||
|
code=code,
|
||||||
|
explanation=explanation,
|
||||||
|
identity_status=(
|
||||||
|
identity.status if identity is not None else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
group_id: TypedGroupMembershipResolutionRef(
|
||||||
|
group=typed_group_ref(groups[group_id]),
|
||||||
|
effective_at=moment,
|
||||||
|
decisions=tuple(decisions[group_id]),
|
||||||
|
)
|
||||||
|
for group_id in requested
|
||||||
|
if group_id in groups
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_group_tenants(session, group_ids, tenant_id):
|
||||||
|
groups: dict[str, IdmTypedGroup] = {}
|
||||||
|
for group_id in group_ids:
|
||||||
|
item = session.get(IdmTypedGroup, group_id)
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
if item.tenant_id != tenant_id:
|
||||||
|
raise ValueError("Typed group belongs to another tenant.")
|
||||||
|
groups[item.id] = item
|
||||||
|
return groups
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _effective_relationships(
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime,
|
||||||
|
identity_ids: Sequence[str] = (),
|
||||||
|
group_ids: Sequence[str] = (),
|
||||||
|
relationship_kinds: Sequence[str] = (),
|
||||||
|
) -> tuple[IdmIdentityRelationship, ...]:
|
||||||
|
query = session.query(IdmIdentityRelationship).filter(
|
||||||
|
IdmIdentityRelationship.tenant_id == tenant_id,
|
||||||
|
IdmIdentityRelationship.status == "active",
|
||||||
|
or_(
|
||||||
|
IdmIdentityRelationship.valid_from.is_(None),
|
||||||
|
IdmIdentityRelationship.valid_from <= effective_at,
|
||||||
|
),
|
||||||
|
or_(
|
||||||
|
IdmIdentityRelationship.valid_until.is_(None),
|
||||||
|
IdmIdentityRelationship.valid_until > effective_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if identity_ids:
|
||||||
|
query = query.filter(
|
||||||
|
IdmIdentityRelationship.subject_identity_id.in_(identity_ids)
|
||||||
|
)
|
||||||
|
if group_ids:
|
||||||
|
query = query.filter(
|
||||||
|
IdmIdentityRelationship.target_group_id.in_(group_ids)
|
||||||
|
)
|
||||||
|
if relationship_kinds:
|
||||||
|
query = query.filter(
|
||||||
|
IdmIdentityRelationship.relationship_kind.in_(
|
||||||
|
tuple(dict.fromkeys(relationship_kinds))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
query.order_by(
|
||||||
|
IdmIdentityRelationship.created_at.asc(),
|
||||||
|
IdmIdentityRelationship.id.asc(),
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _membership_decision(
|
||||||
|
item: IdmIdentityRelationship,
|
||||||
|
*,
|
||||||
|
group: IdmTypedGroup,
|
||||||
|
identity_status: str | None,
|
||||||
|
effective_at: datetime,
|
||||||
|
) -> tuple[bool, str, str]:
|
||||||
|
if group.status != "active":
|
||||||
|
return False, "group.inactive", "The typed group is inactive."
|
||||||
|
if item.status == "revoked":
|
||||||
|
return False, "relationship.revoked", "The relationship was revoked."
|
||||||
|
if item.status != "active":
|
||||||
|
return False, "relationship.inactive", "The relationship is not active."
|
||||||
|
valid_from = ensure_aware_utc(item.valid_from)
|
||||||
|
if valid_from is not None and valid_from > effective_at:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
"relationship.not_yet_effective",
|
||||||
|
"The relationship is not effective yet.",
|
||||||
|
)
|
||||||
|
valid_until = ensure_aware_utc(item.valid_until)
|
||||||
|
if valid_until is not None and valid_until <= effective_at:
|
||||||
|
return False, "relationship.expired", "The relationship has expired."
|
||||||
|
if identity_status is None:
|
||||||
|
return False, "identity.missing", "The related identity no longer exists."
|
||||||
|
if identity_status != "active":
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
"identity.not_active",
|
||||||
|
f"The related identity lifecycle status is {identity_status}.",
|
||||||
|
)
|
||||||
|
return True, "relationship.effective", "The relationship is effective."
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SqlIdmRelationshipDirectory",
|
||||||
|
"identity_relationship_ref",
|
||||||
|
"typed_group_ref",
|
||||||
|
]
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.events import PlatformEvent
|
||||||
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
from govoplan_core.core.search import (
|
||||||
|
SearchAuthorizationRequest,
|
||||||
|
SearchBackfillPage,
|
||||||
|
SearchBackfillRequest,
|
||||||
|
SearchDocument,
|
||||||
|
SearchIndexChange,
|
||||||
|
SearchResourceReference,
|
||||||
|
SearchResourceType,
|
||||||
|
)
|
||||||
|
from govoplan_idm.backend.db.models import (
|
||||||
|
IdmIdentityRelationship,
|
||||||
|
IdmOrganizationFunctionAssignment,
|
||||||
|
IdmTypedGroup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDER_ID = "idm.directory"
|
||||||
|
RESOURCE_MODELS = {
|
||||||
|
"organization_function_assignment": IdmOrganizationFunctionAssignment,
|
||||||
|
"typed_group": IdmTypedGroup,
|
||||||
|
"identity_relationship": IdmIdentityRelationship,
|
||||||
|
}
|
||||||
|
RESOURCE_SCOPES = {
|
||||||
|
"organization_function_assignment": (
|
||||||
|
"idm:organization_assignment:read",
|
||||||
|
"idm:organization_assignment:write",
|
||||||
|
),
|
||||||
|
"typed_group": ("idm:relationship:read", "idm:relationship:write"),
|
||||||
|
"identity_relationship": (
|
||||||
|
"idm:relationship:read",
|
||||||
|
"idm:relationship:write",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class IdmSearchSource:
|
||||||
|
def resource_types(self) -> Sequence[SearchResourceType]:
|
||||||
|
return (
|
||||||
|
SearchResourceType(
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
module_id="idm",
|
||||||
|
resource_type="organization_function_assignment",
|
||||||
|
label="Function assignments",
|
||||||
|
requires_authorization_recheck=True,
|
||||||
|
),
|
||||||
|
SearchResourceType(
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
module_id="idm",
|
||||||
|
resource_type="typed_group",
|
||||||
|
label="Typed groups",
|
||||||
|
requires_authorization_recheck=True,
|
||||||
|
),
|
||||||
|
SearchResourceType(
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
module_id="idm",
|
||||||
|
resource_type="identity_relationship",
|
||||||
|
label="Identity relationships",
|
||||||
|
requires_authorization_recheck=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def backfill(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: SearchBackfillRequest,
|
||||||
|
) -> SearchBackfillPage:
|
||||||
|
model = _model(request.provider_id, request.resource_type)
|
||||||
|
db = _session(session)
|
||||||
|
statement = select(model).where(model.tenant_id == request.tenant_id)
|
||||||
|
if request.cursor:
|
||||||
|
statement = statement.where(model.id > request.cursor)
|
||||||
|
rows = list(
|
||||||
|
db.scalars(
|
||||||
|
statement.order_by(model.id).limit(request.limit + 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
has_more = len(rows) > request.limit
|
||||||
|
selected = rows[: request.limit]
|
||||||
|
high_watermark = db.scalar(
|
||||||
|
select(func.max(model.updated_at)).where(
|
||||||
|
model.tenant_id == request.tenant_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return SearchBackfillPage(
|
||||||
|
documents=tuple(
|
||||||
|
_document(row, resource_type=request.resource_type)
|
||||||
|
for row in selected
|
||||||
|
),
|
||||||
|
next_cursor=selected[-1].id if has_more and selected else None,
|
||||||
|
complete=not has_more,
|
||||||
|
high_watermark=(
|
||||||
|
high_watermark.isoformat()
|
||||||
|
if high_watermark is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
requests: Sequence[SearchAuthorizationRequest],
|
||||||
|
) -> Mapping[str, bool]:
|
||||||
|
decisions = {item.reference.key: False for item in requests}
|
||||||
|
if not isinstance(principal, ApiPrincipal):
|
||||||
|
return decisions
|
||||||
|
db = _session(session)
|
||||||
|
by_type: dict[str, list[SearchAuthorizationRequest]] = {}
|
||||||
|
for item in requests:
|
||||||
|
reference = item.reference
|
||||||
|
if (
|
||||||
|
reference.tenant_id != principal.tenant_id
|
||||||
|
or reference.module_id != "idm"
|
||||||
|
or reference.resource_type not in RESOURCE_MODELS
|
||||||
|
or not any(
|
||||||
|
principal.has(scope)
|
||||||
|
for scope in RESOURCE_SCOPES[reference.resource_type]
|
||||||
|
)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
by_type.setdefault(reference.resource_type, []).append(item)
|
||||||
|
for resource_type, items in by_type.items():
|
||||||
|
model = RESOURCE_MODELS[resource_type]
|
||||||
|
ids = {item.reference.resource_id for item in items}
|
||||||
|
existing = set(
|
||||||
|
db.scalars(
|
||||||
|
select(model.id).where(
|
||||||
|
model.id.in_(ids),
|
||||||
|
model.tenant_id == principal.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for item in items:
|
||||||
|
decisions[item.reference.key] = (
|
||||||
|
item.reference.resource_id in existing
|
||||||
|
)
|
||||||
|
return decisions
|
||||||
|
|
||||||
|
def index_changes_for_event(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
event: PlatformEvent,
|
||||||
|
delivery_key: str,
|
||||||
|
) -> Sequence[SearchIndexChange]:
|
||||||
|
if (
|
||||||
|
event.module_id != "idm"
|
||||||
|
or event.tenant is None
|
||||||
|
or event.resource is None
|
||||||
|
or event.resource.type not in RESOURCE_MODELS
|
||||||
|
or event.resource.id is None
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
db = _session(session)
|
||||||
|
resource_type = event.resource.type
|
||||||
|
model = RESOURCE_MODELS[resource_type]
|
||||||
|
row = db.get(model, event.resource.id)
|
||||||
|
deleted = row is None or row.tenant_id != event.tenant.id
|
||||||
|
cursor = event.event_id
|
||||||
|
document = (
|
||||||
|
None
|
||||||
|
if deleted
|
||||||
|
else _document(
|
||||||
|
row,
|
||||||
|
resource_type=resource_type,
|
||||||
|
change_cursor=cursor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reference = SearchResourceReference(
|
||||||
|
tenant_id=event.tenant.id,
|
||||||
|
module_id="idm",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=event.resource.id,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
SearchIndexChange(
|
||||||
|
change_id=f"{delivery_key}:{PROVIDER_ID}:{resource_type}",
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
kind="delete" if deleted else "upsert",
|
||||||
|
reference=reference,
|
||||||
|
source_revision=(
|
||||||
|
document.source_revision if document is not None else cursor
|
||||||
|
),
|
||||||
|
cursor=cursor,
|
||||||
|
document=document,
|
||||||
|
occurred_at=event.occurred_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_idm_search_source(_context: ModuleContext) -> IdmSearchSource:
|
||||||
|
return IdmSearchSource()
|
||||||
|
|
||||||
|
|
||||||
|
def _document(
|
||||||
|
row: object,
|
||||||
|
*,
|
||||||
|
resource_type: str,
|
||||||
|
change_cursor: str | None = None,
|
||||||
|
) -> SearchDocument:
|
||||||
|
scopes = RESOURCE_SCOPES[resource_type]
|
||||||
|
updated_at = getattr(row, "updated_at") or getattr(row, "created_at")
|
||||||
|
title, summary, body, keywords, metadata, url = _resource_content(
|
||||||
|
row,
|
||||||
|
resource_type=resource_type,
|
||||||
|
)
|
||||||
|
revision = getattr(row, "revision", None)
|
||||||
|
return SearchDocument(
|
||||||
|
tenant_id=str(getattr(row, "tenant_id")),
|
||||||
|
module_id="idm",
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=str(getattr(row, "id")),
|
||||||
|
title=title[:500],
|
||||||
|
url=url,
|
||||||
|
summary=summary[:4000] if summary else None,
|
||||||
|
body=body[:200_000] if body else None,
|
||||||
|
keywords=tuple(item[:200] for item in keywords[:100]),
|
||||||
|
visibility="restricted",
|
||||||
|
acl_tokens=tuple(f"scope:{scope}" for scope in scopes),
|
||||||
|
metadata=metadata,
|
||||||
|
source_revision=f"{revision or 1}:{updated_at.isoformat()}",
|
||||||
|
change_cursor=change_cursor,
|
||||||
|
source_updated_at=updated_at,
|
||||||
|
requires_authorization_recheck=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resource_content(
|
||||||
|
row: object,
|
||||||
|
*,
|
||||||
|
resource_type: str,
|
||||||
|
) -> tuple[str, str, str, tuple[str, ...], dict[str, object], str]:
|
||||||
|
item_id = str(getattr(row, "id"))
|
||||||
|
if resource_type == "typed_group":
|
||||||
|
group = row
|
||||||
|
return (
|
||||||
|
str(getattr(group, "name")),
|
||||||
|
str(getattr(group, "description") or ""),
|
||||||
|
" ".join(
|
||||||
|
str(value)
|
||||||
|
for value in (
|
||||||
|
getattr(group, "key"),
|
||||||
|
getattr(group, "description"),
|
||||||
|
getattr(group, "source_resource_id"),
|
||||||
|
)
|
||||||
|
if value
|
||||||
|
),
|
||||||
|
(
|
||||||
|
str(getattr(group, "group_type")),
|
||||||
|
str(getattr(group, "status")),
|
||||||
|
str(getattr(group, "source_provider")),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"key": getattr(group, "key"),
|
||||||
|
"group_type": getattr(group, "group_type"),
|
||||||
|
"status": getattr(group, "status"),
|
||||||
|
"source_provider": getattr(group, "source_provider"),
|
||||||
|
},
|
||||||
|
f"/idm?groupId={quote(item_id, safe='')}",
|
||||||
|
)
|
||||||
|
if resource_type == "identity_relationship":
|
||||||
|
relationship = row
|
||||||
|
kind = str(getattr(relationship, "relationship_kind"))
|
||||||
|
return (
|
||||||
|
f"Relationship: {kind}",
|
||||||
|
str(getattr(relationship, "role") or ""),
|
||||||
|
" ".join(
|
||||||
|
str(value)
|
||||||
|
for value in (
|
||||||
|
getattr(relationship, "subject_identity_id"),
|
||||||
|
getattr(relationship, "target_group_id"),
|
||||||
|
getattr(relationship, "related_identity_id"),
|
||||||
|
getattr(relationship, "role"),
|
||||||
|
)
|
||||||
|
if value
|
||||||
|
),
|
||||||
|
(kind, str(getattr(relationship, "status"))),
|
||||||
|
{
|
||||||
|
"relationship_kind": kind,
|
||||||
|
"status": getattr(relationship, "status"),
|
||||||
|
"subject_identity_id": getattr(
|
||||||
|
relationship, "subject_identity_id"
|
||||||
|
),
|
||||||
|
"target_group_id": getattr(relationship, "target_group_id"),
|
||||||
|
"related_identity_id": getattr(
|
||||||
|
relationship, "related_identity_id"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
f"/idm?relationshipId={quote(item_id, safe='')}",
|
||||||
|
)
|
||||||
|
assignment = row
|
||||||
|
return (
|
||||||
|
"Organization function assignment",
|
||||||
|
" ".join(
|
||||||
|
(
|
||||||
|
str(getattr(assignment, "function_id")),
|
||||||
|
str(getattr(assignment, "organization_unit_id")),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
" ".join(
|
||||||
|
str(value)
|
||||||
|
for value in (
|
||||||
|
getattr(assignment, "identity_id"),
|
||||||
|
getattr(assignment, "account_id"),
|
||||||
|
getattr(assignment, "function_id"),
|
||||||
|
getattr(assignment, "organization_unit_id"),
|
||||||
|
)
|
||||||
|
if value
|
||||||
|
),
|
||||||
|
(
|
||||||
|
str(getattr(assignment, "source")),
|
||||||
|
"active" if getattr(assignment, "is_active") else "inactive",
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"identity_id": getattr(assignment, "identity_id"),
|
||||||
|
"function_id": getattr(assignment, "function_id"),
|
||||||
|
"organization_unit_id": getattr(
|
||||||
|
assignment, "organization_unit_id"
|
||||||
|
),
|
||||||
|
"is_active": getattr(assignment, "is_active"),
|
||||||
|
"valid_from": (
|
||||||
|
getattr(assignment, "valid_from").isoformat()
|
||||||
|
if getattr(assignment, "valid_from")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"valid_until": (
|
||||||
|
getattr(assignment, "valid_until").isoformat()
|
||||||
|
if getattr(assignment, "valid_until")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
f"/idm?assignmentId={quote(item_id, safe='')}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _model(provider_id: str, resource_type: str):
|
||||||
|
if provider_id != PROVIDER_ID or resource_type not in RESOURCE_MODELS:
|
||||||
|
raise ValueError("Unsupported IDM search source.")
|
||||||
|
return RESOURCE_MODELS[resource_type]
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("IDM search requires a SQLAlchemy session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"IdmSearchSource",
|
||||||
|
"PROVIDER_ID",
|
||||||
|
"RESOURCE_MODELS",
|
||||||
|
"create_idm_search_source",
|
||||||
|
]
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.workflows import WorkflowDefinitionContribution
|
||||||
|
|
||||||
|
|
||||||
|
def function_assignment_workflow_definitions(
|
||||||
|
*,
|
||||||
|
module_version: str,
|
||||||
|
) -> tuple[WorkflowDefinitionContribution, ...]:
|
||||||
|
return (
|
||||||
|
_contribution(
|
||||||
|
module_version=module_version,
|
||||||
|
definition_key="function-assignment-request",
|
||||||
|
name="Request an organization function",
|
||||||
|
description=(
|
||||||
|
"Governed holder, authority, and optional recipient decisions "
|
||||||
|
"for a self-requested organization function assignment."
|
||||||
|
),
|
||||||
|
kind="request",
|
||||||
|
),
|
||||||
|
_contribution(
|
||||||
|
module_version=module_version,
|
||||||
|
definition_key="function-assignment-grant",
|
||||||
|
name="Bestow an organization function",
|
||||||
|
description=(
|
||||||
|
"Governed holder, authority, and recipient decisions for an "
|
||||||
|
"organization function grant."
|
||||||
|
),
|
||||||
|
kind="grant",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _contribution(
|
||||||
|
*,
|
||||||
|
module_version: str,
|
||||||
|
definition_key: str,
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
kind: str,
|
||||||
|
) -> WorkflowDefinitionContribution:
|
||||||
|
return WorkflowDefinitionContribution(
|
||||||
|
origin_module_id="idm",
|
||||||
|
origin_module_version=module_version,
|
||||||
|
definition_key=definition_key,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
graph=_graph(kind=kind),
|
||||||
|
scope_type="system",
|
||||||
|
inherit_to_lower_scopes=True,
|
||||||
|
allow_start=True,
|
||||||
|
allow_reuse=False,
|
||||||
|
allow_automation=False,
|
||||||
|
execution_mode="guided",
|
||||||
|
activate_on_install=True,
|
||||||
|
metadata={
|
||||||
|
"domain": "idm.function_assignment_change",
|
||||||
|
"change_kind": kind,
|
||||||
|
"state_owner": "idm",
|
||||||
|
},
|
||||||
|
policy_metadata={
|
||||||
|
"governance_capability": ("policy.functionAssignmentGovernance"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _graph(*, kind: str) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "start",
|
||||||
|
"type": "workflow.start.manual",
|
||||||
|
"label": "Submitted",
|
||||||
|
"position": {"x": 20, "y": 120},
|
||||||
|
"config": {
|
||||||
|
"input_schema_ref": (f"govoplan/idm/function-assignment-{kind}.v1"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "holder_review",
|
||||||
|
"type": "workflow.review",
|
||||||
|
"label": "Holder review",
|
||||||
|
"position": {"x": 230, "y": 120},
|
||||||
|
"config": {
|
||||||
|
"title": "Holder review",
|
||||||
|
"reviewer": "effective-holder",
|
||||||
|
"required_evidence": [],
|
||||||
|
"view_surface_ids": [
|
||||||
|
"idm.action.view-function-assignments",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "authority_review",
|
||||||
|
"type": "workflow.review",
|
||||||
|
"label": "Authority clearance",
|
||||||
|
"position": {"x": 470, "y": 120},
|
||||||
|
"config": {
|
||||||
|
"title": "Authority clearance",
|
||||||
|
"reviewer": "designated-authority",
|
||||||
|
"required_evidence": [],
|
||||||
|
"view_surface_ids": [
|
||||||
|
"idm.action.view-function-assignments",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "recipient_review",
|
||||||
|
"type": "workflow.review",
|
||||||
|
"label": "Recipient acceptance",
|
||||||
|
"position": {"x": 730, "y": 120},
|
||||||
|
"config": {
|
||||||
|
"title": "Recipient acceptance",
|
||||||
|
"reviewer": "candidate",
|
||||||
|
"required_evidence": [],
|
||||||
|
"view_surface_ids": [
|
||||||
|
"idm.action.view-function-assignments",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "completed",
|
||||||
|
"type": "workflow.end.completed",
|
||||||
|
"label": "Approved",
|
||||||
|
"position": {"x": 990, "y": 70},
|
||||||
|
"config": {"output_mapping": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rejected",
|
||||||
|
"type": "workflow.end.cancelled",
|
||||||
|
"label": "Rejected",
|
||||||
|
"position": {"x": 990, "y": 230},
|
||||||
|
"config": {"reason": "Function assignment change rejected"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "start-holder",
|
||||||
|
"source": "start",
|
||||||
|
"target": "holder_review",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "holder-authority",
|
||||||
|
"source": "holder_review",
|
||||||
|
"source_port": "approved",
|
||||||
|
"target": "authority_review",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "holder-rejected",
|
||||||
|
"source": "holder_review",
|
||||||
|
"source_port": "rejected",
|
||||||
|
"target": "rejected",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "authority-recipient",
|
||||||
|
"source": "authority_review",
|
||||||
|
"source_port": "approved",
|
||||||
|
"target": "recipient_review",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "authority-rejected",
|
||||||
|
"source": "authority_review",
|
||||||
|
"source_port": "rejected",
|
||||||
|
"target": "rejected",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "recipient-completed",
|
||||||
|
"source": "recipient_review",
|
||||||
|
"source_port": "approved",
|
||||||
|
"target": "completed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "recipient-rejected",
|
||||||
|
"source": "recipient_review",
|
||||||
|
"source_port": "rejected",
|
||||||
|
"target": "rejected",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"notation": "govoplan.workflow.native",
|
||||||
|
"domain": "idm.function_assignment_change",
|
||||||
|
"change_kind": kind,
|
||||||
|
"optional_steps": [
|
||||||
|
"holder_review",
|
||||||
|
"authority_review",
|
||||||
|
"recipient_review",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["function_assignment_workflow_definitions"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.core.events import EventBus, PlatformEvent, event_bus_context
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.db.session import configure_database, reset_database
|
||||||
|
from govoplan_identity.backend.db import models as identity_models # noqa: F401
|
||||||
|
from govoplan_idm.backend.assignment_lifecycle import SqlIdmAssignmentLifecycle
|
||||||
|
from govoplan_idm.backend.db.models import (
|
||||||
|
IdmFunctionAssignmentChange,
|
||||||
|
IdmFunctionAssignmentChangeEvent,
|
||||||
|
IdmIdentityRelationship,
|
||||||
|
IdmOrganizationFunctionAssignment,
|
||||||
|
IdmTypedGroup,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.db import models as organization_models # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
class AssignmentExpiryTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.database = configure_database("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.database.engine,
|
||||||
|
tables=[
|
||||||
|
IdmOrganizationFunctionAssignment.__table__,
|
||||||
|
IdmFunctionAssignmentChange.__table__,
|
||||||
|
IdmFunctionAssignmentChangeEvent.__table__,
|
||||||
|
IdmTypedGroup.__table__,
|
||||||
|
IdmIdentityRelationship.__table__,
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.lifecycle = SqlIdmAssignmentLifecycle()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
reset_database(dispose=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assignment(
|
||||||
|
assignment_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str = "tenant-1",
|
||||||
|
valid_until: datetime,
|
||||||
|
active: bool = True,
|
||||||
|
expired_event_at: datetime | None = None,
|
||||||
|
) -> IdmOrganizationFunctionAssignment:
|
||||||
|
return IdmOrganizationFunctionAssignment(
|
||||||
|
id=assignment_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
identity_id=f"identity-{assignment_id}",
|
||||||
|
function_id="function-1",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
source="direct",
|
||||||
|
valid_until=valid_until,
|
||||||
|
expired_event_at=expired_event_at,
|
||||||
|
is_active=active,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sweep_claims_due_assignments_once_and_preserves_provenance(self) -> None:
|
||||||
|
boundary = datetime(2026, 7, 31, 12, tzinfo=timezone.utc)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add_all(
|
||||||
|
(
|
||||||
|
self._assignment(
|
||||||
|
"due",
|
||||||
|
valid_until=boundary - timedelta(seconds=1),
|
||||||
|
),
|
||||||
|
self._assignment(
|
||||||
|
"future",
|
||||||
|
valid_until=boundary + timedelta(seconds=1),
|
||||||
|
),
|
||||||
|
self._assignment(
|
||||||
|
"revoked",
|
||||||
|
valid_until=boundary - timedelta(seconds=1),
|
||||||
|
active=False,
|
||||||
|
),
|
||||||
|
self._assignment(
|
||||||
|
"already-emitted",
|
||||||
|
valid_until=boundary - timedelta(seconds=1),
|
||||||
|
expired_event_at=boundary - timedelta(minutes=1),
|
||||||
|
),
|
||||||
|
self._assignment(
|
||||||
|
"other-tenant",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
valid_until=boundary - timedelta(seconds=1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
events: list[PlatformEvent] = []
|
||||||
|
bus = EventBus()
|
||||||
|
bus.subscribe("idm.function_assignment.expired.v1", events.append)
|
||||||
|
with self.database.SessionLocal() as session, event_bus_context(bus):
|
||||||
|
result = self.lifecycle.process_expired(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
self.assertEqual(1, result["selected"])
|
||||||
|
self.assertEqual(1, result["expired"])
|
||||||
|
self.assertEqual(["due"], result["assignment_ids"])
|
||||||
|
self.assertEqual(1, len(events))
|
||||||
|
self.assertEqual("system", events[0].actor.type)
|
||||||
|
self.assertEqual("tenant-1", events[0].tenant.id)
|
||||||
|
self.assertEqual("identity-due", events[0].payload["identity_id"])
|
||||||
|
self.assertEqual("function-1", events[0].payload["function_id"])
|
||||||
|
|
||||||
|
with self.database.session() as session:
|
||||||
|
due = session.get(IdmOrganizationFunctionAssignment, "due")
|
||||||
|
self.assertEqual(boundary, due.expired_event_at.replace(tzinfo=timezone.utc))
|
||||||
|
repeated = self.lifecycle.process_expired(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
self.assertEqual(0, repeated["expired"])
|
||||||
|
self.assertEqual(1, len(events))
|
||||||
|
|
||||||
|
def test_sweep_rollback_releases_marker_and_event(self) -> None:
|
||||||
|
boundary = datetime(2026, 7, 31, 12, tzinfo=timezone.utc)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add(
|
||||||
|
self._assignment(
|
||||||
|
"rolled-back",
|
||||||
|
valid_until=boundary - timedelta(seconds=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
events: list[PlatformEvent] = []
|
||||||
|
bus = EventBus()
|
||||||
|
bus.subscribe("idm.function_assignment.expired.v1", events.append)
|
||||||
|
with self.database.SessionLocal() as session, event_bus_context(bus):
|
||||||
|
self.lifecycle.process_expired(session, effective_at=boundary)
|
||||||
|
session.rollback()
|
||||||
|
self.assertEqual([], events)
|
||||||
|
|
||||||
|
with self.database.session() as session:
|
||||||
|
item = session.get(IdmOrganizationFunctionAssignment, "rolled-back")
|
||||||
|
self.assertIsNone(item.expired_event_at)
|
||||||
|
|
||||||
|
def test_limit_validation_is_bounded(self) -> None:
|
||||||
|
with self.database.session() as session:
|
||||||
|
for value in (0, 1001):
|
||||||
|
with self.subTest(limit=value), self.assertRaises(ValueError):
|
||||||
|
self.lifecycle.process_expired(session, limit=value)
|
||||||
|
|
||||||
|
def test_sweep_expires_open_governed_changes_once(self) -> None:
|
||||||
|
boundary = datetime(2026, 7, 31, 12, tzinfo=timezone.utc)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add(
|
||||||
|
IdmFunctionAssignmentChange(
|
||||||
|
id="change-due",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
kind="request",
|
||||||
|
state="awaiting_holder",
|
||||||
|
profile="holder_grant",
|
||||||
|
function_id="function-1",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
candidate_identity_id="identity-1",
|
||||||
|
initiator_account_id="account-1",
|
||||||
|
justification="Need the function",
|
||||||
|
evidence=[],
|
||||||
|
assignment_source="governance",
|
||||||
|
required_steps=["holder"],
|
||||||
|
completed_steps=[],
|
||||||
|
policy_decision={},
|
||||||
|
idempotency_key="request-1",
|
||||||
|
expires_at=boundary - timedelta(seconds=1),
|
||||||
|
metadata_={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
events: list[PlatformEvent] = []
|
||||||
|
bus = EventBus()
|
||||||
|
bus.subscribe("idm.function_change.expired.v1", events.append)
|
||||||
|
with self.database.SessionLocal() as session, event_bus_context(bus):
|
||||||
|
result = self.lifecycle.process_expired(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
repeated = self.lifecycle.process_expired(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
self.assertEqual(1, result["expired_changes"])
|
||||||
|
self.assertEqual(["change-due"], result["change_ids"])
|
||||||
|
self.assertEqual(0, repeated["expired_changes"])
|
||||||
|
self.assertEqual(1, len(events))
|
||||||
|
with self.database.session() as session:
|
||||||
|
change = session.get(IdmFunctionAssignmentChange, "change-due")
|
||||||
|
self.assertEqual("expired", change.state)
|
||||||
|
self.assertEqual(2, change.resource_revision)
|
||||||
|
history = session.query(IdmFunctionAssignmentChangeEvent).all()
|
||||||
|
self.assertEqual(["expired"], [item.action for item in history])
|
||||||
|
|
||||||
|
def test_sweep_emits_relationship_expiry_once(self) -> None:
|
||||||
|
boundary = datetime(2026, 8, 2, 12, tzinfo=timezone.utc)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add(
|
||||||
|
IdmTypedGroup(
|
||||||
|
id="group-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
key="eligible",
|
||||||
|
name="Eligible",
|
||||||
|
group_type="business_status",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
IdmIdentityRelationship(
|
||||||
|
id="relationship-due",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
relationship_kind="member",
|
||||||
|
subject_identity_id="identity-1",
|
||||||
|
target_group_id="group-1",
|
||||||
|
valid_until=boundary - timedelta(seconds=1),
|
||||||
|
status="active",
|
||||||
|
properties={},
|
||||||
|
provenance={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
events: list[PlatformEvent] = []
|
||||||
|
bus = EventBus()
|
||||||
|
bus.subscribe("idm.relationship.expired.v1", events.append)
|
||||||
|
with self.database.SessionLocal() as session, event_bus_context(bus):
|
||||||
|
result = self.lifecycle.process_expired(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
repeated = self.lifecycle.process_expired(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
self.assertEqual(["relationship-due"], result["relationship_ids"])
|
||||||
|
self.assertEqual(1, result["expired_relationships"])
|
||||||
|
self.assertEqual(0, repeated["expired_relationships"])
|
||||||
|
self.assertEqual(1, len(events))
|
||||||
|
self.assertEqual("identity-1", events[0].subject.id)
|
||||||
|
with self.database.session() as session:
|
||||||
|
item = session.get(IdmIdentityRelationship, "relationship-due")
|
||||||
|
self.assertEqual(2, item.revision)
|
||||||
|
self.assertIsNotNone(item.expired_event_at)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from govoplan_idm.backend.assignment_transitions import (
|
||||||
|
AssignmentSnapshot,
|
||||||
|
AssignmentTransitionError,
|
||||||
|
lifecycle_event_types,
|
||||||
|
plan_assignment_update,
|
||||||
|
validate_assignment_shape,
|
||||||
|
validate_assignment_source_rules,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assignment(**overrides: object) -> SimpleNamespace:
|
||||||
|
values = {
|
||||||
|
"id": "assignment-1",
|
||||||
|
"identity_id": "identity-1",
|
||||||
|
"account_id": "account-1",
|
||||||
|
"function_id": "function-1",
|
||||||
|
"organization_unit_id": "unit-1",
|
||||||
|
"applies_to_subunits": False,
|
||||||
|
"source": "direct",
|
||||||
|
"delegated_from_assignment_id": None,
|
||||||
|
"acting_for_account_id": None,
|
||||||
|
"valid_from": None,
|
||||||
|
"valid_until": None,
|
||||||
|
"is_active": True,
|
||||||
|
"settings": {},
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return SimpleNamespace(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def function(**overrides: object) -> SimpleNamespace:
|
||||||
|
values = {"delegable": True, "act_in_place_allowed": True}
|
||||||
|
values.update(overrides)
|
||||||
|
return SimpleNamespace(**values)
|
||||||
|
|
||||||
|
|
||||||
|
class AssignmentWorkflowTests(unittest.TestCase):
|
||||||
|
def assert_invalid(self, expected_detail: str, callback: object) -> None:
|
||||||
|
with self.assertRaises(AssignmentTransitionError) as captured:
|
||||||
|
callback()
|
||||||
|
self.assertEqual(str(captured.exception), expected_detail)
|
||||||
|
|
||||||
|
def test_direct_assignment_accepts_plain_shape(self) -> None:
|
||||||
|
item = assignment()
|
||||||
|
|
||||||
|
validate_assignment_shape(item) # type: ignore[arg-type]
|
||||||
|
validate_assignment_source_rules( # type: ignore[arg-type]
|
||||||
|
item,
|
||||||
|
function=function(),
|
||||||
|
base=None,
|
||||||
|
account_linked_to_identity=lambda _identity_id, _account_id: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_shape_rejects_backwards_validity_window(self) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
item = assignment(valid_from=now, valid_until=now - timedelta(days=1))
|
||||||
|
|
||||||
|
self.assert_invalid("Valid until must be after valid from.", lambda: validate_assignment_shape(item)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def test_shape_rejects_self_delegation(self) -> None:
|
||||||
|
item = assignment(id="assignment-1", delegated_from_assignment_id="assignment-1")
|
||||||
|
|
||||||
|
self.assert_invalid("A function assignment cannot delegate from itself.", lambda: validate_assignment_shape(item)) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def test_delegated_assignment_accepts_different_target(self) -> None:
|
||||||
|
base = assignment(id="source-1", identity_id="identity-1", account_id="account-1")
|
||||||
|
item = assignment(
|
||||||
|
id="assignment-2",
|
||||||
|
identity_id="identity-2",
|
||||||
|
account_id="account-2",
|
||||||
|
source="delegated",
|
||||||
|
delegated_from_assignment_id="source-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
validate_assignment_source_rules( # type: ignore[arg-type]
|
||||||
|
item,
|
||||||
|
function=function(delegable=True),
|
||||||
|
base=base,
|
||||||
|
account_linked_to_identity=lambda _identity_id, _account_id: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_delegated_assignment_rejects_same_target(self) -> None:
|
||||||
|
base = assignment(id="source-1", identity_id="identity-1", account_id="account-1")
|
||||||
|
item = assignment(source="delegated", delegated_from_assignment_id="source-1")
|
||||||
|
|
||||||
|
self.assert_invalid(
|
||||||
|
"A delegated assignment must target another identity or account.",
|
||||||
|
lambda: validate_assignment_source_rules( # type: ignore[arg-type]
|
||||||
|
item,
|
||||||
|
function=function(delegable=True),
|
||||||
|
base=base,
|
||||||
|
account_linked_to_identity=lambda _identity_id, _account_id: False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_acting_for_assignment_accepts_identity_linked_account(self) -> None:
|
||||||
|
base = assignment(id="source-1", identity_id="identity-1", account_id=None)
|
||||||
|
item = assignment(
|
||||||
|
id="assignment-2",
|
||||||
|
source="acting_for",
|
||||||
|
delegated_from_assignment_id="source-1",
|
||||||
|
account_id="acting-account",
|
||||||
|
acting_for_account_id="represented-account",
|
||||||
|
)
|
||||||
|
|
||||||
|
validate_assignment_source_rules( # type: ignore[arg-type]
|
||||||
|
item,
|
||||||
|
function=function(act_in_place_allowed=True),
|
||||||
|
base=base,
|
||||||
|
account_linked_to_identity=lambda identity_id, account_id: identity_id == "identity-1" and account_id == "represented-account",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_acting_for_assignment_rejects_unlinked_represented_account(self) -> None:
|
||||||
|
base = assignment(id="source-1", identity_id="identity-1", account_id=None)
|
||||||
|
item = assignment(
|
||||||
|
id="assignment-2",
|
||||||
|
source="acting_for",
|
||||||
|
delegated_from_assignment_id="source-1",
|
||||||
|
account_id="acting-account",
|
||||||
|
acting_for_account_id="represented-account",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assert_invalid(
|
||||||
|
"Acting-for account must belong to the source assignment identity.",
|
||||||
|
lambda: validate_assignment_source_rules( # type: ignore[arg-type]
|
||||||
|
item,
|
||||||
|
function=function(act_in_place_allowed=True),
|
||||||
|
base=base,
|
||||||
|
account_linked_to_identity=lambda _identity_id, _account_id: False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_update_plan_does_not_mutate_until_applied(self) -> None:
|
||||||
|
item = assignment()
|
||||||
|
|
||||||
|
plan = plan_assignment_update(
|
||||||
|
item, # type: ignore[arg-type]
|
||||||
|
{
|
||||||
|
"function_id": "function-2",
|
||||||
|
"account_id": "account-2",
|
||||||
|
"settings": {"source": "test"},
|
||||||
|
},
|
||||||
|
organization_unit_id="unit-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("function-1", item.function_id)
|
||||||
|
self.assertEqual("function-2", plan.after.function_id)
|
||||||
|
self.assertEqual("unit-2", plan.after.organization_unit_id)
|
||||||
|
self.assertEqual(
|
||||||
|
("account_id", "function_id", "settings", "organization_unit_id"),
|
||||||
|
plan.changed_fields,
|
||||||
|
)
|
||||||
|
plan.apply(item) # type: ignore[arg-type]
|
||||||
|
self.assertEqual("function-2", item.function_id)
|
||||||
|
self.assertEqual("unit-2", item.organization_unit_id)
|
||||||
|
|
||||||
|
def test_update_decision_table_rejects_required_nulls(self) -> None:
|
||||||
|
item = assignment()
|
||||||
|
cases = {
|
||||||
|
"identity_id": "Identity is required.",
|
||||||
|
"function_id": "Function is required.",
|
||||||
|
"applies_to_subunits": "Subunit applicability cannot be empty.",
|
||||||
|
"source": "Assignment source is required.",
|
||||||
|
"is_active": "Active state cannot be empty.",
|
||||||
|
"settings": "Settings cannot be empty.",
|
||||||
|
}
|
||||||
|
for field, message in cases.items():
|
||||||
|
with self.subTest(field=field):
|
||||||
|
self.assert_invalid(
|
||||||
|
message,
|
||||||
|
lambda field=field: plan_assignment_update( # type: ignore[misc]
|
||||||
|
item, # type: ignore[arg-type]
|
||||||
|
{field: None},
|
||||||
|
organization_unit_id=(
|
||||||
|
"unit-2" if field == "function_id" else None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_lifecycle_event_decision_table(self) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
before = AssignmentSnapshot.from_assignment(assignment()) # type: ignore[arg-type]
|
||||||
|
cases = (
|
||||||
|
(
|
||||||
|
"ordinary update",
|
||||||
|
plan_assignment_update(
|
||||||
|
assignment(), # type: ignore[arg-type]
|
||||||
|
{"settings": {"changed": True}},
|
||||||
|
).after,
|
||||||
|
("idm.function_assignment.changed.v1",),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"revocation",
|
||||||
|
plan_assignment_update(
|
||||||
|
assignment(), # type: ignore[arg-type]
|
||||||
|
{"is_active": False},
|
||||||
|
).after,
|
||||||
|
(
|
||||||
|
"idm.function_assignment.changed.v1",
|
||||||
|
"idm.function_assignment.revoked.v1",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"new expiration",
|
||||||
|
plan_assignment_update(
|
||||||
|
assignment(), # type: ignore[arg-type]
|
||||||
|
{"valid_until": now - timedelta(minutes=1)},
|
||||||
|
).after,
|
||||||
|
(
|
||||||
|
"idm.function_assignment.changed.v1",
|
||||||
|
"idm.function_assignment.expired.v1",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"future validity",
|
||||||
|
plan_assignment_update(
|
||||||
|
assignment(), # type: ignore[arg-type]
|
||||||
|
{"valid_until": now + timedelta(minutes=1)},
|
||||||
|
).after,
|
||||||
|
("idm.function_assignment.changed.v1",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for label, after, expected in cases:
|
||||||
|
with self.subTest(label=label):
|
||||||
|
self.assertEqual(
|
||||||
|
expected,
|
||||||
|
lifecycle_event_types(before, after, now=now),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reactivation_of_elapsed_assignment_emits_expiry(self) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
item = assignment(
|
||||||
|
is_active=False,
|
||||||
|
valid_until=now - timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
before = AssignmentSnapshot.from_assignment(item) # type: ignore[arg-type]
|
||||||
|
after = plan_assignment_update( # type: ignore[arg-type]
|
||||||
|
item,
|
||||||
|
{"is_active": True},
|
||||||
|
).after
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
(
|
||||||
|
"idm.function_assignment.changed.v1",
|
||||||
|
"idm.function_assignment.expired.v1",
|
||||||
|
),
|
||||||
|
lifecycle_event_types(before, after, now=now),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from govoplan_core.core.identity import IdentityAccountLinkRef, IdentityRef
|
||||||
|
from govoplan_core.core.organizations import OrganizationFunctionRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.db.session import configure_database, reset_database
|
||||||
|
from govoplan_identity.backend.db import models as identity_models # noqa: F401 - resolve assignment foreign keys
|
||||||
|
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
|
||||||
|
from govoplan_idm.backend.directory import SqlIdmDirectory
|
||||||
|
from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - resolve assignment foreign keys
|
||||||
|
|
||||||
|
|
||||||
|
class StubIdentityDirectory:
|
||||||
|
def __init__(self, identities: tuple[IdentityRef, ...] = ()) -> None:
|
||||||
|
self.identities = identities
|
||||||
|
|
||||||
|
def get_identity(self, identity_id: str) -> IdentityRef | None:
|
||||||
|
return next(
|
||||||
|
(item for item in self.identities if item.id == identity_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def identity_for_account(self, account_id: str) -> IdentityRef | None:
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in self.identities
|
||||||
|
if account_id in item.account_ids
|
||||||
|
or item.primary_account_id == account_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def identities_for_accounts(self, account_ids: tuple[str, ...]) -> tuple[IdentityRef, ...]:
|
||||||
|
requested = set(account_ids)
|
||||||
|
return tuple(
|
||||||
|
item
|
||||||
|
for item in self.identities
|
||||||
|
if requested.intersection(item.account_ids)
|
||||||
|
or item.primary_account_id in requested
|
||||||
|
)
|
||||||
|
|
||||||
|
def accounts_for_identity(self, identity_id: str) -> tuple[IdentityAccountLinkRef, ...]:
|
||||||
|
identity = self.get_identity(identity_id)
|
||||||
|
if identity is None:
|
||||||
|
return ()
|
||||||
|
return tuple(
|
||||||
|
IdentityAccountLinkRef(
|
||||||
|
id=f"{identity_id}:{account_id}",
|
||||||
|
identity_id=identity_id,
|
||||||
|
account_id=account_id,
|
||||||
|
is_primary=account_id == identity.primary_account_id,
|
||||||
|
)
|
||||||
|
for account_id in identity.account_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StubOrganizationDirectory:
|
||||||
|
def get_function(self, function_id: str) -> OrganizationFunctionRef | None:
|
||||||
|
return OrganizationFunctionRef(
|
||||||
|
id=function_id,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
slug=function_id,
|
||||||
|
name=function_id,
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_organization_unit(self, organization_unit_id: str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def organization_units_for_tenant(self, tenant_id: str):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def functions_for_organization_unit(self, organization_unit_id: str, *, include_subunits: bool = False):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
class IdmDirectoryDelegationTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.database = configure_database("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.database.engine,
|
||||||
|
tables=[IdmOrganizationFunctionAssignment.__table__],
|
||||||
|
)
|
||||||
|
self.directory = SqlIdmDirectory(
|
||||||
|
identities=StubIdentityDirectory(), # type: ignore[arg-type]
|
||||||
|
organizations=StubOrganizationDirectory(), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
reset_database(dispose=True)
|
||||||
|
|
||||||
|
def _source_and_child(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
case: str,
|
||||||
|
child_source: str = "delegated",
|
||||||
|
source_tenant_id: str = "tenant-1",
|
||||||
|
source_function_id: str = "function-1",
|
||||||
|
source_is_active: bool = True,
|
||||||
|
source_valid_from: datetime | None = None,
|
||||||
|
source_valid_until: datetime | None = None,
|
||||||
|
) -> tuple[IdmOrganizationFunctionAssignment, IdmOrganizationFunctionAssignment]:
|
||||||
|
source = IdmOrganizationFunctionAssignment(
|
||||||
|
id=f"source-{case}",
|
||||||
|
tenant_id=source_tenant_id,
|
||||||
|
identity_id=f"source-identity-{case}",
|
||||||
|
function_id=source_function_id,
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
source="direct",
|
||||||
|
is_active=source_is_active,
|
||||||
|
valid_from=source_valid_from,
|
||||||
|
valid_until=source_valid_until,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
child = IdmOrganizationFunctionAssignment(
|
||||||
|
id=f"child-{case}",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id=f"target-identity-{case}",
|
||||||
|
function_id="function-1",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
source=child_source,
|
||||||
|
delegated_from_assignment_id=source.id,
|
||||||
|
acting_for_account_id="represented-account" if child_source == "acting_for" else None,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
return source, child
|
||||||
|
|
||||||
|
def test_effective_delegations_require_a_current_matching_source(self) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cases = (
|
||||||
|
self._source_and_child(case="valid"),
|
||||||
|
self._source_and_child(case="acting", child_source="acting_for"),
|
||||||
|
self._source_and_child(case="revoked", source_is_active=False),
|
||||||
|
self._source_and_child(case="expired", source_valid_until=now - timedelta(days=1)),
|
||||||
|
self._source_and_child(case="future", source_valid_from=now + timedelta(days=1)),
|
||||||
|
self._source_and_child(case="wrong-tenant", source_tenant_id="tenant-2"),
|
||||||
|
self._source_and_child(case="wrong-function", source_function_id="function-2"),
|
||||||
|
)
|
||||||
|
with self.database.session() as session:
|
||||||
|
for source, child in cases:
|
||||||
|
session.add_all((source, child))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
"valid": ("child-valid",),
|
||||||
|
"acting": ("child-acting",),
|
||||||
|
"revoked": (),
|
||||||
|
"expired": (),
|
||||||
|
"future": (),
|
||||||
|
"wrong-tenant": (),
|
||||||
|
"wrong-function": (),
|
||||||
|
}
|
||||||
|
for case, expected_ids in expected.items():
|
||||||
|
with self.subTest(case=case):
|
||||||
|
assignments = self.directory.organization_function_assignments_for_identity(
|
||||||
|
f"target-identity-{case}",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)
|
||||||
|
self.assertEqual(expected_ids, tuple(item.id for item in assignments))
|
||||||
|
|
||||||
|
def test_reverse_lookup_returns_only_effective_function_assignments(self) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
active = IdmOrganizationFunctionAssignment(
|
||||||
|
id="active-function-holder",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id="identity-active",
|
||||||
|
account_id="account-active",
|
||||||
|
function_id="function-1",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
source="direct",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
expired = IdmOrganizationFunctionAssignment(
|
||||||
|
id="expired-function-holder",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id="identity-expired",
|
||||||
|
function_id="function-1",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
source="direct",
|
||||||
|
is_active=True,
|
||||||
|
valid_until=now - timedelta(minutes=1),
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
other_tenant = IdmOrganizationFunctionAssignment(
|
||||||
|
id="other-tenant-holder",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
identity_id="identity-other",
|
||||||
|
function_id="function-1",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
source="direct",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add_all((active, expired, other_tenant))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
assignments = self.directory.organization_function_assignments_for_function(
|
||||||
|
"function-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
("active-function-holder",),
|
||||||
|
tuple(item.id for item in assignments),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_batch_incumbency_reports_vacancy_and_honors_effective_time(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
boundary = datetime.now(timezone.utc)
|
||||||
|
assignment = IdmOrganizationFunctionAssignment(
|
||||||
|
id="bounded-holder",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id="identity-bounded",
|
||||||
|
account_id="account-bounded",
|
||||||
|
function_id="function-1",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
source="direct",
|
||||||
|
is_active=True,
|
||||||
|
valid_from=boundary - timedelta(hours=1),
|
||||||
|
valid_until=boundary + timedelta(hours=1),
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add(assignment)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
current = self.directory.organization_function_incumbencies(
|
||||||
|
("function-1", "function-2"),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary,
|
||||||
|
)
|
||||||
|
later = self.directory.organization_function_incumbencies(
|
||||||
|
("function-1",),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary + timedelta(hours=2),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
("bounded-holder",),
|
||||||
|
tuple(item.id for item in current["function-1"].assignments),
|
||||||
|
)
|
||||||
|
self.assertFalse(current["function-1"].vacant)
|
||||||
|
self.assertTrue(current["function-2"].vacant)
|
||||||
|
self.assertTrue(later["function-1"].vacant)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "another tenant"):
|
||||||
|
self.directory.organization_function_incumbencies(
|
||||||
|
("function-1",),
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_batch_accounts_preserve_account_and_subunit_provenance(self) -> None:
|
||||||
|
self.directory = SqlIdmDirectory(
|
||||||
|
identities=StubIdentityDirectory(
|
||||||
|
(
|
||||||
|
IdentityRef(
|
||||||
|
id="identity-shared",
|
||||||
|
primary_account_id="account-1",
|
||||||
|
account_ids=("account-1", "account-2"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
), # type: ignore[arg-type]
|
||||||
|
organizations=StubOrganizationDirectory(), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
broad = IdmOrganizationFunctionAssignment(
|
||||||
|
id="broad",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id="identity-shared",
|
||||||
|
account_id=None,
|
||||||
|
function_id="function-1",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
applies_to_subunits=True,
|
||||||
|
source="direct",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
account_specific = IdmOrganizationFunctionAssignment(
|
||||||
|
id="account-specific",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id="identity-shared",
|
||||||
|
account_id="account-2",
|
||||||
|
function_id="function-2",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
source="governance",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
simultaneous = IdmOrganizationFunctionAssignment(
|
||||||
|
id="second-incumbent",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id="identity-second",
|
||||||
|
function_id="function-1",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
source="direct",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add_all((broad, account_specific, simultaneous))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resolved = self.directory.organization_function_assignments_for_accounts(
|
||||||
|
("account-1", "account-2", "missing"),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)
|
||||||
|
self.assertEqual(("broad",), tuple(item.id for item in resolved["account-1"]))
|
||||||
|
self.assertEqual(
|
||||||
|
("broad", "account-specific"),
|
||||||
|
tuple(item.id for item in resolved["account-2"]),
|
||||||
|
)
|
||||||
|
self.assertEqual((), resolved["missing"])
|
||||||
|
self.assertTrue(resolved["account-1"][0].applies_to_subunits)
|
||||||
|
self.assertEqual("governance", resolved["account-2"][1].source)
|
||||||
|
|
||||||
|
incumbency = self.directory.organization_function_incumbencies(
|
||||||
|
("function-1",),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)["function-1"]
|
||||||
|
self.assertEqual(
|
||||||
|
("broad", "second-incumbent"),
|
||||||
|
tuple(item.id for item in incumbency.assignments),
|
||||||
|
)
|
||||||
|
self.assertFalse(incumbency.vacant)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.core.concurrency import RevisionConflictError
|
||||||
|
from govoplan_core.core.organizations import OrganizationFunctionRef
|
||||||
|
from govoplan_core.core.policy import FunctionAssignmentGovernanceDecision
|
||||||
|
from govoplan_core.core.workflows import WorkflowInstanceRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.db.session import configure_database, reset_database
|
||||||
|
from govoplan_identity.backend.db import models as identity_models # noqa: F401
|
||||||
|
from govoplan_idm.backend.api.v1.schemas import FunctionAssignmentChangeCreateRequest
|
||||||
|
from govoplan_idm.backend.api.v1.function_changes import _change_item
|
||||||
|
from govoplan_idm.backend.db.models import (
|
||||||
|
IdmFunctionAssignmentChange,
|
||||||
|
IdmFunctionAssignmentChangeEvent,
|
||||||
|
IdmOrganizationFunctionAssignment,
|
||||||
|
IdmTenantSettings,
|
||||||
|
)
|
||||||
|
from govoplan_idm.backend.function_assignment_changes import (
|
||||||
|
create_function_assignment_change,
|
||||||
|
transition_function_assignment_change,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.db import models as organization_models # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
class _Policy:
|
||||||
|
def resolve_function_assignment_action(self, session=None, *, request):
|
||||||
|
del session
|
||||||
|
steps = ("holder", "authority")
|
||||||
|
context = request.context
|
||||||
|
allowed = {
|
||||||
|
"submit": bool(context.get("candidate_is_actor")),
|
||||||
|
"approve_holder": bool(context.get("actor_is_holder")),
|
||||||
|
"approve_authority": bool(context.get("actor_is_authority")),
|
||||||
|
"accept_recipient": bool(context.get("candidate_is_actor")),
|
||||||
|
"request_changes": bool(
|
||||||
|
context.get("actor_is_holder") or context.get("actor_is_authority")
|
||||||
|
),
|
||||||
|
"respond": bool(
|
||||||
|
context.get("actor_is_initiator") or context.get("candidate_is_actor")
|
||||||
|
),
|
||||||
|
"reject": bool(
|
||||||
|
context.get("actor_is_holder") or context.get("actor_is_authority")
|
||||||
|
),
|
||||||
|
"withdraw": bool(context.get("actor_is_initiator")),
|
||||||
|
"recover": request.actor.account_id == "admin",
|
||||||
|
"apply": bool(context.get("approvals_complete")),
|
||||||
|
}.get(request.action, False)
|
||||||
|
return FunctionAssignmentGovernanceDecision(
|
||||||
|
allowed=allowed,
|
||||||
|
reason=None if allowed else "Not eligible for this action.",
|
||||||
|
profile="holder_with_authority_clearance",
|
||||||
|
required_steps=steps,
|
||||||
|
authority_function_id="authority-function",
|
||||||
|
separation_of_duties=False,
|
||||||
|
request_expiry_hours=24,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Workflow:
|
||||||
|
nodes = ("holder_review", "authority_review", "recipient_review")
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.instances: dict[str, WorkflowInstanceRef] = {}
|
||||||
|
|
||||||
|
def start_standard(self, session, principal, *, request):
|
||||||
|
del session, principal
|
||||||
|
instance_id = f"workflow-{request.idempotency_key}"
|
||||||
|
existing = self.instances.get(instance_id)
|
||||||
|
if existing is not None:
|
||||||
|
return replace(existing, replayed=True)
|
||||||
|
reference = WorkflowInstanceRef(
|
||||||
|
id=instance_id,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
definition_id="definition-1",
|
||||||
|
definition_revision_id="revision-1",
|
||||||
|
definition_revision=1,
|
||||||
|
definition_hash="a" * 64,
|
||||||
|
status="waiting",
|
||||||
|
current_step_id="step-holder_review",
|
||||||
|
current_node_id="holder_review",
|
||||||
|
)
|
||||||
|
self.instances[instance_id] = reference
|
||||||
|
return reference
|
||||||
|
|
||||||
|
def resolve_current_step(
|
||||||
|
self,
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
*,
|
||||||
|
tenant_id,
|
||||||
|
instance_id,
|
||||||
|
resolution,
|
||||||
|
):
|
||||||
|
del session, principal, tenant_id
|
||||||
|
current = self.instances[instance_id]
|
||||||
|
if resolution.expected_step_id != current.current_step_id:
|
||||||
|
raise ValueError("Workflow current step changed.")
|
||||||
|
if resolution.action == "changes":
|
||||||
|
return current
|
||||||
|
if resolution.action in {"reject", "cancel"}:
|
||||||
|
result = replace(
|
||||||
|
current,
|
||||||
|
status="completed",
|
||||||
|
current_step_id=None,
|
||||||
|
current_node_id=None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
index = self.nodes.index(current.current_node_id or "") + 1
|
||||||
|
if index >= len(self.nodes):
|
||||||
|
result = replace(
|
||||||
|
current,
|
||||||
|
status="completed",
|
||||||
|
current_step_id=None,
|
||||||
|
current_node_id=None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
node = self.nodes[index]
|
||||||
|
result = replace(
|
||||||
|
current,
|
||||||
|
current_step_id=f"step-{node}",
|
||||||
|
current_node_id=node,
|
||||||
|
)
|
||||||
|
self.instances[instance_id] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_instance(self, session, *, tenant_id, instance_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return self.instances[instance_id]
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.policy = _Policy()
|
||||||
|
self.workflow = _Workflow()
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return name in {
|
||||||
|
"policy.functionAssignmentGovernance",
|
||||||
|
"workflow.orchestration",
|
||||||
|
}
|
||||||
|
|
||||||
|
def capability(self, name: str):
|
||||||
|
if name == "policy.functionAssignmentGovernance":
|
||||||
|
return self.policy
|
||||||
|
if name == "workflow.orchestration":
|
||||||
|
return self.workflow
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def principal(account_id: str, identity_id: str) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id=account_id,
|
||||||
|
membership_id=f"membership-{account_id}",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id=identity_id,
|
||||||
|
scopes=frozenset({"idm:function_change:decide"}),
|
||||||
|
),
|
||||||
|
account=object(),
|
||||||
|
user=object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def function() -> OrganizationFunctionRef:
|
||||||
|
return OrganizationFunctionRef(
|
||||||
|
id="target-function",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
slug="target",
|
||||||
|
name="Target function",
|
||||||
|
settings={
|
||||||
|
"assignment_governance": {
|
||||||
|
"request_profile": "holder_with_authority_clearance",
|
||||||
|
"authority_function_id": "authority-function",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def payload() -> FunctionAssignmentChangeCreateRequest:
|
||||||
|
return FunctionAssignmentChangeCreateRequest(
|
||||||
|
kind="request",
|
||||||
|
function_id="target-function",
|
||||||
|
candidate_identity_id="candidate-identity",
|
||||||
|
candidate_account_id="candidate",
|
||||||
|
justification="The function is needed for the assigned work.",
|
||||||
|
idempotency_key="request-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionAssignmentChangeTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.database = configure_database("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.database.engine,
|
||||||
|
tables=[
|
||||||
|
IdmOrganizationFunctionAssignment.__table__,
|
||||||
|
IdmFunctionAssignmentChange.__table__,
|
||||||
|
IdmFunctionAssignmentChangeEvent.__table__,
|
||||||
|
IdmTenantSettings.__table__,
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.registry = _Registry()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
reset_database(dispose=True)
|
||||||
|
|
||||||
|
def _add_reviewer_assignments(self, session) -> None:
|
||||||
|
session.add_all(
|
||||||
|
(
|
||||||
|
IdmOrganizationFunctionAssignment(
|
||||||
|
id="holder-assignment",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id="holder-identity",
|
||||||
|
account_id="holder",
|
||||||
|
function_id="target-function",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
source="direct",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
),
|
||||||
|
IdmOrganizationFunctionAssignment(
|
||||||
|
id="authority-assignment",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id="authority-identity",
|
||||||
|
account_id="authority",
|
||||||
|
function_id="authority-function",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
source="direct",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
def test_request_is_idempotent_and_applies_once_after_required_steps(self) -> None:
|
||||||
|
with self.database.session() as session:
|
||||||
|
self._add_reviewer_assignments(session)
|
||||||
|
change, replayed = create_function_assignment_change(
|
||||||
|
session,
|
||||||
|
principal=principal("candidate", "candidate-identity"),
|
||||||
|
registry=self.registry,
|
||||||
|
function=function(),
|
||||||
|
payload=payload(),
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
same, replay = create_function_assignment_change(
|
||||||
|
session,
|
||||||
|
principal=principal("candidate", "candidate-identity"),
|
||||||
|
registry=self.registry,
|
||||||
|
function=function(),
|
||||||
|
payload=payload(),
|
||||||
|
)
|
||||||
|
self.assertFalse(replayed)
|
||||||
|
self.assertTrue(replay)
|
||||||
|
self.assertEqual(change.id, same.id)
|
||||||
|
self.assertEqual("awaiting_holder", change.state)
|
||||||
|
|
||||||
|
transition_function_assignment_change(
|
||||||
|
session,
|
||||||
|
principal=principal("holder", "holder-identity"),
|
||||||
|
registry=self.registry,
|
||||||
|
change=change,
|
||||||
|
function=function(),
|
||||||
|
action="approve",
|
||||||
|
base_revision=1,
|
||||||
|
comment="Holder approval",
|
||||||
|
evidence=(),
|
||||||
|
)
|
||||||
|
self.assertEqual("awaiting_authority", change.state)
|
||||||
|
transition_function_assignment_change(
|
||||||
|
session,
|
||||||
|
principal=principal("authority", "authority-identity"),
|
||||||
|
registry=self.registry,
|
||||||
|
change=change,
|
||||||
|
function=function(),
|
||||||
|
action="approve",
|
||||||
|
base_revision=2,
|
||||||
|
comment="Authority approval",
|
||||||
|
evidence=(),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
self.assertEqual("applied", change.state)
|
||||||
|
self.assertIsNotNone(change.resulting_assignment_id)
|
||||||
|
resulting = session.get(
|
||||||
|
IdmOrganizationFunctionAssignment,
|
||||||
|
change.resulting_assignment_id,
|
||||||
|
)
|
||||||
|
self.assertEqual("candidate-identity", resulting.identity_id)
|
||||||
|
self.assertEqual("governance", resulting.source)
|
||||||
|
self.assertEqual(
|
||||||
|
change.id,
|
||||||
|
resulting.settings["governance"]["change_id"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
session.query(IdmOrganizationFunctionAssignment)
|
||||||
|
.filter(
|
||||||
|
IdmOrganizationFunctionAssignment.identity_id
|
||||||
|
== "candidate-identity"
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_vacant_function_blocks_and_revision_claim_rejects_stale_action(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with self.database.session() as session:
|
||||||
|
change, _ = create_function_assignment_change(
|
||||||
|
session,
|
||||||
|
principal=principal("candidate", "candidate-identity"),
|
||||||
|
registry=self.registry,
|
||||||
|
function=function(),
|
||||||
|
payload=payload(),
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
self.assertEqual("blocked", change.state)
|
||||||
|
self.assertIn("vacant", change.outcome_reason)
|
||||||
|
|
||||||
|
self._add_reviewer_assignments(session)
|
||||||
|
change.state = "awaiting_holder"
|
||||||
|
change.resource_revision = 2
|
||||||
|
session.flush()
|
||||||
|
with self.assertRaises(RevisionConflictError):
|
||||||
|
transition_function_assignment_change(
|
||||||
|
session,
|
||||||
|
principal=principal("holder", "holder-identity"),
|
||||||
|
registry=self.registry,
|
||||||
|
change=change,
|
||||||
|
function=function(),
|
||||||
|
action="approve",
|
||||||
|
base_revision=1,
|
||||||
|
comment=None,
|
||||||
|
evidence=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_historical_change_remains_readable_after_function_removal(self) -> None:
|
||||||
|
with self.database.session() as session:
|
||||||
|
change, _ = create_function_assignment_change(
|
||||||
|
session,
|
||||||
|
principal=principal("candidate", "candidate-identity"),
|
||||||
|
registry=self.registry,
|
||||||
|
function=function(),
|
||||||
|
payload=payload(),
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_idm.backend.api.v1.function_changes._historical_function",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
item = _change_item(
|
||||||
|
session,
|
||||||
|
principal("candidate", "candidate-identity"),
|
||||||
|
change,
|
||||||
|
include_events=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(change.id, item.id)
|
||||||
|
self.assertEqual([], item.available_actions)
|
||||||
|
self.assertIn("no longer available", item.availability_reason)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_idm.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class IdmInterfaceDocumentationContractTests(unittest.TestCase):
|
||||||
|
def test_route_and_contributed_action_remain_declared(self) -> None:
|
||||||
|
frontend = manifest.frontend
|
||||||
|
self.assertIsNotNone(frontend)
|
||||||
|
self.assertEqual(
|
||||||
|
{"/idm"},
|
||||||
|
{route.path for route in frontend.routes}, # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{"idm.action.view-function-assignments"},
|
||||||
|
{surface.id for surface in frontend.view_surfaces}, # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
self.assertTrue(all(item.icon == "users" for item in manifest.nav_items))
|
||||||
|
|
||||||
|
def test_topics_publish_workflow_blocker_and_consequence_metadata(self) -> None:
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
self.assertIn("idm.workflow.assign-function-to-identity", topics)
|
||||||
|
self.assertIn("idm.reference.assignment-governance", topics)
|
||||||
|
self.assertIn("idm.reference.fields-and-consequences", topics)
|
||||||
|
|
||||||
|
workflow = topics["idm.workflow.assign-function-to-identity"]
|
||||||
|
self.assertIn("idm.blocker.permission", workflow.metadata["help_contexts"])
|
||||||
|
governance = topics["idm.reference.assignment-governance"]
|
||||||
|
self.assertIn("function_decision", governance.metadata["consequence_classes"])
|
||||||
|
reference = topics["idm.reference.fields-and-consequences"]
|
||||||
|
self.assertIn("idm.field.acting-for", reference.metadata["help_contexts"])
|
||||||
|
self.assertIn("deactivate_or_expire", reference.metadata["consequence_classes"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
|
||||||
|
from govoplan_core.db.migrations import migrate_database
|
||||||
|
from govoplan_identity.backend.manifest import get_manifest as identity_manifest
|
||||||
|
from govoplan_idm.backend.manifest import get_manifest as idm_manifest
|
||||||
|
from govoplan_organizations.backend.manifest import (
|
||||||
|
get_manifest as organizations_manifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IdmMigrationTests(unittest.TestCase):
|
||||||
|
def test_migrations_create_typed_relationship_tables_and_head(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-idm-migration-") as directory:
|
||||||
|
url = f"sqlite:///{Path(directory) / 'idm.db'}"
|
||||||
|
migrate_database(
|
||||||
|
database_url=url,
|
||||||
|
enabled_modules=("identity", "organizations", "idm"),
|
||||||
|
manifest_factories=(
|
||||||
|
identity_manifest,
|
||||||
|
organizations_manifest,
|
||||||
|
idm_manifest,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
engine = create_engine(url)
|
||||||
|
try:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
self.assertIn(
|
||||||
|
"b1c2d3e4f5a6",
|
||||||
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"idm_function_assignment_change_events",
|
||||||
|
"idm_function_assignment_changes",
|
||||||
|
"idm_identity_relationships",
|
||||||
|
"idm_organization_function_assignments",
|
||||||
|
"idm_tenant_settings",
|
||||||
|
"idm_typed_groups",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name
|
||||||
|
for name in inspect(connection).get_table_names()
|
||||||
|
if name.startswith("idm_")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from govoplan_core.core.identity import IdentityAccountLinkRef, IdentityRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.db.session import configure_database, reset_database
|
||||||
|
from govoplan_identity.backend.db.models import CanonicalIdentity
|
||||||
|
from govoplan_idm.backend.db.models import (
|
||||||
|
IdmIdentityRelationship,
|
||||||
|
IdmTypedGroup,
|
||||||
|
)
|
||||||
|
from govoplan_idm.backend.relationships import SqlIdmRelationshipDirectory
|
||||||
|
|
||||||
|
|
||||||
|
class StubIdentityDirectory:
|
||||||
|
def __init__(self, identities: tuple[IdentityRef, ...]) -> None:
|
||||||
|
self._identities = {item.id: item for item in identities}
|
||||||
|
|
||||||
|
def get_identity(self, identity_id: str) -> IdentityRef | None:
|
||||||
|
return self._identities.get(identity_id)
|
||||||
|
|
||||||
|
def identity_for_account(self, account_id: str) -> IdentityRef | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def identities_for_accounts(self, account_ids):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def accounts_for_identity(self, identity_id: str) -> tuple[IdentityAccountLinkRef, ...]:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
class IdmRelationshipDirectoryTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.database = configure_database("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.database.engine,
|
||||||
|
tables=[
|
||||||
|
CanonicalIdentity.__table__,
|
||||||
|
IdmTypedGroup.__table__,
|
||||||
|
IdmIdentityRelationship.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
identities = (
|
||||||
|
IdentityRef(id="identity-active", display_name="Active", status="active"),
|
||||||
|
IdentityRef(id="identity-future", display_name="Future", status="active"),
|
||||||
|
IdentityRef(id="identity-expired", display_name="Expired", status="active"),
|
||||||
|
IdentityRef(id="identity-revoked", display_name="Revoked", status="active"),
|
||||||
|
IdentityRef(id="identity-suspended", display_name="Suspended", status="suspended"),
|
||||||
|
)
|
||||||
|
self.directory = SqlIdmRelationshipDirectory(
|
||||||
|
identities=StubIdentityDirectory(identities) # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add_all(
|
||||||
|
CanonicalIdentity(
|
||||||
|
id=item.id,
|
||||||
|
display_name=item.display_name,
|
||||||
|
source="local",
|
||||||
|
is_active=item.status == "active",
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
for item in identities
|
||||||
|
)
|
||||||
|
session.add_all(
|
||||||
|
(
|
||||||
|
IdmTypedGroup(
|
||||||
|
id="group-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
key="permit-holder",
|
||||||
|
name="Permit holders",
|
||||||
|
group_type="business_status",
|
||||||
|
source_provider="ldap",
|
||||||
|
source_resource_type="group",
|
||||||
|
source_resource_id="cn=permit-holders,dc=example",
|
||||||
|
source_revision="directory-42",
|
||||||
|
properties={"classification": "resident"},
|
||||||
|
provenance={"connector_id": "ldap-1"},
|
||||||
|
),
|
||||||
|
IdmTypedGroup(
|
||||||
|
id="group-other",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
key="other",
|
||||||
|
name="Other tenant",
|
||||||
|
group_type="business_status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
reset_database(dispose=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _relationship(
|
||||||
|
relationship_id: str,
|
||||||
|
identity_id: str,
|
||||||
|
*,
|
||||||
|
boundary: datetime,
|
||||||
|
valid_from: datetime | None = None,
|
||||||
|
valid_until: datetime | None = None,
|
||||||
|
status: str = "active",
|
||||||
|
) -> IdmIdentityRelationship:
|
||||||
|
return IdmIdentityRelationship(
|
||||||
|
id=relationship_id,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
relationship_kind="member",
|
||||||
|
subject_identity_id=identity_id,
|
||||||
|
target_group_id="group-1",
|
||||||
|
valid_from=valid_from,
|
||||||
|
valid_until=valid_until,
|
||||||
|
status=status,
|
||||||
|
revoked_at=boundary if status == "revoked" else None,
|
||||||
|
revoked_by="account-1" if status == "revoked" else None,
|
||||||
|
revocation_reason="No longer eligible" if status == "revoked" else None,
|
||||||
|
source_provider="ldap",
|
||||||
|
source_resource_type="membership",
|
||||||
|
source_resource_id=f"member:{identity_id}",
|
||||||
|
source_revision="directory-42",
|
||||||
|
properties={"rank": 1},
|
||||||
|
provenance={"sync_run_id": "sync-1"},
|
||||||
|
revision=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolution_explains_current_future_expired_revoked_and_lifecycle(self) -> None:
|
||||||
|
boundary = datetime(2026, 8, 2, 12, tzinfo=timezone.utc)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add_all(
|
||||||
|
(
|
||||||
|
self._relationship("active", "identity-active", boundary=boundary),
|
||||||
|
self._relationship(
|
||||||
|
"future",
|
||||||
|
"identity-future",
|
||||||
|
boundary=boundary,
|
||||||
|
valid_from=boundary + timedelta(days=1),
|
||||||
|
),
|
||||||
|
self._relationship(
|
||||||
|
"expired",
|
||||||
|
"identity-expired",
|
||||||
|
boundary=boundary,
|
||||||
|
valid_until=boundary,
|
||||||
|
),
|
||||||
|
self._relationship(
|
||||||
|
"revoked",
|
||||||
|
"identity-revoked",
|
||||||
|
boundary=boundary,
|
||||||
|
status="revoked",
|
||||||
|
),
|
||||||
|
self._relationship(
|
||||||
|
"suspended",
|
||||||
|
"identity-suspended",
|
||||||
|
boundary=boundary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resolved = self.directory.resolve_typed_group_memberships(
|
||||||
|
("group-1",),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary,
|
||||||
|
)["group-1"]
|
||||||
|
|
||||||
|
self.assertEqual(("identity-active",), resolved.identity_ids)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"active": "relationship.effective",
|
||||||
|
"future": "relationship.not_yet_effective",
|
||||||
|
"expired": "relationship.expired",
|
||||||
|
"revoked": "relationship.revoked",
|
||||||
|
"suspended": "identity.not_active",
|
||||||
|
},
|
||||||
|
{item.relationship.id: item.code for item in resolved.decisions},
|
||||||
|
)
|
||||||
|
self.assertEqual("directory-42", resolved.group.source_revision)
|
||||||
|
self.assertEqual("ldap", resolved.decisions[0].relationship.source_provider)
|
||||||
|
|
||||||
|
def test_forward_reverse_batch_queries_return_only_effective_relationships(self) -> None:
|
||||||
|
boundary = datetime(2026, 8, 2, 12, tzinfo=timezone.utc)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add_all(
|
||||||
|
(
|
||||||
|
self._relationship("active", "identity-active", boundary=boundary),
|
||||||
|
self._relationship(
|
||||||
|
"future",
|
||||||
|
"identity-future",
|
||||||
|
boundary=boundary,
|
||||||
|
valid_from=boundary + timedelta(days=1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
forward = self.directory.identity_relationships_for_identities(
|
||||||
|
("identity-active", "identity-future"),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary,
|
||||||
|
)
|
||||||
|
reverse = self.directory.identity_relationships_for_groups(
|
||||||
|
("group-1",),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(("active",), tuple(item.id for item in forward["identity-active"]))
|
||||||
|
self.assertEqual((), forward["identity-future"])
|
||||||
|
self.assertEqual(("active",), tuple(item.id for item in reverse["group-1"]))
|
||||||
|
|
||||||
|
def test_cross_tenant_group_references_are_rejected(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "another tenant"):
|
||||||
|
self.directory.get_typed_group("group-other", tenant_id="tenant-1")
|
||||||
|
with self.assertRaisesRegex(ValueError, "another tenant"):
|
||||||
|
self.directory.identity_relationships_for_group(
|
||||||
|
"group-other", tenant_id="tenant-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.events import EventObjectRef, EventTenantRef, PlatformEvent
|
||||||
|
from govoplan_core.core.search import (
|
||||||
|
SearchAuthorizationRequest,
|
||||||
|
SearchBackfillRequest,
|
||||||
|
SearchResourceReference,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_idm.backend.db.models import IdmTypedGroup
|
||||||
|
from govoplan_idm.backend.search_source import IdmSearchSource, PROVIDER_ID
|
||||||
|
|
||||||
|
|
||||||
|
class IdmSearchSourceTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=(IdmTypedGroup.__table__,),
|
||||||
|
)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
IdmTypedGroup(
|
||||||
|
id="group-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
key="permit-holder",
|
||||||
|
name="Permit holders",
|
||||||
|
group_type="business_status",
|
||||||
|
),
|
||||||
|
IdmTypedGroup(
|
||||||
|
id="group-other",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
key="other",
|
||||||
|
name="Other tenant",
|
||||||
|
group_type="business_status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.source = IdmSearchSource()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_typed_group_search_is_tenant_and_scope_bounded(self) -> None:
|
||||||
|
page = self.source.backfill(
|
||||||
|
self.session,
|
||||||
|
request=SearchBackfillRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
resource_type="typed_group",
|
||||||
|
rebuild_id="rebuild-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(("group-1",), tuple(doc.resource_id for doc in page.documents))
|
||||||
|
reference = SearchResourceReference(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
module_id="idm",
|
||||||
|
resource_type="typed_group",
|
||||||
|
resource_id="group-1",
|
||||||
|
)
|
||||||
|
request = SearchAuthorizationRequest(reference=reference, source_revision="1")
|
||||||
|
self.assertTrue(
|
||||||
|
self.source.authorize(
|
||||||
|
self.session,
|
||||||
|
_principal({"idm:relationship:read"}),
|
||||||
|
requests=(request,),
|
||||||
|
)[reference.key]
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
self.source.authorize(
|
||||||
|
self.session,
|
||||||
|
_principal(set()),
|
||||||
|
requests=(request,),
|
||||||
|
)[reference.key]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_typed_group_event_is_translated_to_an_upsert(self) -> None:
|
||||||
|
event = PlatformEvent(
|
||||||
|
type="idm.typed_group.updated",
|
||||||
|
module_id="idm",
|
||||||
|
tenant=EventTenantRef(id="tenant-1"),
|
||||||
|
resource=EventObjectRef(type="typed_group", id="group-1"),
|
||||||
|
)
|
||||||
|
change = self.source.index_changes_for_event(
|
||||||
|
self.session,
|
||||||
|
event=event,
|
||||||
|
delivery_key="delivery-1",
|
||||||
|
)[0]
|
||||||
|
self.assertEqual("upsert", change.kind)
|
||||||
|
self.assertEqual("group-1", change.reference.resource_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(scopes: set[str]) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="user-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scopes=frozenset(scopes),
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id="account-1"),
|
||||||
|
user=SimpleNamespace(id="user-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/idm-webui",
|
||||||
|
"version": "0.1.18",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"import": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/idm.css": "./src/styles/idm.css"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^7.3.6"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
function source(path) {
|
||||||
|
return readFileSync(new URL(path, import.meta.url), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = source("../src/features/IdmPage.tsx");
|
||||||
|
const changes = source("../src/features/FunctionAssignmentChangesPanel.tsx");
|
||||||
|
const patterns = source("../src/features/interfacePatterns.ts");
|
||||||
|
const moduleSource = source("../src/module.ts");
|
||||||
|
const translations = source("../src/i18n/generatedTranslations.ts");
|
||||||
|
const styles = source("../src/styles/idm.css");
|
||||||
|
|
||||||
|
assert(page.includes("ActionBlockerHint") && page.includes("DocumentationHelpLink"), "IDM permissions and prerequisites expose actionable help");
|
||||||
|
assert(page.includes("assignmentBaseline") && page.includes("draftKey(assignmentDraft) !== draftKey(assignmentBaseline)"), "Existing assignments compare against their loaded draft baseline");
|
||||||
|
assert(page.includes("requestDiscard(() => void loadData())") && page.includes("closeAssignmentEditor"), "Reload and dialog close preserve actual dirty drafts");
|
||||||
|
assert(page.includes("disabledReason") && changes.includes("disabledReason"), "Unavailable assignment and decision actions explain their state");
|
||||||
|
assert(changes.includes("ConfirmDialog") && changes.includes("confirm_function_action"), "Governed function decisions require explicit shared confirmation");
|
||||||
|
assert(changes.includes("useUnsavedDraftGuard") && changes.includes("usePlatformLanguage"), "Governed editors protect drafts and format dates with the platform locale");
|
||||||
|
assert(patterns.includes('topicId: "idm.reference.fields-and-consequences"'), "IDM fields use manifest-backed consequence help");
|
||||||
|
assert(moduleSource.includes('version: "0.1.8"') && moduleSource.includes('label: "i18n:govoplan-idm.view_assignments.2d40d6a5"'), "WebUI metadata matches the module release and localizes its action surface");
|
||||||
|
assert(translations.includes('"i18n:govoplan-idm.state_awaiting_authority"'), "Governed states and decisions are in the translation catalogue");
|
||||||
|
assert(!page.includes("window.confirm") && !changes.includes("window.confirm"), "IDM does not use browser-native consequential confirmation");
|
||||||
|
assert(styles.includes(".idm-page") && !/\.idm-page\s*\{[^}]*max-width/s.test(styles), "The IDM workspace uses the full shared application width");
|
||||||
|
|
||||||
|
console.log("IDM surfaces satisfy the recorded interface pattern-language contract.");
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { apiFetch, apiPatchJson, apiPostJson, 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[];
|
||||||
|
total?: number;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
pages?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
governance_override_reason?: string | null;
|
||||||
|
governance_override_evidence?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IdmSettingsPayload = Partial<Pick<IdmSettings, "require_assignment_change_requests" | "audit_detail_level" | "change_retention_days" | "settings">>;
|
||||||
|
|
||||||
|
export type FunctionAssignmentChangeKind = "request" | "grant";
|
||||||
|
export type FunctionAssignmentChangeAction = "approve" | "reject" | "accept" | "request_changes" | "respond" | "withdraw" | "recover";
|
||||||
|
|
||||||
|
export type FunctionAssignmentChangeEvent = {
|
||||||
|
id: string;
|
||||||
|
sequence: number;
|
||||||
|
action: string;
|
||||||
|
from_state?: string | null;
|
||||||
|
to_state: string;
|
||||||
|
actor_account_id?: string | null;
|
||||||
|
actor_identity_id?: string | null;
|
||||||
|
comment?: string | null;
|
||||||
|
evidence: string[];
|
||||||
|
policy_decision: Record<string, unknown>;
|
||||||
|
workflow_step_id?: string | null;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FunctionAssignmentChange = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
kind: FunctionAssignmentChangeKind;
|
||||||
|
state: string;
|
||||||
|
profile: string;
|
||||||
|
function_id: string;
|
||||||
|
organization_unit_id: string;
|
||||||
|
candidate_identity_id: string;
|
||||||
|
candidate_account_id?: string | null;
|
||||||
|
initiator_account_id: string;
|
||||||
|
initiator_identity_id?: string | null;
|
||||||
|
justification: string;
|
||||||
|
evidence: string[];
|
||||||
|
requested_valid_from?: string | null;
|
||||||
|
requested_valid_until?: string | null;
|
||||||
|
required_steps: string[];
|
||||||
|
completed_steps: string[];
|
||||||
|
policy_decision: Record<string, unknown>;
|
||||||
|
workflow_definition_revision?: number | null;
|
||||||
|
workflow_definition_hash?: string | null;
|
||||||
|
workflow_instance_id?: string | null;
|
||||||
|
resulting_assignment_id?: string | null;
|
||||||
|
expires_at?: string | null;
|
||||||
|
outcome_reason?: string | null;
|
||||||
|
resource_revision: number;
|
||||||
|
etag: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
events: FunctionAssignmentChangeEvent[];
|
||||||
|
available_actions: FunctionAssignmentChangeAction[];
|
||||||
|
availability_reason?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FunctionAssignmentChangeList = {
|
||||||
|
changes: FunctionAssignmentChange[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
pages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FunctionAssignmentChangePayload = {
|
||||||
|
kind: FunctionAssignmentChangeKind;
|
||||||
|
function_id: string;
|
||||||
|
candidate_identity_id: string;
|
||||||
|
candidate_account_id?: string | null;
|
||||||
|
justification: string;
|
||||||
|
evidence?: string[];
|
||||||
|
requested_valid_from?: string | null;
|
||||||
|
requested_valid_until?: string | null;
|
||||||
|
applies_to_subunits?: boolean;
|
||||||
|
assignment_source?: "governance" | "delegated";
|
||||||
|
represented_assignment_id?: string | null;
|
||||||
|
idempotency_key: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getOrganizationModel(settings: ApiSettings): Promise<OrganizationModel> {
|
||||||
|
return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrganizationFunctionAssignments(settings: ApiSettings): Promise<OrganizationFunctionAssignmentList> {
|
||||||
|
const pageSize = 500;
|
||||||
|
const assignments: OrganizationFunctionAssignmentItem[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for (let page = 1; ; page += 1) {
|
||||||
|
const response = await apiFetch<OrganizationFunctionAssignmentList>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/idm/organization-function-assignments?page=${page}&page_size=${pageSize}`
|
||||||
|
);
|
||||||
|
assignments.push(...response.assignments);
|
||||||
|
total = response.total ?? assignments.length;
|
||||||
|
if (page >= (response.pages ?? 1)) {
|
||||||
|
return {
|
||||||
|
assignments,
|
||||||
|
total,
|
||||||
|
page: 1,
|
||||||
|
page_size: assignments.length,
|
||||||
|
pages: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIdmSettings(settings: ApiSettings): Promise<IdmSettings> {
|
||||||
|
return apiFetch<IdmSettings>(settings, "/api/v1/idm/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchIdmSettings(settings: ApiSettings, payload: IdmSettingsPayload): Promise<IdmSettings> {
|
||||||
|
return apiPatchJson(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 apiPostJson(settings, "/api/v1/idm/organization-function-assignments", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchOrganizationFunctionAssignment(
|
||||||
|
settings: ApiSettings,
|
||||||
|
id: string,
|
||||||
|
payload: Partial<OrganizationFunctionAssignmentPayload>
|
||||||
|
): Promise<OrganizationFunctionAssignmentItem> {
|
||||||
|
return apiPatchJson(settings, `/api/v1/idm/organization-function-assignments/${encodeURIComponent(id)}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFunctionAssignmentChanges(settings: ApiSettings): Promise<FunctionAssignmentChangeList> {
|
||||||
|
return apiFetch<FunctionAssignmentChangeList>(settings, "/api/v1/idm/function-assignment-changes?page_size=200");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFunctionAssignmentChange(settings: ApiSettings, id: string): Promise<FunctionAssignmentChange> {
|
||||||
|
return apiFetch<FunctionAssignmentChange>(settings, `/api/v1/idm/function-assignment-changes/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFunctionAssignmentChange(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: FunctionAssignmentChangePayload
|
||||||
|
): Promise<FunctionAssignmentChange> {
|
||||||
|
return apiPostJson(settings, "/api/v1/idm/function-assignment-changes", payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actOnFunctionAssignmentChange(
|
||||||
|
settings: ApiSettings,
|
||||||
|
change: FunctionAssignmentChange,
|
||||||
|
action: FunctionAssignmentChangeAction,
|
||||||
|
comment?: string
|
||||||
|
): Promise<FunctionAssignmentChange> {
|
||||||
|
return apiFetch<FunctionAssignmentChange>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/idm/function-assignment-changes/${encodeURIComponent(change.id)}/actions`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "If-Match": change.etag },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action,
|
||||||
|
base_revision: change.resource_revision,
|
||||||
|
comment: comment?.trim() || null,
|
||||||
|
evidence: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||||
|
import { Check, Eye, Plus, RotateCcw, Undo2, X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
AdminIconButton,
|
||||||
|
ApiError,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
ConfirmDialog,
|
||||||
|
DataGrid,
|
||||||
|
Dialog,
|
||||||
|
DismissibleAlert,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
FormField,
|
||||||
|
LoadingFrame,
|
||||||
|
SegmentedControl,
|
||||||
|
StatusBadge,
|
||||||
|
TableActionGroup,
|
||||||
|
hasScope,
|
||||||
|
i18nMessage,
|
||||||
|
usePlatformLanguage,
|
||||||
|
useUnsavedChanges,
|
||||||
|
useUnsavedDraftGuard,
|
||||||
|
type ApiSettings,
|
||||||
|
type AuthInfo,
|
||||||
|
type DataGridColumn
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
actOnFunctionAssignmentChange,
|
||||||
|
createFunctionAssignmentChange,
|
||||||
|
getFunctionAssignmentChange,
|
||||||
|
getFunctionAssignmentChanges,
|
||||||
|
type FunctionAssignmentChange,
|
||||||
|
type FunctionAssignmentChangeAction,
|
||||||
|
type FunctionAssignmentChangeKind,
|
||||||
|
type IdentityOption,
|
||||||
|
type OrganizationModel
|
||||||
|
} from "../api/idm";
|
||||||
|
import {
|
||||||
|
IDM_FIELD_DOCUMENTATION,
|
||||||
|
IDM_GOVERNANCE_DOCUMENTATION,
|
||||||
|
IDM_INTERFACE_I18N,
|
||||||
|
idmDisabledReason
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
settings: ApiSettings;
|
||||||
|
auth: AuthInfo;
|
||||||
|
model: OrganizationModel;
|
||||||
|
identities: IdentityOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Draft = {
|
||||||
|
kind: FunctionAssignmentChangeKind;
|
||||||
|
functionId: string;
|
||||||
|
identityId: string;
|
||||||
|
accountId: string;
|
||||||
|
justification: string;
|
||||||
|
evidence: string;
|
||||||
|
validFrom: string;
|
||||||
|
validUntil: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function emptyDraft(auth: AuthInfo, kind: FunctionAssignmentChangeKind): Draft {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
functionId: "",
|
||||||
|
identityId: kind === "request" ? auth.principal?.identity_id ?? "" : "",
|
||||||
|
accountId: kind === "request" ? auth.principal?.account_id ?? "" : "",
|
||||||
|
justification: "",
|
||||||
|
evidence: "",
|
||||||
|
validFrom: "",
|
||||||
|
validUntil: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(error.body) as { detail?: string | { message?: string } };
|
||||||
|
if (typeof body.detail === "string") return body.detail;
|
||||||
|
if (body.detail?.message) return body.detail.message;
|
||||||
|
} catch {
|
||||||
|
// Use the transport message.
|
||||||
|
}
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateTimeValue(value: string): string | null {
|
||||||
|
return value ? new Date(value).toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(state: string): string {
|
||||||
|
if (state === "applied") return "success";
|
||||||
|
if (["rejected", "expired", "withdrawn", "cancelled"].includes(state)) return "inactive";
|
||||||
|
if (["blocked", "failed_manual_review"].includes(state)) return "danger";
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOMAIN_LABELS: Record<string, string> = {
|
||||||
|
request: "i18n:govoplan-idm.kind_request",
|
||||||
|
grant: "i18n:govoplan-idm.kind_grant",
|
||||||
|
submitted: "i18n:govoplan-idm.state_submitted",
|
||||||
|
awaiting_holder: "i18n:govoplan-idm.state_awaiting_holder",
|
||||||
|
awaiting_authority: "i18n:govoplan-idm.state_awaiting_authority",
|
||||||
|
awaiting_recipient: "i18n:govoplan-idm.state_awaiting_recipient",
|
||||||
|
changes_requested: "i18n:govoplan-idm.state_changes_requested",
|
||||||
|
applied: "i18n:govoplan-idm.state_applied",
|
||||||
|
rejected: "i18n:govoplan-idm.state_rejected",
|
||||||
|
expired: "i18n:govoplan-idm.state_expired",
|
||||||
|
withdrawn: "i18n:govoplan-idm.state_withdrawn",
|
||||||
|
cancelled: "i18n:govoplan-idm.state_cancelled",
|
||||||
|
blocked: "i18n:govoplan-idm.state_blocked",
|
||||||
|
failed_manual_review: "i18n:govoplan-idm.state_failed_manual_review",
|
||||||
|
approve_holder: "i18n:govoplan-idm.step_approve_holder",
|
||||||
|
approve_authority: "i18n:govoplan-idm.step_approve_authority",
|
||||||
|
accept_recipient: "i18n:govoplan-idm.step_accept_recipient",
|
||||||
|
holder_review: "i18n:govoplan-idm.profile_holder_review",
|
||||||
|
authority_review: "i18n:govoplan-idm.profile_authority_review",
|
||||||
|
recipient_review: "i18n:govoplan-idm.profile_recipient_review",
|
||||||
|
approve: "i18n:govoplan-idm.action_approve",
|
||||||
|
reject: "i18n:govoplan-idm.action_reject",
|
||||||
|
accept: "i18n:govoplan-idm.action_accept",
|
||||||
|
request_changes: "i18n:govoplan-idm.action_request_changes",
|
||||||
|
respond: "i18n:govoplan-idm.action_respond",
|
||||||
|
withdraw: "i18n:govoplan-idm.action_withdraw",
|
||||||
|
recover: "i18n:govoplan-idm.action_recheck"
|
||||||
|
};
|
||||||
|
|
||||||
|
function domainLabel(value: string): string {
|
||||||
|
return DOMAIN_LABELS[value] ?? value.replaceAll("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(action: FunctionAssignmentChangeAction): string {
|
||||||
|
return {
|
||||||
|
approve: "i18n:govoplan-idm.action_approve",
|
||||||
|
reject: "i18n:govoplan-idm.action_reject",
|
||||||
|
accept: "i18n:govoplan-idm.action_accept",
|
||||||
|
request_changes: "i18n:govoplan-idm.action_request_changes",
|
||||||
|
respond: "i18n:govoplan-idm.action_respond",
|
||||||
|
withdraw: "i18n:govoplan-idm.action_withdraw",
|
||||||
|
recover: "i18n:govoplan-idm.action_recheck"
|
||||||
|
}[action];
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionIcon(action: FunctionAssignmentChangeAction): JSX.Element {
|
||||||
|
const props = { size: 16, "aria-hidden": true as const };
|
||||||
|
if (action === "approve" || action === "accept") return <Check {...props} />;
|
||||||
|
if (action === "reject") return <X {...props} />;
|
||||||
|
if (action === "request_changes") return <Undo2 {...props} />;
|
||||||
|
if (action === "recover") return <RotateCcw {...props} />;
|
||||||
|
return <Undo2 {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FunctionAssignmentChangesPanel({ settings, auth, model, identities }: Props) {
|
||||||
|
const canRequest = hasScope(auth, "idm:function_request:create");
|
||||||
|
const canGrant = hasScope(auth, "idm:function_grant:create") || hasScope(auth, "idm:organization_assignment:write");
|
||||||
|
const visible = canRequest || canGrant || hasScope(auth, "idm:function_change:read") || hasScope(auth, "idm:function_change:decide") || hasScope(auth, "idm:function_change:admin");
|
||||||
|
const initialKind: FunctionAssignmentChangeKind = canRequest ? "request" : "grant";
|
||||||
|
const [changes, setChanges] = useState<FunctionAssignmentChange[]>([]);
|
||||||
|
const [draft, setDraft] = useState<Draft>(() => emptyDraft(auth, initialKind));
|
||||||
|
const [draftBaseline, setDraftBaseline] = useState<Draft>(() => emptyDraft(auth, initialKind));
|
||||||
|
const [selected, setSelected] = useState<FunctionAssignmentChange | null>(null);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [pendingAction, setPendingAction] = useState<FunctionAssignmentChangeAction | null>(null);
|
||||||
|
const { language } = usePlatformLanguage();
|
||||||
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
|
const initialChangeId = useMemo(() => {
|
||||||
|
if (typeof window === "undefined") return "";
|
||||||
|
return new URLSearchParams(window.location.search).get("change") ?? "";
|
||||||
|
}, []);
|
||||||
|
const initialChangeOpened = useRef(false);
|
||||||
|
const functionById = useMemo(() => new Map(model.functions.map((item) => [item.id, item])), [model.functions]);
|
||||||
|
const identityById = useMemo(() => new Map(identities.map((item) => [item.id, item])), [identities]);
|
||||||
|
const selectedIdentity = identityById.get(draft.identityId);
|
||||||
|
const draftDirty = createOpen && draftKey(draft) !== draftKey(draftBaseline);
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty: draftDirty,
|
||||||
|
title: "Unsaved function change",
|
||||||
|
message: "Save or discard the function request or grant before leaving this surface.",
|
||||||
|
onSave: submit,
|
||||||
|
onDiscard: discardCreateDraft
|
||||||
|
});
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!visible) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await getFunctionAssignmentChanges(settings);
|
||||||
|
setChanges(response.changes);
|
||||||
|
if (initialChangeId && !initialChangeOpened.current) {
|
||||||
|
initialChangeOpened.current = true;
|
||||||
|
const detail = await getFunctionAssignmentChange(settings, initialChangeId);
|
||||||
|
setSelected(detail);
|
||||||
|
setDetailOpen(true);
|
||||||
|
}
|
||||||
|
} catch (caught) {
|
||||||
|
setError(errorMessage(caught));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [initialChangeId, settings, visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
function setKind(kind: FunctionAssignmentChangeKind) {
|
||||||
|
const next = emptyDraft(auth, kind);
|
||||||
|
setDraft(next);
|
||||||
|
setDraftBaseline(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
const next = emptyDraft(auth, initialKind);
|
||||||
|
setDraft(next);
|
||||||
|
setDraftBaseline(next);
|
||||||
|
setCreateOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardCreateDraft() {
|
||||||
|
const next = emptyDraft(auth, initialKind);
|
||||||
|
setDraft(next);
|
||||||
|
setDraftBaseline(next);
|
||||||
|
setCreateOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreate() {
|
||||||
|
if (busy) return;
|
||||||
|
if (draftDirty) requestDiscard(discardCreateDraft);
|
||||||
|
else discardCreateDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setIdentity(identityId: string) {
|
||||||
|
const identity = identityById.get(identityId);
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
identityId,
|
||||||
|
accountId: identity?.primary_account_id ?? identity?.account_ids[0] ?? ""
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(item: FunctionAssignmentChange) {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const detail = await getFunctionAssignmentChange(settings, item.id);
|
||||||
|
setSelected(detail);
|
||||||
|
setComment("");
|
||||||
|
setDetailOpen(true);
|
||||||
|
} catch (caught) {
|
||||||
|
setError(errorMessage(caught));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(event?: FormEvent): Promise<boolean> {
|
||||||
|
event?.preventDefault();
|
||||||
|
if (!draft.functionId || !draft.identityId || !draft.justification.trim()) return false;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const created = await createFunctionAssignmentChange(settings, {
|
||||||
|
kind: draft.kind,
|
||||||
|
function_id: draft.functionId,
|
||||||
|
candidate_identity_id: draft.identityId,
|
||||||
|
candidate_account_id: draft.accountId || null,
|
||||||
|
justification: draft.justification.trim(),
|
||||||
|
evidence: draft.evidence.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||||
|
requested_valid_from: dateTimeValue(draft.validFrom),
|
||||||
|
requested_valid_until: dateTimeValue(draft.validUntil),
|
||||||
|
idempotency_key: crypto.randomUUID(),
|
||||||
|
metadata: {}
|
||||||
|
});
|
||||||
|
setCreateOpen(false);
|
||||||
|
const next = emptyDraft(auth, initialKind);
|
||||||
|
setDraft(next);
|
||||||
|
setDraftBaseline(next);
|
||||||
|
setSelected(created);
|
||||||
|
setDetailOpen(true);
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
} catch (caught) {
|
||||||
|
setError(errorMessage(caught));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performAction(action: FunctionAssignmentChangeAction) {
|
||||||
|
if (!selected) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const updated = await actOnFunctionAssignmentChange(settings, selected, action, comment);
|
||||||
|
setSelected(updated);
|
||||||
|
setComment("");
|
||||||
|
await load();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(errorMessage(caught));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: DataGridColumn<FunctionAssignmentChange>[] = [
|
||||||
|
{ id: "kind", header: "Kind", width: 110, sortable: true, filterable: true, value: (row) => row.kind, render: (row) => domainLabel(row.kind) },
|
||||||
|
{
|
||||||
|
id: "function",
|
||||||
|
header: "Function",
|
||||||
|
minWidth: 220,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (row) => functionById.get(row.function_id)?.name ?? row.function_id,
|
||||||
|
render: (row) => functionById.get(row.function_id)?.name ?? row.function_id
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "candidate",
|
||||||
|
header: "Candidate",
|
||||||
|
minWidth: 220,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (row) => identityById.get(row.candidate_identity_id)?.display_name ?? row.candidate_identity_id,
|
||||||
|
render: (row) => identityById.get(row.candidate_identity_id)?.display_name ?? row.candidate_identity_id
|
||||||
|
},
|
||||||
|
{ id: "state", header: "State", width: 170, sortable: true, filterable: true, value: (row) => row.state, render: (row) => <StatusBadge status={statusTone(row.state)} label={domainLabel(row.state)} /> },
|
||||||
|
{ id: "progress", header: "Decisions", width: 140, value: (row) => `${row.completed_steps.length}/${row.required_steps.length}`, render: (row) => `${row.completed_steps.length} / ${row.required_steps.length}` },
|
||||||
|
{ id: "updated", header: "Updated", width: 170, sortable: true, value: (row) => row.updated_at, render: (row) => new Date(row.updated_at).toLocaleString(language) },
|
||||||
|
{ id: "actions", header: "Actions", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "view", label: "Open change", icon: <Eye size={16} aria-hidden="true" />, onClick: () => void openDetail(row) }]} /> }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
<Card
|
||||||
|
title="Function requests and grants"
|
||||||
|
collapsible
|
||||||
|
collapseKey="idm.function-assignment-changes"
|
||||||
|
actions={(
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<DocumentationHelpLink reference={IDM_GOVERNANCE_DOCUMENTATION} />
|
||||||
|
{(canRequest || canGrant) ? <AdminIconButton label="Start governed change" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={busy} disabledReason={idmDisabledReason(false, busy)} onClick={openCreate} /> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LoadingFrame loading={loading} label="Loading governed function changes">
|
||||||
|
<DataGrid id="idm-function-assignment-changes" rows={changes} columns={columns} getRowKey={(row) => row.id} emptyText="No governed function changes" initialFit="container" />
|
||||||
|
</LoadingFrame>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={createOpen}
|
||||||
|
title="Start governed function change"
|
||||||
|
className="admin-dialog admin-dialog-wide idm-change-dialog"
|
||||||
|
onClose={closeCreate}
|
||||||
|
closeDisabled={busy}
|
||||||
|
footer={<><Button type="button" onClick={closeCreate} disabled={busy} disabledReason={idmDisabledReason(false, busy)}>Cancel</Button><Button type="submit" form="idm-change-create" variant="primary" disabled={busy || !draft.functionId || !draft.identityId || !draft.justification.trim()} disabledReason={idmDisabledReason(false, busy) ?? ((!draft.functionId || !draft.identityId || !draft.justification.trim()) ? IDM_INTERFACE_I18N.incomplete : undefined)}>Submit</Button></>}
|
||||||
|
>
|
||||||
|
<form id="idm-change-create" className="admin-form-grid two-columns" onSubmit={(event) => void submit(event)}>
|
||||||
|
<div className="wide">
|
||||||
|
<SegmentedControl
|
||||||
|
ariaLabel="Function change kind"
|
||||||
|
value={draft.kind}
|
||||||
|
onChange={setKind}
|
||||||
|
options={[
|
||||||
|
{ id: "request", label: "Request function", disabled: !canRequest },
|
||||||
|
{ id: "grant", label: "Grant function", disabled: !canGrant }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField label="Function" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<select value={draft.functionId} onChange={(event) => setDraft((current) => ({ ...current, functionId: event.target.value }))} disabled={busy}>
|
||||||
|
<option value="">Select function</option>
|
||||||
|
{model.functions.filter((item) => item.is_active).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Candidate identity" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<select value={draft.identityId} onChange={(event) => setIdentity(event.target.value)} disabled={busy || draft.kind === "request"}>
|
||||||
|
<option value="">Select identity</option>
|
||||||
|
{identities.map((item) => <option key={item.id} value={item.id}>{item.display_name || item.external_subject || item.id}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Candidate account" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<select value={draft.accountId} onChange={(event) => setDraft((current) => ({ ...current, accountId: event.target.value }))} disabled={busy}>
|
||||||
|
<option value="">No linked account</option>
|
||||||
|
{(selectedIdentity?.account_ids ?? []).map((accountId) => <option key={accountId} value={accountId}>{accountId}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Valid from" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<input type="datetime-local" value={draft.validFrom} onChange={(event) => setDraft((current) => ({ ...current, validFrom: event.target.value }))} disabled={busy} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Valid until" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<input type="datetime-local" value={draft.validUntil} onChange={(event) => setDraft((current) => ({ ...current, validUntil: event.target.value }))} disabled={busy} />
|
||||||
|
</FormField>
|
||||||
|
<div className="wide">
|
||||||
|
<FormField label="Justification" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<textarea rows={4} value={draft.justification} onChange={(event) => setDraft((current) => ({ ...current, justification: event.target.value }))} disabled={busy} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="wide">
|
||||||
|
<FormField label="Evidence references (one per line)" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<textarea rows={3} value={draft.evidence} onChange={(event) => setDraft((current) => ({ ...current, evidence: event.target.value }))} disabled={busy} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={detailOpen && selected !== null}
|
||||||
|
title={selected
|
||||||
|
? i18nMessage(
|
||||||
|
selected.kind === "request"
|
||||||
|
? "i18n:govoplan-idm.function_request_title"
|
||||||
|
: "i18n:govoplan-idm.function_grant_title",
|
||||||
|
{ function: functionById.get(selected.function_id)?.name ?? selected.function_id }
|
||||||
|
)
|
||||||
|
: "Function change"}
|
||||||
|
className="admin-dialog admin-dialog-wide idm-change-dialog"
|
||||||
|
onClose={() => !busy && setDetailOpen(false)}
|
||||||
|
closeDisabled={busy}
|
||||||
|
footer={<Button type="button" onClick={() => setDetailOpen(false)} disabled={busy} disabledReason={idmDisabledReason(false, busy)}>Close</Button>}
|
||||||
|
>
|
||||||
|
{selected && (
|
||||||
|
<div className="idm-change-detail">
|
||||||
|
<dl className="idm-change-summary">
|
||||||
|
<div><dt>State</dt><dd><StatusBadge status={statusTone(selected.state)} label={domainLabel(selected.state)} /></dd></div>
|
||||||
|
<div><dt>Candidate</dt><dd>{identityById.get(selected.candidate_identity_id)?.display_name ?? selected.candidate_identity_id}</dd></div>
|
||||||
|
<div><dt>Profile</dt><dd>{domainLabel(selected.profile)}</dd></div>
|
||||||
|
<div><dt>Workflow revision</dt><dd>{selected.workflow_definition_revision ?? "-"}</dd></div>
|
||||||
|
<div><dt>Required decisions</dt><dd>{selected.required_steps.map(domainLabel).join(", ") || "None"}</dd></div>
|
||||||
|
<div><dt>Completed decisions</dt><dd>{selected.completed_steps.map(domainLabel).join(", ") || "None"}</dd></div>
|
||||||
|
<div className="wide"><dt>Justification</dt><dd>{selected.justification}</dd></div>
|
||||||
|
{selected.outcome_reason && <div className="wide"><dt>Explanation</dt><dd>{selected.outcome_reason}</dd></div>}
|
||||||
|
</dl>
|
||||||
|
{selected.available_actions.length > 0 && (
|
||||||
|
<div className="idm-change-actions">
|
||||||
|
<FormField label="Decision comment" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<textarea rows={2} value={comment} onChange={(event) => setComment(event.target.value)} disabled={busy} />
|
||||||
|
</FormField>
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
{selected.available_actions.map((action) => (
|
||||||
|
<Button key={action} type="button" variant={action === "reject" ? "danger" : action === "approve" || action === "accept" ? "primary" : "secondary"} disabled={busy} disabledReason={idmDisabledReason(false, busy)} onClick={() => setPendingAction(action)}>
|
||||||
|
{actionIcon(action)} {actionLabel(action)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selected.availability_reason && selected.available_actions.length === 0 && <p className="idm-muted">{selected.availability_reason}</p>}
|
||||||
|
<div>
|
||||||
|
<h3>History</h3>
|
||||||
|
<ol className="idm-change-history">
|
||||||
|
{selected.events.map((event) => <li key={event.id}><strong>{domainLabel(event.action)}</strong><span>{new Date(event.created_at).toLocaleString(language)}</span><span>{event.from_state ? `${domainLabel(event.from_state)} -> ` : ""}{domainLabel(event.to_state)}</span>{event.comment && <p>{event.comment}</p>}</li>)}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(pendingAction && selected)}
|
||||||
|
title="Confirm function decision"
|
||||||
|
message={pendingAction && selected
|
||||||
|
? i18nMessage("i18n:govoplan-idm.confirm_function_action", {
|
||||||
|
action: actionLabel(pendingAction),
|
||||||
|
function: functionById.get(selected.function_id)?.name ?? selected.function_id
|
||||||
|
})
|
||||||
|
: ""}
|
||||||
|
confirmLabel={pendingAction ? actionLabel(pendingAction) : "Confirm"}
|
||||||
|
tone={pendingAction === "reject" || pendingAction === "withdraw" ? "danger" : "default"}
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (!pendingAction) return;
|
||||||
|
const action = pendingAction;
|
||||||
|
setPendingAction(null);
|
||||||
|
void performAction(action);
|
||||||
|
}}
|
||||||
|
onCancel={() => setPendingAction(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftKey(value: unknown): string {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,897 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||||
|
import { Edit3, Plus, RefreshCw } from "lucide-react";
|
||||||
|
import {
|
||||||
|
ActionBlockerHint,
|
||||||
|
AdminIconButton,
|
||||||
|
ApiError,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataGrid,
|
||||||
|
Dialog,
|
||||||
|
DismissibleAlert,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
FormField,
|
||||||
|
LoadingFrame,
|
||||||
|
PageScrollViewport,
|
||||||
|
PageTitle,
|
||||||
|
StatusBadge,
|
||||||
|
TableActionGroup,
|
||||||
|
ToggleSwitch,
|
||||||
|
hasScope,
|
||||||
|
useUnsavedChanges,
|
||||||
|
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";
|
||||||
|
import FunctionAssignmentChangesPanel from "./FunctionAssignmentChangesPanel";
|
||||||
|
import {
|
||||||
|
IDM_DOCUMENTATION,
|
||||||
|
IDM_FIELD_DOCUMENTATION,
|
||||||
|
IDM_GOVERNANCE_DOCUMENTATION,
|
||||||
|
IDM_INTERFACE_I18N,
|
||||||
|
idmDisabledReason
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
|
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;
|
||||||
|
governance_override_reason: string;
|
||||||
|
governance_override_evidence: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
governance_override_reason: "",
|
||||||
|
governance_override_evidence: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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: {},
|
||||||
|
governance_override_reason: textOrNull(draft.governance_override_reason),
|
||||||
|
governance_override_evidence: draft.governance_override_evidence
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
governance_override_reason: "",
|
||||||
|
governance_override_evidence: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGovernedFunction(item: OrganizationFunctionItem | undefined): boolean {
|
||||||
|
const governance = item?.settings.assignment_governance;
|
||||||
|
if (!governance || typeof governance !== "object" || Array.isArray(governance)) return false;
|
||||||
|
const values = governance as Record<string, unknown>;
|
||||||
|
return ["request_profile", "grant_profile"].some((key) => {
|
||||||
|
const value = String(values[key] ?? "unavailable").trim().toLowerCase();
|
||||||
|
return Boolean(value && value !== "unavailable");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function idmInitialQuery(): { assignmentId: string; functionId: string } {
|
||||||
|
if (typeof window === "undefined") return { assignmentId: "", functionId: "" };
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return {
|
||||||
|
assignmentId: params.get("assignment_id") || "",
|
||||||
|
functionId: params.get("function_id") || ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [assignmentBaseline, setAssignmentBaseline] = useState<AssignmentDraft>(() => emptyAssignmentDraft());
|
||||||
|
const [settingsDraft, setSettingsDraft] = useState<SettingsDraft>(() => settingsDraftFrom(null));
|
||||||
|
const [editingAssignmentId, setEditingAssignmentId] = useState<string | null>(null);
|
||||||
|
const [assignmentEditorOpen, setAssignmentEditorOpen] = useState(false);
|
||||||
|
const [assignmentChangeRequestId, setAssignmentChangeRequestId] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
const initialQuery = useMemo(() => idmInitialQuery(), []);
|
||||||
|
const appliedInitialQueryRef = useRef(false);
|
||||||
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
|
|
||||||
|
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 selectedFunctionIsGoverned = isGovernedFunction(functionById.get(assignmentDraft.function_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 = initialQuery.functionId;
|
||||||
|
const hasDirtyAssignmentDraft = assignmentEditorOpen && draftKey(assignmentDraft) !== draftKey(assignmentBaseline);
|
||||||
|
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 (!appliedInitialQueryRef.current && initialQuery.assignmentId) {
|
||||||
|
const initialAssignment = nextAssignments.assignments.find((item) => item.id === initialQuery.assignmentId);
|
||||||
|
if (initialAssignment) {
|
||||||
|
appliedInitialQueryRef.current = true;
|
||||||
|
setEditingAssignmentId(initialAssignment.id);
|
||||||
|
const nextDraft = assignmentDraftFrom(initialAssignment);
|
||||||
|
setAssignmentDraft(nextDraft);
|
||||||
|
setAssignmentBaseline(nextDraft);
|
||||||
|
setAssignmentEditorOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, initialQuery.assignmentId, 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(() => {
|
||||||
|
const nextDraft = emptyAssignmentDraft();
|
||||||
|
setAssignmentDraft(nextDraft);
|
||||||
|
setAssignmentBaseline(nextDraft);
|
||||||
|
setEditingAssignmentId(null);
|
||||||
|
setAssignmentEditorOpen(false);
|
||||||
|
setAssignmentChangeRequestId("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (selectedFunctionIsGoverned && !assignmentDraft.governance_override_reason.trim()) {
|
||||||
|
setError("Direct changes to this governed function require an emergency override reason.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const requestId = textOrNull(assignmentChangeRequestId);
|
||||||
|
const payload = requestId ? { ...assignmentPayload(assignmentDraft), change_request_id: requestId } : 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;
|
||||||
|
}, [assignmentChangeRequestId, assignmentDraft, canManage, discardAssignmentDraft, editingAssignmentId, runAction, selectedFunctionIsGoverned, 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 openCreateAssignment = useCallback(() => {
|
||||||
|
setEditingAssignmentId(null);
|
||||||
|
const nextDraft = { ...emptyAssignmentDraft(), function_id: initialFunctionFilter && functionById.has(initialFunctionFilter) ? initialFunctionFilter : "" };
|
||||||
|
setAssignmentDraft(nextDraft);
|
||||||
|
setAssignmentBaseline(nextDraft);
|
||||||
|
setAssignmentChangeRequestId("");
|
||||||
|
setAssignmentEditorOpen(true);
|
||||||
|
}, [functionById, initialFunctionFilter]);
|
||||||
|
|
||||||
|
const editAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => {
|
||||||
|
const nextDraft = assignmentDraftFrom(item);
|
||||||
|
setEditingAssignmentId(item.id);
|
||||||
|
setAssignmentDraft(nextDraft);
|
||||||
|
setAssignmentBaseline(nextDraft);
|
||||||
|
setAssignmentChangeRequestId("");
|
||||||
|
setAssignmentEditorOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeAssignmentEditor = useCallback(() => {
|
||||||
|
if (busy) return;
|
||||||
|
if (hasDirtyAssignmentDraft) requestDiscard(discardAssignmentDraft);
|
||||||
|
else discardAssignmentDraft();
|
||||||
|
}, [busy, discardAssignmentDraft, hasDirtyAssignmentDraft, requestDiscard]);
|
||||||
|
|
||||||
|
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: "Actions",
|
||||||
|
width: 72,
|
||||||
|
sticky: "end",
|
||||||
|
render: (row) => <TableActionGroup actions={[{
|
||||||
|
id: "edit",
|
||||||
|
label: "i18n:govoplan-idm.edit.a5a0f3cc",
|
||||||
|
icon: <Edit3 size={16} aria-hidden="true" />,
|
||||||
|
disabled: !canManage || busy,
|
||||||
|
disabledReason: idmDisabledReason(false, busy, canManage),
|
||||||
|
onClick: () => editAssignment(row)
|
||||||
|
}]} />
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageScrollViewport>
|
||||||
|
<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">
|
||||||
|
<DocumentationHelpLink reference={IDM_DOCUMENTATION} />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => requestDiscard(() => void loadData())}
|
||||||
|
disabled={loading || busy}
|
||||||
|
disabledReason={idmDisabledReason(loading, busy)}
|
||||||
|
title="i18n:govoplan-idm.reload.870ca3ec"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-idm.reload.870ca3ec
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
|
{success && !error && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||||
|
{!canManage && (
|
||||||
|
<ActionBlockerHint
|
||||||
|
reason={{
|
||||||
|
summary: IDM_INTERFACE_I18N.writeReason,
|
||||||
|
requiredAction: IDM_INTERFACE_I18N.permissionAction,
|
||||||
|
actor: IDM_INTERFACE_I18N.permissionActor,
|
||||||
|
target: IDM_INTERFACE_I18N.permissionDestination
|
||||||
|
}}
|
||||||
|
labels={{
|
||||||
|
requiredAction: IDM_INTERFACE_I18N.requiredAction,
|
||||||
|
actor: IDM_INTERFACE_I18N.actor,
|
||||||
|
target: IDM_INTERFACE_I18N.destination
|
||||||
|
}}
|
||||||
|
documentation={IDM_DOCUMENTATION}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!model.functions.length && !loading && (
|
||||||
|
<ActionBlockerHint
|
||||||
|
reason={{
|
||||||
|
summary: IDM_INTERFACE_I18N.noFunctions,
|
||||||
|
requiredAction: IDM_INTERFACE_I18N.functionAction,
|
||||||
|
actor: IDM_INTERFACE_I18N.functionActor,
|
||||||
|
target: IDM_INTERFACE_I18N.functionDestination
|
||||||
|
}}
|
||||||
|
labels={{
|
||||||
|
requiredAction: IDM_INTERFACE_I18N.requiredAction,
|
||||||
|
actor: IDM_INTERFACE_I18N.actor,
|
||||||
|
target: IDM_INTERFACE_I18N.destination
|
||||||
|
}}
|
||||||
|
documentation={IDM_DOCUMENTATION}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LoadingFrame loading={loading || busy} label="i18n:govoplan-idm.loading_idm_assignments.0b1501bd">
|
||||||
|
<div className="idm-table-stack">
|
||||||
|
{canReadSettings && (
|
||||||
|
<Card
|
||||||
|
title="i18n:govoplan-idm.idm_governance.6e4f3251"
|
||||||
|
collapsible
|
||||||
|
collapseKey="idm.governance"
|
||||||
|
actions={<DocumentationHelpLink reference={IDM_GOVERNANCE_DOCUMENTATION} />}
|
||||||
|
>
|
||||||
|
<form className="admin-form-grid two-columns" onSubmit={(event) => { event.preventDefault(); void submitSettings(); }}>
|
||||||
|
<div className="idm-check-list wide">
|
||||||
|
<ToggleSwitch label="i18n:govoplan-idm.require_assignment_change_requests.697718a1" checked={settingsDraft.require_assignment_change_requests} disabled={!canManageSettings || busy} help={idmDisabledReason(false, busy, canManageSettings)} onChange={(require_assignment_change_requests) => setSettingsDraft({ ...settingsDraft, require_assignment_change_requests })} />
|
||||||
|
</div>
|
||||||
|
<FormField label="i18n:govoplan-idm.audit_detail_level.eb2e6fd2" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<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" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<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="button-row compact-actions wide">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={!canManageSettings || busy || !hasDirtySettingsDraft}
|
||||||
|
disabledReason={idmDisabledReason(false, busy, canManageSettings) ?? (!hasDirtySettingsDraft ? IDM_INTERFACE_I18N.noChanges : undefined)}
|
||||||
|
>
|
||||||
|
i18n:govoplan-idm.save_settings.4602c430
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FunctionAssignmentChangesPanel
|
||||||
|
settings={settings}
|
||||||
|
auth={auth}
|
||||||
|
model={model}
|
||||||
|
identities={identityOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card title="i18n:govoplan-idm.assignments.a0d19ec5" collapsible collapseKey="idm.assignments" actions={<AdminIconButton label="i18n:govoplan-idm.add_assignment.08f2a0d5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canManage || busy || !model.functions.length} disabledReason={idmDisabledReason(false, busy, canManage) ?? (!model.functions.length ? IDM_INTERFACE_I18N.noFunctions : undefined)} onClick={openCreateAssignment} />}>
|
||||||
|
<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>
|
||||||
|
{renderAssignmentDialog()}
|
||||||
|
</div>
|
||||||
|
</PageScrollViewport>
|
||||||
|
);
|
||||||
|
|
||||||
|
function renderAssignmentDialog() {
|
||||||
|
const formId = "idm-assignment-editor";
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={assignmentEditorOpen}
|
||||||
|
title={editingAssignmentId ? "i18n:govoplan-idm.update_assignment.e20f52aa" : "i18n:govoplan-idm.add_assignment.08f2a0d5"}
|
||||||
|
onClose={closeAssignmentEditor}
|
||||||
|
closeDisabled={busy}
|
||||||
|
className="admin-dialog admin-dialog-wide idm-editor-dialog"
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<Button type="button" onClick={closeAssignmentEditor} disabled={busy} disabledReason={idmDisabledReason(false, busy)}>i18n:govoplan-idm.cancel_edit.ea4781e0</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form={formId}
|
||||||
|
variant="primary"
|
||||||
|
disabled={!canManage || busy || !assignmentDraft.identity_id || !assignmentDraft.function_id || (selectedFunctionIsGoverned && !assignmentDraft.governance_override_reason.trim())}
|
||||||
|
disabledReason={idmDisabledReason(false, busy, canManage) ?? ((!assignmentDraft.identity_id || !assignmentDraft.function_id || (selectedFunctionIsGoverned && !assignmentDraft.governance_override_reason.trim())) ? IDM_INTERFACE_I18N.incomplete : undefined)}
|
||||||
|
>
|
||||||
|
{editingAssignmentId ? "i18n:govoplan-idm.update_assignment.e20f52aa" : "i18n:govoplan-idm.add_assignment.08f2a0d5"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<form id={formId} className="admin-form-grid two-columns" onSubmit={(event) => void submitAssignment(event)}>
|
||||||
|
<FormField label="i18n:govoplan-idm.identity_search.d3460fcf" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<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" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<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" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<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" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<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" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<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" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<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" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<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" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<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">
|
||||||
|
<ToggleSwitch label="i18n:govoplan-idm.applies_to_subunits.2e31b50b" checked={assignmentDraft.applies_to_subunits} disabled={!canManage || busy} help={idmDisabledReason(false, busy, canManage)} onChange={(applies_to_subunits) => setAssignmentDraft({ ...assignmentDraft, applies_to_subunits })} />
|
||||||
|
<ToggleSwitch label="i18n:govoplan-idm.active.7bd0e9f8" checked={assignmentDraft.is_active} disabled={!canManage || busy} help={idmDisabledReason(false, busy, canManage)} onChange={(is_active) => setAssignmentDraft({ ...assignmentDraft, is_active })} />
|
||||||
|
</div>
|
||||||
|
{selectedFunctionIsGoverned && (
|
||||||
|
<div className="wide idm-governance-override">
|
||||||
|
<DismissibleAlert tone="warning" dismissible={false}>
|
||||||
|
Direct changes to this governed function are emergency overrides. Use a request or grant above for the normal process.
|
||||||
|
<DocumentationHelpLink reference={IDM_GOVERNANCE_DOCUMENTATION} />
|
||||||
|
</DismissibleAlert>
|
||||||
|
<FormField label="Emergency override reason" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={assignmentDraft.governance_override_reason}
|
||||||
|
onChange={(event) => setAssignmentDraft({ ...assignmentDraft, governance_override_reason: event.target.value })}
|
||||||
|
disabled={!canManage || busy}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Override evidence references (one per line)" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={assignmentDraft.governance_override_evidence}
|
||||||
|
onChange={(event) => setAssignmentDraft({ ...assignmentDraft, governance_override_evidence: event.target.value })}
|
||||||
|
disabled={!canManage || busy}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="wide idm-dialog-change-request">
|
||||||
|
<FormField label="i18n:govoplan-idm.change_request_id.b7d816db" documentation={IDM_FIELD_DOCUMENTATION}>
|
||||||
|
<input value={assignmentChangeRequestId} onChange={(event) => setAssignmentChangeRequestId(event.target.value)} placeholder="cfgreq-..." disabled={busy} />
|
||||||
|
</FormField>
|
||||||
|
<p className="idm-muted">i18n:govoplan-idm.change_request_id_help.cc7de508</p>
|
||||||
|
</div>
|
||||||
|
{!identityLookupAvailable && (
|
||||||
|
<div className="wide">
|
||||||
|
<ActionBlockerHint
|
||||||
|
reason={{
|
||||||
|
summary: "i18n:govoplan-idm.identity_lookup_unavailable.b76f7714",
|
||||||
|
requiredAction: IDM_INTERFACE_I18N.permissionAction,
|
||||||
|
actor: IDM_INTERFACE_I18N.permissionActor,
|
||||||
|
target: IDM_INTERFACE_I18N.permissionDestination
|
||||||
|
}}
|
||||||
|
labels={{
|
||||||
|
requiredAction: IDM_INTERFACE_I18N.requiredAction,
|
||||||
|
actor: IDM_INTERFACE_I18N.actor,
|
||||||
|
target: IDM_INTERFACE_I18N.destination
|
||||||
|
}}
|
||||||
|
documentation={IDM_DOCUMENTATION}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{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>}
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftKey(value: unknown): string {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const IDM_DOCUMENTATION = {
|
||||||
|
topicId: "idm.workflow.assign-function-to-identity",
|
||||||
|
documentationType: "user"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const IDM_GOVERNANCE_DOCUMENTATION = {
|
||||||
|
topicId: "idm.reference.assignment-governance",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const IDM_FIELD_DOCUMENTATION = {
|
||||||
|
topicId: "idm.reference.fields-and-consequences",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const IDM_INTERFACE_I18N = {
|
||||||
|
loading: "i18n:govoplan-idm.loading_reason",
|
||||||
|
busy: "i18n:govoplan-idm.busy_reason",
|
||||||
|
writeReason: "i18n:govoplan-idm.write_permission_required.c7dde7c6",
|
||||||
|
settingsReason: "i18n:govoplan-idm.settings_write_permission_required.37f37efa",
|
||||||
|
searchReason: "i18n:govoplan-idm.identity_search_permission_reason",
|
||||||
|
incomplete: "i18n:govoplan-idm.incomplete_draft_reason",
|
||||||
|
noChanges: "i18n:govoplan-idm.no_changes_reason",
|
||||||
|
noFunctions: "i18n:govoplan-idm.no_functions_available.51ba08eb",
|
||||||
|
requiredAction: "i18n:govoplan-idm.required_action",
|
||||||
|
actor: "i18n:govoplan-idm.responsible_actor",
|
||||||
|
destination: "i18n:govoplan-idm.destination",
|
||||||
|
permissionAction: "i18n:govoplan-idm.permission_required_action",
|
||||||
|
permissionActor: "i18n:govoplan-idm.permission_responsible_actor",
|
||||||
|
permissionDestination: "i18n:govoplan-idm.permission_destination",
|
||||||
|
functionAction: "i18n:govoplan-idm.function_required_action",
|
||||||
|
functionActor: "i18n:govoplan-idm.function_responsible_actor",
|
||||||
|
functionDestination: "i18n:govoplan-idm.function_destination"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function idmDisabledReason(
|
||||||
|
loading: boolean,
|
||||||
|
busy: boolean,
|
||||||
|
permitted = true
|
||||||
|
): string | undefined {
|
||||||
|
if (loading) return IDM_INTERFACE_I18N.loading;
|
||||||
|
if (busy) return IDM_INTERFACE_I18N.busy;
|
||||||
|
if (!permitted) return IDM_INTERFACE_I18N.writeReason;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
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_request_id.b7d816db": "Change request ID",
|
||||||
|
"i18n:govoplan-idm.change_request_id_help.cc7de508": "Only required when IDM assignment governance requires an approved recorded change request.",
|
||||||
|
"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.",
|
||||||
|
"i18n:govoplan-idm.loading_reason": "IDM data is still loading.",
|
||||||
|
"i18n:govoplan-idm.busy_reason": "Another IDM action is still running.",
|
||||||
|
"i18n:govoplan-idm.identity_search_permission_reason": "Your account may not search identity candidates.",
|
||||||
|
"i18n:govoplan-idm.incomplete_draft_reason": "Complete all required fields before submitting.",
|
||||||
|
"i18n:govoplan-idm.no_changes_reason": "There are no settings changes to save.",
|
||||||
|
"i18n:govoplan-idm.required_action": "Required action",
|
||||||
|
"i18n:govoplan-idm.responsible_actor": "Responsible actor",
|
||||||
|
"i18n:govoplan-idm.destination": "Destination",
|
||||||
|
"i18n:govoplan-idm.permission_required_action": "Ask for the corresponding IDM management permission.",
|
||||||
|
"i18n:govoplan-idm.permission_responsible_actor": "An Access or tenant administrator",
|
||||||
|
"i18n:govoplan-idm.permission_destination": "Access role assignments",
|
||||||
|
"i18n:govoplan-idm.function_required_action": "Create or activate an organization function first.",
|
||||||
|
"i18n:govoplan-idm.function_responsible_actor": "An organization administrator",
|
||||||
|
"i18n:govoplan-idm.function_destination": "Organizations",
|
||||||
|
"i18n:govoplan-idm.confirm_function_action": "{action} the governed change for {function}? The decision and actor are retained as evidence.",
|
||||||
|
"i18n:govoplan-idm.function_request_title": "Function request: {function}",
|
||||||
|
"i18n:govoplan-idm.function_grant_title": "Function grant: {function}",
|
||||||
|
"i18n:govoplan-idm.action_approve": "Approve",
|
||||||
|
"i18n:govoplan-idm.action_reject": "Reject",
|
||||||
|
"i18n:govoplan-idm.action_accept": "Accept",
|
||||||
|
"i18n:govoplan-idm.action_request_changes": "Request changes",
|
||||||
|
"i18n:govoplan-idm.action_respond": "Respond",
|
||||||
|
"i18n:govoplan-idm.action_withdraw": "Withdraw",
|
||||||
|
"i18n:govoplan-idm.action_recheck": "Recheck",
|
||||||
|
"i18n:govoplan-idm.kind_request": "Request",
|
||||||
|
"i18n:govoplan-idm.kind_grant": "Grant",
|
||||||
|
"i18n:govoplan-idm.state_submitted": "Submitted",
|
||||||
|
"i18n:govoplan-idm.state_awaiting_holder": "Awaiting holder",
|
||||||
|
"i18n:govoplan-idm.state_awaiting_authority": "Awaiting authority",
|
||||||
|
"i18n:govoplan-idm.state_awaiting_recipient": "Awaiting recipient",
|
||||||
|
"i18n:govoplan-idm.state_changes_requested": "Changes requested",
|
||||||
|
"i18n:govoplan-idm.state_applied": "Applied",
|
||||||
|
"i18n:govoplan-idm.state_rejected": "Rejected",
|
||||||
|
"i18n:govoplan-idm.state_expired": "Expired",
|
||||||
|
"i18n:govoplan-idm.state_withdrawn": "Withdrawn",
|
||||||
|
"i18n:govoplan-idm.state_cancelled": "Cancelled",
|
||||||
|
"i18n:govoplan-idm.state_blocked": "Blocked",
|
||||||
|
"i18n:govoplan-idm.state_failed_manual_review": "Manual review failed",
|
||||||
|
"i18n:govoplan-idm.step_approve_holder": "Holder approval",
|
||||||
|
"i18n:govoplan-idm.step_approve_authority": "Authority approval",
|
||||||
|
"i18n:govoplan-idm.step_accept_recipient": "Recipient acceptance",
|
||||||
|
"i18n:govoplan-idm.profile_holder_review": "Holder review",
|
||||||
|
"i18n:govoplan-idm.profile_authority_review": "Authority review",
|
||||||
|
"i18n:govoplan-idm.profile_recipient_review": "Recipient review",
|
||||||
|
"Actions": "Actions",
|
||||||
|
"Direct changes to this governed function require an emergency override reason.": "Direct changes to this governed function require an emergency override reason.",
|
||||||
|
"Direct changes to this governed function are emergency overrides. Use a request or grant above for the normal process.": "Direct changes to this governed function are emergency overrides. Use a request or grant above for the normal process.",
|
||||||
|
"Emergency override reason": "Emergency override reason",
|
||||||
|
"Override evidence references (one per line)": "Override evidence references (one per line)",
|
||||||
|
"Unsaved function change": "Unsaved function change",
|
||||||
|
"Save or discard the function request or grant before leaving this surface.": "Save or discard the function request or grant before leaving this surface.",
|
||||||
|
"Kind": "Kind",
|
||||||
|
"Function": "Function",
|
||||||
|
"Candidate": "Candidate",
|
||||||
|
"State": "State",
|
||||||
|
"Decisions": "Decisions",
|
||||||
|
"Updated": "Updated",
|
||||||
|
"Open change": "Open change",
|
||||||
|
"Function requests and grants": "Function requests and grants",
|
||||||
|
"Start governed change": "Start governed change",
|
||||||
|
"Loading governed function changes": "Loading governed function changes",
|
||||||
|
"No governed function changes": "No governed function changes",
|
||||||
|
"Start governed function change": "Start governed function change",
|
||||||
|
"Cancel": "Cancel",
|
||||||
|
"Submit": "Submit",
|
||||||
|
"Function change kind": "Function change kind",
|
||||||
|
"Request function": "Request function",
|
||||||
|
"Grant function": "Grant function",
|
||||||
|
"Select function": "Select function",
|
||||||
|
"Candidate identity": "Candidate identity",
|
||||||
|
"Select identity": "Select identity",
|
||||||
|
"Candidate account": "Candidate account",
|
||||||
|
"No linked account": "No linked account",
|
||||||
|
"Valid from": "Valid from",
|
||||||
|
"Valid until": "Valid until",
|
||||||
|
"Justification": "Justification",
|
||||||
|
"Evidence references (one per line)": "Evidence references (one per line)",
|
||||||
|
"Function change": "Function change",
|
||||||
|
"Close": "Close",
|
||||||
|
"Profile": "Profile",
|
||||||
|
"Workflow revision": "Workflow revision",
|
||||||
|
"Required decisions": "Required decisions",
|
||||||
|
"Completed decisions": "Completed decisions",
|
||||||
|
"Explanation": "Explanation",
|
||||||
|
"Decision comment": "Decision comment",
|
||||||
|
"History": "History",
|
||||||
|
"Confirm function decision": "Confirm function decision",
|
||||||
|
"Confirm": "Confirm",
|
||||||
|
"None": "None"
|
||||||
|
},
|
||||||
|
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_request_id.b7d816db": "Änderungsantrags-ID",
|
||||||
|
"i18n:govoplan-idm.change_request_id_help.cc7de508": "Nur erforderlich, wenn die IDM-Zuordnungs-Governance einen genehmigten erfassten Änderungsantrag verlangt.",
|
||||||
|
"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.",
|
||||||
|
"i18n:govoplan-idm.loading_reason": "IDM-Daten werden noch geladen.",
|
||||||
|
"i18n:govoplan-idm.busy_reason": "Eine andere IDM-Aktion läuft noch.",
|
||||||
|
"i18n:govoplan-idm.identity_search_permission_reason": "Ihr Konto darf keine Identitätskandidaten suchen.",
|
||||||
|
"i18n:govoplan-idm.incomplete_draft_reason": "Füllen Sie vor dem Absenden alle Pflichtfelder aus.",
|
||||||
|
"i18n:govoplan-idm.no_changes_reason": "Es gibt keine Einstellungsänderungen zu speichern.",
|
||||||
|
"i18n:govoplan-idm.required_action": "Erforderliche Aktion",
|
||||||
|
"i18n:govoplan-idm.responsible_actor": "Verantwortliche Stelle",
|
||||||
|
"i18n:govoplan-idm.destination": "Ziel",
|
||||||
|
"i18n:govoplan-idm.permission_required_action": "Fordern Sie die entsprechende IDM-Verwaltungsberechtigung an.",
|
||||||
|
"i18n:govoplan-idm.permission_responsible_actor": "Access- oder Mandantenadministration",
|
||||||
|
"i18n:govoplan-idm.permission_destination": "Access-Rollenzuordnungen",
|
||||||
|
"i18n:govoplan-idm.function_required_action": "Erstellen oder aktivieren Sie zuerst eine Organisationsfunktion.",
|
||||||
|
"i18n:govoplan-idm.function_responsible_actor": "Organisationsadministration",
|
||||||
|
"i18n:govoplan-idm.function_destination": "Organisationen",
|
||||||
|
"i18n:govoplan-idm.confirm_function_action": "{action} der gesteuerten Änderung für {function}? Entscheidung und handelnde Person werden als Nachweis aufbewahrt.",
|
||||||
|
"i18n:govoplan-idm.function_request_title": "Funktionsantrag: {function}",
|
||||||
|
"i18n:govoplan-idm.function_grant_title": "Funktionsvergabe: {function}",
|
||||||
|
"i18n:govoplan-idm.action_approve": "Genehmigen",
|
||||||
|
"i18n:govoplan-idm.action_reject": "Ablehnen",
|
||||||
|
"i18n:govoplan-idm.action_accept": "Annehmen",
|
||||||
|
"i18n:govoplan-idm.action_request_changes": "Änderungen anfordern",
|
||||||
|
"i18n:govoplan-idm.action_respond": "Antworten",
|
||||||
|
"i18n:govoplan-idm.action_withdraw": "Zurückziehen",
|
||||||
|
"i18n:govoplan-idm.action_recheck": "Erneut prüfen",
|
||||||
|
"i18n:govoplan-idm.kind_request": "Antrag",
|
||||||
|
"i18n:govoplan-idm.kind_grant": "Vergabe",
|
||||||
|
"i18n:govoplan-idm.state_submitted": "Eingereicht",
|
||||||
|
"i18n:govoplan-idm.state_awaiting_holder": "Wartet auf Funktionsinhaber",
|
||||||
|
"i18n:govoplan-idm.state_awaiting_authority": "Wartet auf zuständige Stelle",
|
||||||
|
"i18n:govoplan-idm.state_awaiting_recipient": "Wartet auf Empfänger",
|
||||||
|
"i18n:govoplan-idm.state_changes_requested": "Änderungen angefordert",
|
||||||
|
"i18n:govoplan-idm.state_applied": "Angewendet",
|
||||||
|
"i18n:govoplan-idm.state_rejected": "Abgelehnt",
|
||||||
|
"i18n:govoplan-idm.state_expired": "Abgelaufen",
|
||||||
|
"i18n:govoplan-idm.state_withdrawn": "Zurückgezogen",
|
||||||
|
"i18n:govoplan-idm.state_cancelled": "Abgebrochen",
|
||||||
|
"i18n:govoplan-idm.state_blocked": "Blockiert",
|
||||||
|
"i18n:govoplan-idm.state_failed_manual_review": "Manuelle Prüfung fehlgeschlagen",
|
||||||
|
"i18n:govoplan-idm.step_approve_holder": "Genehmigung durch Funktionsinhaber",
|
||||||
|
"i18n:govoplan-idm.step_approve_authority": "Genehmigung durch zuständige Stelle",
|
||||||
|
"i18n:govoplan-idm.step_accept_recipient": "Annahme durch Empfänger",
|
||||||
|
"i18n:govoplan-idm.profile_holder_review": "Prüfung durch Funktionsinhaber",
|
||||||
|
"i18n:govoplan-idm.profile_authority_review": "Prüfung durch zuständige Stelle",
|
||||||
|
"i18n:govoplan-idm.profile_recipient_review": "Prüfung durch Empfänger",
|
||||||
|
"Actions": "Aktionen",
|
||||||
|
"Direct changes to this governed function require an emergency override reason.": "Direkte Änderungen an dieser gesteuerten Funktion erfordern eine Begründung für die Notfallübersteuerung.",
|
||||||
|
"Direct changes to this governed function are emergency overrides. Use a request or grant above for the normal process.": "Direkte Änderungen an dieser gesteuerten Funktion sind Notfallübersteuerungen. Verwenden Sie für den regulären Prozess einen Antrag oder eine Vergabe.",
|
||||||
|
"Emergency override reason": "Begründung der Notfallübersteuerung",
|
||||||
|
"Override evidence references (one per line)": "Nachweisreferenzen der Übersteuerung (eine pro Zeile)",
|
||||||
|
"Unsaved function change": "Ungespeicherte Funktionsänderung",
|
||||||
|
"Save or discard the function request or grant before leaving this surface.": "Speichern oder verwerfen Sie den Funktionsantrag oder die Vergabe, bevor Sie diesen Bereich verlassen.",
|
||||||
|
"Kind": "Art",
|
||||||
|
"Function": "Funktion",
|
||||||
|
"Candidate": "Kandidat",
|
||||||
|
"State": "Status",
|
||||||
|
"Decisions": "Entscheidungen",
|
||||||
|
"Updated": "Aktualisiert",
|
||||||
|
"Open change": "Änderung öffnen",
|
||||||
|
"Function requests and grants": "Funktionsanträge und -vergaben",
|
||||||
|
"Start governed change": "Gesteuerte Änderung starten",
|
||||||
|
"Loading governed function changes": "Gesteuerte Funktionsänderungen werden geladen",
|
||||||
|
"No governed function changes": "Keine gesteuerten Funktionsänderungen",
|
||||||
|
"Start governed function change": "Gesteuerte Funktionsänderung starten",
|
||||||
|
"Cancel": "Abbrechen",
|
||||||
|
"Submit": "Absenden",
|
||||||
|
"Function change kind": "Art der Funktionsänderung",
|
||||||
|
"Request function": "Funktion beantragen",
|
||||||
|
"Grant function": "Funktion vergeben",
|
||||||
|
"Select function": "Funktion auswählen",
|
||||||
|
"Candidate identity": "Kandidatenidentität",
|
||||||
|
"Select identity": "Identität auswählen",
|
||||||
|
"Candidate account": "Kandidatenkonto",
|
||||||
|
"No linked account": "Kein verknüpftes Konto",
|
||||||
|
"Valid from": "Gültig ab",
|
||||||
|
"Valid until": "Gültig bis",
|
||||||
|
"Justification": "Begründung",
|
||||||
|
"Evidence references (one per line)": "Nachweisreferenzen (eine pro Zeile)",
|
||||||
|
"Function change": "Funktionsänderung",
|
||||||
|
"Close": "Schließen",
|
||||||
|
"Profile": "Profil",
|
||||||
|
"Workflow revision": "Workflow-Revision",
|
||||||
|
"Required decisions": "Erforderliche Entscheidungen",
|
||||||
|
"Completed decisions": "Abgeschlossene Entscheidungen",
|
||||||
|
"Explanation": "Erläuterung",
|
||||||
|
"Decision comment": "Entscheidungskommentar",
|
||||||
|
"History": "Verlauf",
|
||||||
|
"Confirm function decision": "Funktionsentscheidung bestätigen",
|
||||||
|
"Confirm": "Bestätigen",
|
||||||
|
"None": "Keine"
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { idmModule, default } from "./module";
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import { Users } from "lucide-react";
|
||||||
|
import type { OrganizationFunctionActionsUiCapability, 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",
|
||||||
|
surfaceId: "idm.action.view-function-assignments",
|
||||||
|
label: "i18n:govoplan-idm.view_assignments.2d40d6a5",
|
||||||
|
icon: createElement(Users, { size: 16 }),
|
||||||
|
anyOf: idmReadScopes,
|
||||||
|
order: 40,
|
||||||
|
onClick: ({ function: item }) => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.location.href = `/idm?function_id=${encodeURIComponent(item.id)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export const idmModule: PlatformWebModule = {
|
||||||
|
id: "idm",
|
||||||
|
label: "i18n:govoplan-idm.idm.61f4a7a2",
|
||||||
|
version: "0.1.8",
|
||||||
|
dependencies: ["identity", "organizations"],
|
||||||
|
translations,
|
||||||
|
viewSurfaces: [
|
||||||
|
{
|
||||||
|
id: "idm.action.view-function-assignments",
|
||||||
|
moduleId: "idm",
|
||||||
|
kind: "action",
|
||||||
|
label: "i18n:govoplan-idm.view_assignments.2d40d6a5",
|
||||||
|
order: 40
|
||||||
|
}
|
||||||
|
],
|
||||||
|
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;
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
.idm-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-heading {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-table-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-table-stack > .card {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-dialog-change-request {
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-governance-override {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-change-detail {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-change-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px 18px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-change-summary > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-change-summary > .wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-change-summary dt {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-change-summary dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-change-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding-block: 14px;
|
||||||
|
border-block: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-change-history {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-change-history li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(120px, 0.6fr) minmax(150px, 0.7fr) minmax(180px, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-change-history p {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.idm-change-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-change-summary > .wide {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idm-change-history li {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user