Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d051b84f6 | ||
|
|
f3daca7ed7 | ||
|
|
d435fefed9 | ||
|
|
b5017acad3 | ||
|
|
34357fe0ec | ||
|
|
43f0128e6a | ||
|
|
f4938c9666 | ||
|
|
8c5dbda376 | ||
|
|
6dbd930e40 | ||
|
|
fbe97efd48 | ||
|
|
0161f65c5d | ||
|
|
5068185fa4 | ||
|
|
44e751a1ae | ||
|
|
551d1160da | ||
|
|
a527310e71 | ||
|
|
546909baac | ||
|
|
7a1710af89 | ||
|
|
99560a74c6 | ||
|
|
c0afbc9333 | ||
|
|
1cdc760c2f | ||
|
|
d7bf71a2d9 |
@@ -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
|
||||
+267
@@ -6,3 +6,270 @@ __pycache__/
|
||||
.ruff_cache/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# GovOPlaN shared ignore rules from govoplan-core
|
||||
# ---> 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
|
||||
*$py.class
|
||||
# C extensions
|
||||
*.so
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
develop-eggs/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
.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/
|
||||
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
|
||||
.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:
|
||||
# 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/
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# GovOPlaN Identity Codex Guide
|
||||
|
||||
## 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 Identity internals.
|
||||
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||
|
||||
## Scope
|
||||
|
||||
This repository owns the canonical GovOPlaN identity directory: internal
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# GovOPlaN Identity
|
||||
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (platform).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`govoplan-identity` is the canonical identity directory module for GovOPlaN.
|
||||
|
||||
It owns identities and links between identities and platform accounts. This is
|
||||
@@ -37,3 +41,21 @@ From the core checkout:
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./.venv/bin/python -m pip install -e ../govoplan-identity
|
||||
```
|
||||
|
||||
## Git-source WebUI package
|
||||
|
||||
The repository root exposes `@govoplan/identity-webui` for Git-tagged release
|
||||
dependencies. It mirrors the owning `webui/package.json` version, public
|
||||
TypeScript/CSS exports and peer requirements, with entry paths under
|
||||
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
|
||||
development or install scripts. The source archive contains `webui/src`, this
|
||||
README and any repository license file. Run module development checks from `webui/`; Python
|
||||
installation remains governed by `pyproject.toml`.
|
||||
|
||||
Das Repository stellt `@govoplan/identity-webui` am Wurzelpfad für versionierte
|
||||
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
|
||||
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
|
||||
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
|
||||
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
|
||||
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
|
||||
`pyproject.toml` definiert.
|
||||
|
||||
+67
-3
@@ -10,6 +10,32 @@ authenticate and independent of what they may do.
|
||||
- Primary account: the account used as the default display/explainability
|
||||
anchor when multiple accounts exist.
|
||||
|
||||
## Lifecycle semantics
|
||||
|
||||
An active identity is eligible for ordinary directory search. Deactivation
|
||||
removes it from default search results but does not delete the identity, its
|
||||
account links, their source provenance, or the primary-account marker. Direct
|
||||
identifier/account resolution retains the record with an explicit `inactive`
|
||||
status so Access and reconcilers do not mistake deactivation for absence.
|
||||
Authorized lifecycle owners may also include inactive identities in search and
|
||||
may reactivate them. Deactivation is therefore a reversible directory-state
|
||||
change, not account suspension or erasure; Access owns those separate
|
||||
consequences.
|
||||
|
||||
Each account link records the origin of the accepted association in `source`
|
||||
(for example `local` or an IDM reconciliation source). The source is provenance,
|
||||
not authorization and not proof that the external source remains reachable.
|
||||
Changing the primary account never rewrites this origin.
|
||||
|
||||
An identity may retain multiple account links but has at most one primary
|
||||
account. A primary-account change may select only an existing link belonging to
|
||||
that identity, atomically demotes the previous primary, preserves every link,
|
||||
and records old/new account ids plus link-source provenance in the audit log.
|
||||
The lifecycle service does not commit: its caller authorizes the operation and
|
||||
commits the state and audit record together, or rolls both back on validation,
|
||||
audit, or persistence failure. Repeating the already-effective selection is a
|
||||
no-op and does not create misleading audit activity.
|
||||
|
||||
## Boundary With Access
|
||||
|
||||
Access owns authorization. Identity only tells access which identity is behind
|
||||
@@ -17,11 +43,49 @@ an account. Access can then evaluate roles, rights, delegation, and policy.
|
||||
|
||||
## Boundary With Organizations
|
||||
|
||||
Organization functions and function assignments live in
|
||||
`govoplan-organizations`. Those assignments may reference identity and account
|
||||
IDs, but identity does not own organizational structure.
|
||||
Organization functions live in `govoplan-organizations`.
|
||||
Identity-to-function assignments live in `govoplan-idm`. Those assignments may
|
||||
reference identity and account IDs, but identity does not own organizational
|
||||
structure or assignment workflows.
|
||||
|
||||
## Boundary With IDM
|
||||
|
||||
`govoplan-idm` imports, previews, reconciles, and applies external identity
|
||||
facts. Once accepted, normalized identity records belong here.
|
||||
|
||||
## Access Projection Migration
|
||||
|
||||
`govoplan-access` now prefers `identity.directory` when it needs to resolve the
|
||||
identity behind an account or render identity labels in semantic access views.
|
||||
If the identity module is not installed, Access falls back to the legacy
|
||||
`access_identities` and `access_identity_account_links` projection tables.
|
||||
|
||||
Rollout plan:
|
||||
|
||||
- keep the Access projection tables readable until existing installations have
|
||||
a backfill path;
|
||||
- backfill `identity_identities` and `identity_account_links` from the Access
|
||||
projection where Identity is newly installed on an existing deployment;
|
||||
- keep Access writes that still create local accounts able to maintain the
|
||||
projection during the compatibility window;
|
||||
- once deployments use `identity.directory` consistently, retire direct Access
|
||||
identity reads and leave the projection tables as migration-only data until a
|
||||
release-level retirement plan removes them.
|
||||
|
||||
The close-out condition is that Access works with canonical Identity installed
|
||||
and still works without it through the projection fallback.
|
||||
|
||||
## Administration surface
|
||||
|
||||
Identity now exposes a system-scoped administration API and an embedded
|
||||
administration workspace. Administrators can create, inspect, update,
|
||||
deactivate, and reactivate identities, then add or remove opaque platform
|
||||
account references and promote one link as primary. The first account link is
|
||||
made primary automatically. A primary link cannot be removed while another
|
||||
link remains; the replacement must be promoted first.
|
||||
|
||||
The current tenant is retained as the actor context, but it does not make the
|
||||
canonical identity record tenant-owned. Every mutation is therefore written as
|
||||
a system-scoped audit event. Identity does not inspect account credentials or
|
||||
authorization state and does not treat deactivation as account suspension.
|
||||
IDM continues to own external import and reconciliation decisions.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@govoplan/identity-webui",
|
||||
"version": "0.1.21",
|
||||
"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/identity.css": "./webui/src/styles/identity.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"webui/src",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-identity"
|
||||
version = "0.1.6"
|
||||
version = "0.1.21"
|
||||
description = "GovOPlaN identity directory module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.6",
|
||||
"govoplan-core>=0.1.18",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN identity module."""
|
||||
|
||||
__version__ = "0.1.6"
|
||||
__version__ = "0.1.21"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Identity API package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Identity API v1 package."""
|
||||
@@ -0,0 +1,519 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, require_any_scope
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
||||
from govoplan_identity.backend.lifecycle import (
|
||||
IdentityLifecycleError,
|
||||
set_identity_active,
|
||||
set_primary_account,
|
||||
)
|
||||
|
||||
from .schemas import (
|
||||
IdentityAccountLinkCreateRequest,
|
||||
IdentityAccountLinkItem,
|
||||
IdentityAccountLinkUpdateRequest,
|
||||
IdentityCreateRequest,
|
||||
IdentityItem,
|
||||
IdentityLifecycleRequest,
|
||||
IdentityListResponse,
|
||||
IdentityUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/identity", tags=["identity"])
|
||||
|
||||
IDENTITY_READ_SCOPES = (
|
||||
"identity:identity:read",
|
||||
"identity:identity:admin",
|
||||
"identity:account_link:admin",
|
||||
"identity:read",
|
||||
"admin:users:read",
|
||||
"system:accounts:read",
|
||||
"organizations:function:assign",
|
||||
)
|
||||
IDENTITY_WRITE_SCOPES = (
|
||||
"identity:identity:admin",
|
||||
"system:accounts:update",
|
||||
"access:account:update",
|
||||
)
|
||||
ACCOUNT_LINK_WRITE_SCOPES = (
|
||||
"identity:account_link:admin",
|
||||
"identity:identity:admin",
|
||||
"system:accounts:update",
|
||||
"access:account:update",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/identities", response_model=IdentityListResponse)
|
||||
def list_identities(
|
||||
query: str | None = Query(default=None, min_length=1, max_length=255),
|
||||
limit: int = Query(default=25, ge=1, le=500),
|
||||
include_inactive: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_READ_SCOPES)),
|
||||
) -> IdentityListResponse:
|
||||
identity_query = session.query(Identity)
|
||||
if not include_inactive:
|
||||
identity_query = identity_query.filter(Identity.is_active.is_(True))
|
||||
if query:
|
||||
pattern = f"%{query.strip().casefold()}%"
|
||||
matching_account_links = session.query(IdentityAccountLink.identity_id).filter(
|
||||
func.lower(IdentityAccountLink.account_id).like(pattern)
|
||||
)
|
||||
identity_query = identity_query.filter(
|
||||
or_(
|
||||
func.lower(Identity.id).like(pattern),
|
||||
func.lower(Identity.display_name).like(pattern),
|
||||
func.lower(Identity.external_subject).like(pattern),
|
||||
Identity.id.in_(matching_account_links),
|
||||
)
|
||||
)
|
||||
|
||||
identities = (
|
||||
identity_query.order_by(Identity.display_name.asc(), Identity.id.asc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
links_by_identity = _links_by_identity(session, identities)
|
||||
return IdentityListResponse(
|
||||
identities=[
|
||||
_identity_item(identity, links_by_identity.get(identity.id, ()))
|
||||
for identity in identities
|
||||
],
|
||||
tenant_context_id=principal.tenant_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/identities/{identity_id}", response_model=IdentityItem)
|
||||
def get_identity(
|
||||
identity_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_READ_SCOPES)),
|
||||
) -> IdentityItem:
|
||||
del principal
|
||||
identity = _require_identity(session, identity_id)
|
||||
return _identity_item(identity, _identity_links(session, identity.id))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/identities",
|
||||
response_model=IdentityItem,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_identity(
|
||||
payload: IdentityCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_WRITE_SCOPES)),
|
||||
) -> IdentityItem:
|
||||
identity = Identity(
|
||||
display_name=_optional_text(payload.display_name),
|
||||
external_subject=_optional_text(payload.external_subject),
|
||||
source=payload.source.strip(),
|
||||
is_active=payload.is_active,
|
||||
settings=dict(payload.settings),
|
||||
)
|
||||
session.add(identity)
|
||||
session.flush()
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="identity.created",
|
||||
scope="system",
|
||||
object_type="identity",
|
||||
object_id=identity.id,
|
||||
details={
|
||||
"management_scope": "system",
|
||||
"source": identity.source,
|
||||
"active": identity.is_active,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(identity)
|
||||
return _identity_item(identity, ())
|
||||
|
||||
|
||||
@router.patch("/identities/{identity_id}", response_model=IdentityItem)
|
||||
def update_identity(
|
||||
identity_id: str,
|
||||
payload: IdentityUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_WRITE_SCOPES)),
|
||||
) -> IdentityItem:
|
||||
identity = _require_identity(session, identity_id)
|
||||
changed_fields: list[str] = []
|
||||
supplied = payload.model_fields_set
|
||||
if "display_name" in supplied:
|
||||
identity.display_name = _optional_text(payload.display_name)
|
||||
changed_fields.append("display_name")
|
||||
if "external_subject" in supplied:
|
||||
identity.external_subject = _optional_text(payload.external_subject)
|
||||
changed_fields.append("external_subject")
|
||||
if "source" in supplied and payload.source is not None:
|
||||
identity.source = payload.source.strip()
|
||||
changed_fields.append("source")
|
||||
if "settings" in supplied and payload.settings is not None:
|
||||
identity.settings = dict(payload.settings)
|
||||
changed_fields.append("settings")
|
||||
if "is_active" in supplied and payload.is_active is not None:
|
||||
try:
|
||||
result = set_identity_active(
|
||||
session,
|
||||
identity_id=identity.id,
|
||||
active=payload.is_active,
|
||||
actor_tenant_id=principal.tenant_id,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_scope="system",
|
||||
reason=payload.reason,
|
||||
)
|
||||
except IdentityLifecycleError as exc: # pragma: no cover - already loaded
|
||||
raise _lifecycle_http_error(exc) from exc
|
||||
if result.changed:
|
||||
changed_fields.append("is_active")
|
||||
if changed_fields:
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="identity.updated",
|
||||
scope="system",
|
||||
object_type="identity",
|
||||
object_id=identity.id,
|
||||
details={
|
||||
"management_scope": "system",
|
||||
"changed_fields": sorted(changed_fields),
|
||||
"reason": _optional_text(payload.reason),
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(identity)
|
||||
return _identity_item(identity, _identity_links(session, identity.id))
|
||||
|
||||
|
||||
@router.post("/identities/{identity_id}/deactivate", response_model=IdentityItem)
|
||||
def deactivate_identity(
|
||||
identity_id: str,
|
||||
payload: IdentityLifecycleRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_WRITE_SCOPES)),
|
||||
) -> IdentityItem:
|
||||
return _set_active_response(session, principal, identity_id, False, payload.reason)
|
||||
|
||||
|
||||
@router.post("/identities/{identity_id}/activate", response_model=IdentityItem)
|
||||
def activate_identity(
|
||||
identity_id: str,
|
||||
payload: IdentityLifecycleRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_WRITE_SCOPES)),
|
||||
) -> IdentityItem:
|
||||
return _set_active_response(session, principal, identity_id, True, payload.reason)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/identities/{identity_id}/account-links",
|
||||
response_model=IdentityItem,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def add_account_link(
|
||||
identity_id: str,
|
||||
payload: IdentityAccountLinkCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*ACCOUNT_LINK_WRITE_SCOPES)),
|
||||
) -> IdentityItem:
|
||||
identity = _require_identity(session, identity_id)
|
||||
account_id = payload.account_id.strip()
|
||||
existing = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(IdentityAccountLink.account_id == account_id)
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
detail = (
|
||||
"The account is already linked to this identity."
|
||||
if existing.identity_id == identity.id
|
||||
else "The account is already linked to another identity."
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
|
||||
has_links = (
|
||||
session.query(IdentityAccountLink.id)
|
||||
.filter(IdentityAccountLink.identity_id == identity.id)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
link = IdentityAccountLink(
|
||||
identity_id=identity.id,
|
||||
account_id=account_id,
|
||||
is_primary=False,
|
||||
source=payload.source.strip(),
|
||||
)
|
||||
session.add(link)
|
||||
try:
|
||||
session.flush()
|
||||
if payload.make_primary or not has_links:
|
||||
set_primary_account(
|
||||
session,
|
||||
identity_id=identity.id,
|
||||
account_id=account_id,
|
||||
actor_tenant_id=principal.tenant_id,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_scope="system",
|
||||
reason=payload.reason,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="identity.account_link_added",
|
||||
scope="system",
|
||||
object_type="identity_account_link",
|
||||
object_id=link.id,
|
||||
details={
|
||||
"management_scope": "system",
|
||||
"identity_id": identity.id,
|
||||
"account_id": account_id,
|
||||
"source": link.source,
|
||||
"made_primary": payload.make_primary or not has_links,
|
||||
"reason": _optional_text(payload.reason),
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="The account link conflicts with an existing primary-account assignment.",
|
||||
) from exc
|
||||
session.refresh(identity)
|
||||
return _identity_item(identity, _identity_links(session, identity.id))
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/identities/{identity_id}/account-links/{link_id}",
|
||||
response_model=IdentityItem,
|
||||
)
|
||||
def update_account_link(
|
||||
identity_id: str,
|
||||
link_id: str,
|
||||
payload: IdentityAccountLinkUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*ACCOUNT_LINK_WRITE_SCOPES)),
|
||||
) -> IdentityItem:
|
||||
identity = _require_identity(session, identity_id)
|
||||
link = _require_link(session, identity.id, link_id)
|
||||
if not payload.is_primary:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="A primary link can only be demoted by promoting a replacement.",
|
||||
)
|
||||
try:
|
||||
set_primary_account(
|
||||
session,
|
||||
identity_id=identity.id,
|
||||
account_id=link.account_id,
|
||||
actor_tenant_id=principal.tenant_id,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_scope="system",
|
||||
reason=payload.reason,
|
||||
)
|
||||
except IdentityLifecycleError as exc:
|
||||
raise _lifecycle_http_error(exc) from exc
|
||||
session.commit()
|
||||
session.refresh(identity)
|
||||
return _identity_item(identity, _identity_links(session, identity.id))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/identities/{identity_id}/account-links/{link_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def remove_account_link(
|
||||
identity_id: str,
|
||||
link_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*ACCOUNT_LINK_WRITE_SCOPES)),
|
||||
) -> Response:
|
||||
identity = _require_identity(session, identity_id)
|
||||
link = _require_link(session, identity.id, link_id)
|
||||
remaining_count = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(
|
||||
IdentityAccountLink.identity_id == identity.id,
|
||||
IdentityAccountLink.id != link.id,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if link.is_primary and remaining_count:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Promote another account before removing the primary link.",
|
||||
)
|
||||
evidence = {
|
||||
"management_scope": "system",
|
||||
"identity_id": identity.id,
|
||||
"account_id": link.account_id,
|
||||
"source": link.source,
|
||||
"was_primary": link.is_primary,
|
||||
}
|
||||
session.delete(link)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="identity.account_link_removed",
|
||||
scope="system",
|
||||
object_type="identity_account_link",
|
||||
object_id=link.id,
|
||||
details=evidence,
|
||||
)
|
||||
session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
def _set_active_response(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
identity_id: str,
|
||||
active: bool,
|
||||
reason: str | None,
|
||||
) -> IdentityItem:
|
||||
identity = _require_identity(session, identity_id)
|
||||
try:
|
||||
set_identity_active(
|
||||
session,
|
||||
identity_id=identity.id,
|
||||
active=active,
|
||||
actor_tenant_id=principal.tenant_id,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_scope="system",
|
||||
reason=reason,
|
||||
)
|
||||
except IdentityLifecycleError as exc:
|
||||
raise _lifecycle_http_error(exc) from exc
|
||||
session.commit()
|
||||
session.refresh(identity)
|
||||
return _identity_item(identity, _identity_links(session, identity.id))
|
||||
|
||||
|
||||
def _require_identity(session: Session, identity_id: str) -> Identity:
|
||||
identity = session.get(Identity, identity_id)
|
||||
if identity is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Identity not found.",
|
||||
)
|
||||
return identity
|
||||
|
||||
|
||||
def _require_link(
|
||||
session: Session,
|
||||
identity_id: str,
|
||||
link_id: str,
|
||||
) -> IdentityAccountLink:
|
||||
link = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(
|
||||
IdentityAccountLink.id == link_id,
|
||||
IdentityAccountLink.identity_id == identity_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if link is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Account link not found.",
|
||||
)
|
||||
return link
|
||||
|
||||
|
||||
def _links_by_identity(
|
||||
session: Session,
|
||||
identities: Sequence[Identity],
|
||||
) -> dict[str, list[IdentityAccountLink]]:
|
||||
identity_ids = [identity.id for identity in identities]
|
||||
result: dict[str, list[IdentityAccountLink]] = {
|
||||
identity_id: [] for identity_id in identity_ids
|
||||
}
|
||||
if not identity_ids:
|
||||
return result
|
||||
links = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(IdentityAccountLink.identity_id.in_(identity_ids))
|
||||
.order_by(
|
||||
IdentityAccountLink.identity_id.asc(),
|
||||
IdentityAccountLink.is_primary.desc(),
|
||||
IdentityAccountLink.account_id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for link in links:
|
||||
result.setdefault(link.identity_id, []).append(link)
|
||||
return result
|
||||
|
||||
|
||||
def _identity_links(
|
||||
session: Session,
|
||||
identity_id: str,
|
||||
) -> list[IdentityAccountLink]:
|
||||
return (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(IdentityAccountLink.identity_id == identity_id)
|
||||
.order_by(
|
||||
IdentityAccountLink.is_primary.desc(),
|
||||
IdentityAccountLink.account_id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _identity_item(
|
||||
identity: Identity,
|
||||
links: Sequence[IdentityAccountLink],
|
||||
) -> IdentityItem:
|
||||
primary_link = next((link for link in links if link.is_primary), None)
|
||||
return IdentityItem(
|
||||
id=identity.id,
|
||||
display_name=identity.display_name,
|
||||
external_subject=identity.external_subject,
|
||||
source=identity.source,
|
||||
primary_account_id=primary_link.account_id if primary_link is not None else None,
|
||||
account_ids=[link.account_id for link in links],
|
||||
account_links=[_account_link_item(link) for link in links],
|
||||
status="active" if identity.is_active else "inactive",
|
||||
is_active=identity.is_active,
|
||||
settings=dict(identity.settings or {}),
|
||||
created_at=identity.created_at,
|
||||
updated_at=identity.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _account_link_item(link: IdentityAccountLink) -> IdentityAccountLinkItem:
|
||||
return IdentityAccountLinkItem(
|
||||
id=link.id,
|
||||
identity_id=link.identity_id,
|
||||
account_id=link.account_id,
|
||||
is_primary=link.is_primary,
|
||||
source=link.source,
|
||||
created_at=link.created_at,
|
||||
updated_at=link.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _optional_text(value: Any) -> str | None:
|
||||
normalized = str(value or "").strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _lifecycle_http_error(exc: IdentityLifecycleError) -> HTTPException:
|
||||
code = (
|
||||
status.HTTP_404_NOT_FOUND
|
||||
if exc.code == "identity_not_found"
|
||||
else status.HTTP_409_CONFLICT
|
||||
)
|
||||
return HTTPException(status_code=code, detail=str(exc))
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class IdentityAccountLinkItem(BaseModel):
|
||||
id: str
|
||||
identity_id: str
|
||||
account_id: str
|
||||
is_primary: bool
|
||||
source: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class IdentityItem(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]
|
||||
account_links: list[IdentityAccountLinkItem] = Field(default_factory=list)
|
||||
status: Literal["active", "inactive"]
|
||||
is_active: bool
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
management_scope: Literal["system"] = "system"
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class IdentityListResponse(BaseModel):
|
||||
identities: list[IdentityItem]
|
||||
management_scope: Literal["system"] = "system"
|
||||
tenant_context_id: str | None = None
|
||||
|
||||
|
||||
class IdentityCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
external_subject: str | None = Field(default=None, max_length=255)
|
||||
source: str = Field(default="local", min_length=1, max_length=50)
|
||||
is_active: bool = True
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class IdentityUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
external_subject: str | None = Field(default=None, max_length=255)
|
||||
source: str | None = Field(default=None, min_length=1, max_length=50)
|
||||
is_active: bool | None = None
|
||||
settings: dict[str, Any] | None = None
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class IdentityLifecycleRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class IdentityAccountLinkCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
account_id: str = Field(min_length=1, max_length=36)
|
||||
source: str = Field(default="local", min_length=1, max_length=50)
|
||||
make_primary: bool = False
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class IdentityAccountLinkUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
is_primary: bool
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
|
||||
from govoplan_core.core.identity import IdentityAccountLinkRef, IdentityDirectory, IdentityRef
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
||||
@@ -103,3 +105,49 @@ class SqlIdentityDirectory(IdentityDirectory):
|
||||
.all()
|
||||
)
|
||||
return tuple(_link_ref(link) for link in links)
|
||||
|
||||
def search_identities(
|
||||
self,
|
||||
query: str | None = None,
|
||||
*,
|
||||
include_inactive: bool = False,
|
||||
limit: int = 25,
|
||||
) -> tuple[IdentityRef, ...]:
|
||||
normalized_limit = max(1, min(int(limit), 100))
|
||||
with get_database().session() as session:
|
||||
identity_query = session.query(Identity)
|
||||
if not include_inactive:
|
||||
identity_query = identity_query.filter(Identity.is_active.is_(True))
|
||||
if query and query.strip():
|
||||
pattern = f"%{query.strip().casefold()}%"
|
||||
matching_account_links = session.query(IdentityAccountLink.identity_id).filter(
|
||||
func.lower(IdentityAccountLink.account_id).like(pattern)
|
||||
)
|
||||
identity_query = identity_query.filter(
|
||||
or_(
|
||||
func.lower(Identity.id).like(pattern),
|
||||
func.lower(Identity.display_name).like(pattern),
|
||||
func.lower(Identity.external_subject).like(pattern),
|
||||
Identity.id.in_(matching_account_links),
|
||||
)
|
||||
)
|
||||
|
||||
identities = (
|
||||
identity_query
|
||||
.order_by(Identity.display_name.asc(), Identity.id.asc())
|
||||
.limit(normalized_limit)
|
||||
.all()
|
||||
)
|
||||
identity_ids = [identity.id for identity in identities]
|
||||
links = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(IdentityAccountLink.identity_id.in_(identity_ids))
|
||||
.order_by(IdentityAccountLink.is_primary.desc(), IdentityAccountLink.account_id.asc())
|
||||
.all()
|
||||
if identity_ids
|
||||
else []
|
||||
)
|
||||
links_by_identity: dict[str, list[IdentityAccountLink]] = {}
|
||||
for link in links:
|
||||
links_by_identity.setdefault(link.identity_id, []).append(link)
|
||||
return tuple(_identity_ref(identity, links_by_identity.get(identity.id, ())) for identity in identities)
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
||||
|
||||
|
||||
IDENTITY_DSAR_CAPABILITY = dsar_capability_name("identity")
|
||||
_CONFLICT = object()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SubjectSelectors:
|
||||
identity_id: str
|
||||
account_id: str | None
|
||||
link_id: str | None
|
||||
|
||||
|
||||
class IdentityDsarProvider:
|
||||
provider_id = "identity"
|
||||
module_id = "identity"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
del tenant_id
|
||||
db = _session(session)
|
||||
selectors = _subject_selectors(subject)
|
||||
if selectors is None:
|
||||
return ()
|
||||
query = (
|
||||
db.query(Identity, IdentityAccountLink)
|
||||
.join(
|
||||
IdentityAccountLink,
|
||||
IdentityAccountLink.identity_id == Identity.id,
|
||||
)
|
||||
.filter(Identity.id == selectors.identity_id)
|
||||
)
|
||||
if selectors.account_id:
|
||||
query = query.filter(
|
||||
IdentityAccountLink.account_id == selectors.account_id
|
||||
)
|
||||
if selectors.link_id:
|
||||
query = query.filter(IdentityAccountLink.id == selectors.link_id)
|
||||
matches = query.limit(2).all()
|
||||
if len(matches) != 1:
|
||||
return ()
|
||||
identity, link = matches[0]
|
||||
return (_identity_record(identity, link),)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
selectors = _subject_selectors(subject)
|
||||
if selectors is None:
|
||||
raise ValueError("Identity DSAR subject selectors conflict or are incomplete.")
|
||||
actions: list[DsarErasureActionRef] = []
|
||||
for record in records:
|
||||
_validate_record(record)
|
||||
if record.resource_id != selectors.identity_id:
|
||||
raise ValueError("Identity DSAR record does not match the subject.")
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=f"identity:manual_review:canonical_identity:{record.resource_id}",
|
||||
provider_id=self.provider_id,
|
||||
module_id=self.module_id,
|
||||
kind="manual_review",
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=f"Review {record.title}",
|
||||
rationale=(
|
||||
"Canonical identities and account links are system-scoped and may "
|
||||
"support authentication or memberships in more than one tenant. "
|
||||
"Identity, Access, and tenancy owners must review deactivation, "
|
||||
"unlinking, or minimization together."
|
||||
),
|
||||
executable=False,
|
||||
)
|
||||
)
|
||||
return tuple(actions)
|
||||
|
||||
def execute_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
request_id: str,
|
||||
) -> Sequence[DsarExecutionResultRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _subject_selectors(subject) is None:
|
||||
raise ValueError("Identity DSAR subject selectors conflict or are incomplete.")
|
||||
results: list[DsarExecutionResultRef] = []
|
||||
for action in actions:
|
||||
_validate_action(action)
|
||||
if action.executable or action.kind != "manual_review":
|
||||
raise ValueError("Identity DSAR publishes manual-review actions only.")
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"The system identity and account link remain unchanged pending "
|
||||
"cross-tenant identity, authentication, and retention review."
|
||||
),
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||
references = subject.external_references
|
||||
identity_id = _coalesce(
|
||||
subject.identity_id,
|
||||
references.get("identity.id"),
|
||||
references.get("identity.identity"),
|
||||
)
|
||||
account_id = _coalesce(
|
||||
subject.account_id,
|
||||
references.get("identity.account"),
|
||||
references.get("access.account"),
|
||||
)
|
||||
link_id = _coalesce(
|
||||
references.get("identity.link"),
|
||||
references.get("identity.account_link"),
|
||||
)
|
||||
if _CONFLICT in (identity_id, account_id, link_id):
|
||||
return None
|
||||
normalized_identity = _optional_string(identity_id)
|
||||
normalized_account = _optional_string(account_id)
|
||||
normalized_link = _optional_string(link_id)
|
||||
if not normalized_identity or not (normalized_account or normalized_link):
|
||||
return None
|
||||
return _SubjectSelectors(
|
||||
identity_id=normalized_identity,
|
||||
account_id=normalized_account,
|
||||
link_id=normalized_link,
|
||||
)
|
||||
|
||||
|
||||
def _identity_record(
|
||||
identity: Identity,
|
||||
link: IdentityAccountLink,
|
||||
) -> DsarRecordRef:
|
||||
observed = max(
|
||||
value for value in (identity.updated_at, link.updated_at) if value is not None
|
||||
)
|
||||
return DsarRecordRef(
|
||||
provider_id="identity",
|
||||
module_id="identity",
|
||||
resource_type="canonical_identity",
|
||||
resource_id=identity.id,
|
||||
category="system_identity_and_account_link",
|
||||
title="Canonical identity and corroborated account link",
|
||||
data={
|
||||
"identity_id": identity.id,
|
||||
"display_name": (identity.display_name or "")[:255] or None,
|
||||
"external_subject": (identity.external_subject or "")[:255] or None,
|
||||
"source": identity.source,
|
||||
"is_active": identity.is_active,
|
||||
"created_at": _iso(identity.created_at),
|
||||
"updated_at": _iso(identity.updated_at),
|
||||
"matching_account_link": {
|
||||
"id": link.id,
|
||||
"account_id": link.account_id,
|
||||
"is_primary": link.is_primary,
|
||||
"source": link.source,
|
||||
"created_at": _iso(link.created_at),
|
||||
"updated_at": _iso(link.updated_at),
|
||||
},
|
||||
},
|
||||
observed_at=_aware(observed),
|
||||
retention_reason=(
|
||||
"The canonical identity and account link are system-scoped and require "
|
||||
"cross-tenant lifecycle review before alteration."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _coalesce(*values: str | None) -> str | None | object:
|
||||
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||
if len(normalized) > 1:
|
||||
return _CONFLICT
|
||||
return next(iter(normalized), None)
|
||||
|
||||
|
||||
def _optional_string(value: object) -> str | None:
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
aware = _aware(value)
|
||||
return aware.isoformat() if aware else None
|
||||
|
||||
|
||||
def _aware(value: datetime | None) -> datetime | None:
|
||||
if value is None or value.tzinfo is not None:
|
||||
return value
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Identity DSAR requires a SQLAlchemy Session.")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_record(record: DsarRecordRef) -> None:
|
||||
if record.provider_id != "identity" or record.module_id != "identity":
|
||||
raise ValueError("Identity DSAR cannot plan a foreign provider record.")
|
||||
if record.resource_type != "canonical_identity" or not record.resource_id:
|
||||
raise ValueError("Identity DSAR record identity is invalid.")
|
||||
|
||||
|
||||
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||
if action.provider_id != "identity" or action.module_id != "identity":
|
||||
raise ValueError("Identity DSAR cannot execute a foreign provider action.")
|
||||
if not action.action_id.startswith("identity:manual_review:"):
|
||||
raise ValueError("Identity DSAR action identity is invalid.")
|
||||
|
||||
|
||||
__all__ = ["IDENTITY_DSAR_CAPABILITY", "IdentityDsarProvider"]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""German translations for public structured documentation metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'identity.administration': {'outcome': 'Canonical Identität und Link Zustand ändert sich atomar '
|
||||
'mit system-scoped Audit-Beweis.',
|
||||
'prerequisites': ['Der Administrator verfügt über die Berechtigung '
|
||||
'zur Verwaltung der Systemidentität.',
|
||||
'Konto-IDs werden von einem autorisierten '
|
||||
'Access-Verwaltungsworkflow abgerufen.'],
|
||||
'verification': 'Laden Sie die Identität neu, überprüfen Sie den '
|
||||
'primären Marker und den Lebenszykluszustand und '
|
||||
'prüfen Sie dann die entsprechenden '
|
||||
'Systemaudit-Aufzeichnungen.'},
|
||||
'identity.data-subject-requests': {'consequence_classes': {'corroborated_export': 'Gibt nur ein '
|
||||
'übereinstimmendes '
|
||||
'Identitäts-/Konto-Link-Paar '
|
||||
'an.',
|
||||
'manual_erasure_review': 'Verhindert, '
|
||||
'dass eine '
|
||||
'Mandantenanfrage '
|
||||
'den '
|
||||
'systemweiten '
|
||||
'Identitätsstatus '
|
||||
'ändert.'}},
|
||||
'identity.lifecycle': {'outcome': 'Identitätssichtbarkeit oder Statusänderungen des Primärkontos, '
|
||||
'ohne die Herkunft des Kontolinks zu löschen.',
|
||||
'prerequisites': ['Der Anrufer hat eine separate Lifecycle-Berechtigung '
|
||||
'eingerichtet.',
|
||||
'Das Ersatz-Primärkonto ist bereits mit der Identität '
|
||||
'verknüpft.'],
|
||||
'verification': 'Bestätigen Sie gewöhnliche versus inaktive '
|
||||
'Verzeichnisergebnisse, überprüfen Sie jeden beibehaltenen '
|
||||
'Kontolink und überprüfen Sie den entsprechenden '
|
||||
'Identitätslebenszyklus-Audit-Record.'}}
|
||||
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
||||
|
||||
|
||||
class IdentityLifecycleError(ValueError):
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IdentityLifecycleResult:
|
||||
identity_id: str
|
||||
changed: bool
|
||||
previous_primary_account_id: str | None = None
|
||||
primary_account_id: str | None = None
|
||||
active: bool | None = None
|
||||
|
||||
|
||||
def set_identity_active(
|
||||
session: Session,
|
||||
*,
|
||||
identity_id: str,
|
||||
active: bool,
|
||||
actor_tenant_id: str | None,
|
||||
actor_user_id: str | None,
|
||||
actor_scope: str = "tenant",
|
||||
reason: str | None = None,
|
||||
) -> IdentityLifecycleResult:
|
||||
"""Change directory visibility without deleting identity or link evidence.
|
||||
|
||||
The caller owns authorization and the outer transaction. No commit occurs
|
||||
here, so an API, import, or reconciliation owner can roll the state and its
|
||||
audit record back together.
|
||||
"""
|
||||
|
||||
identity = session.get(Identity, identity_id)
|
||||
if identity is None:
|
||||
raise IdentityLifecycleError("identity_not_found", "Identity not found.")
|
||||
desired = bool(active)
|
||||
if identity.is_active == desired:
|
||||
return IdentityLifecycleResult(
|
||||
identity_id=identity.id,
|
||||
changed=False,
|
||||
active=identity.is_active,
|
||||
)
|
||||
with session.begin_nested():
|
||||
identity.is_active = desired
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=actor_tenant_id,
|
||||
user_id=actor_user_id,
|
||||
scope=actor_scope,
|
||||
action="identity.activated" if desired else "identity.deactivated",
|
||||
object_type="identity",
|
||||
object_id=identity.id,
|
||||
details={"active": desired, "reason": _bounded_reason(reason)},
|
||||
)
|
||||
session.flush()
|
||||
return IdentityLifecycleResult(
|
||||
identity_id=identity.id,
|
||||
changed=True,
|
||||
active=identity.is_active,
|
||||
)
|
||||
|
||||
|
||||
def set_primary_account(
|
||||
session: Session,
|
||||
*,
|
||||
identity_id: str,
|
||||
account_id: str,
|
||||
actor_tenant_id: str | None,
|
||||
actor_user_id: str | None,
|
||||
actor_scope: str = "tenant",
|
||||
reason: str | None = None,
|
||||
) -> IdentityLifecycleResult:
|
||||
"""Atomically promote an existing link and retain every other account link."""
|
||||
|
||||
identity = session.get(Identity, identity_id)
|
||||
if identity is None:
|
||||
raise IdentityLifecycleError("identity_not_found", "Identity not found.")
|
||||
target = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(
|
||||
IdentityAccountLink.identity_id == identity.id,
|
||||
IdentityAccountLink.account_id == account_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if target is None:
|
||||
raise IdentityLifecycleError(
|
||||
"account_not_linked",
|
||||
"The requested account is not linked to this identity.",
|
||||
)
|
||||
conflicting_primary = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(
|
||||
IdentityAccountLink.account_id == account_id,
|
||||
IdentityAccountLink.identity_id != identity.id,
|
||||
IdentityAccountLink.is_primary.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if conflicting_primary is not None:
|
||||
raise IdentityLifecycleError(
|
||||
"account_primary_elsewhere",
|
||||
"The requested account is already primary for another identity.",
|
||||
)
|
||||
previous = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(
|
||||
IdentityAccountLink.identity_id == identity.id,
|
||||
IdentityAccountLink.is_primary.is_(True),
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
previous_account_id = previous.account_id if previous is not None else None
|
||||
if previous is not None and previous.id == target.id:
|
||||
return IdentityLifecycleResult(
|
||||
identity_id=identity.id,
|
||||
changed=False,
|
||||
previous_primary_account_id=previous_account_id,
|
||||
primary_account_id=target.account_id,
|
||||
active=identity.is_active,
|
||||
)
|
||||
with session.begin_nested():
|
||||
if previous is not None:
|
||||
previous.is_primary = False
|
||||
# Partial unique indexes are evaluated per statement. Persist the
|
||||
# demotion before the promotion so SQLite and PostgreSQL never see
|
||||
# two primary links during the transition.
|
||||
session.flush()
|
||||
target.is_primary = True
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=actor_tenant_id,
|
||||
user_id=actor_user_id,
|
||||
scope=actor_scope,
|
||||
action="identity.primary_account_changed",
|
||||
object_type="identity",
|
||||
object_id=identity.id,
|
||||
details={
|
||||
"previous_primary_account_id": previous_account_id,
|
||||
"primary_account_id": target.account_id,
|
||||
"link_id": target.id,
|
||||
"link_source": target.source,
|
||||
"reason": _bounded_reason(reason),
|
||||
},
|
||||
)
|
||||
session.flush()
|
||||
return IdentityLifecycleResult(
|
||||
identity_id=identity.id,
|
||||
changed=True,
|
||||
previous_primary_account_id=previous_account_id,
|
||||
primary_account_id=target.account_id,
|
||||
active=identity.is_active,
|
||||
)
|
||||
|
||||
|
||||
def _bounded_reason(reason: str | None) -> str | None:
|
||||
normalized = str(reason or "").strip()
|
||||
return normalized[:500] or None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IdentityLifecycleError",
|
||||
"IdentityLifecycleResult",
|
||||
"set_identity_active",
|
||||
"set_primary_account",
|
||||
]
|
||||
@@ -1,10 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY
|
||||
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||
from govoplan_identity.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||
|
||||
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,
|
||||
)
|
||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import DocumentationTopic, MigrationSpec, ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
ViewSurface,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_identity.backend.db import models as identity_models # noqa: F401 - populate metadata
|
||||
from govoplan_identity.backend.dsar_provider import (
|
||||
IDENTITY_DSAR_CAPABILITY,
|
||||
IdentityDsarProvider,
|
||||
)
|
||||
|
||||
|
||||
def _permission(
|
||||
scope: str,
|
||||
label: str,
|
||||
description: str,
|
||||
*,
|
||||
level: str = "tenant",
|
||||
) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
scope=scope,
|
||||
label=label,
|
||||
description=description,
|
||||
category="Identity",
|
||||
level=level,
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(
|
||||
"identity:identity:read",
|
||||
"View identities",
|
||||
"Search and read normalized identities and their account links.",
|
||||
),
|
||||
_permission(
|
||||
"identity:identity:admin",
|
||||
"Administer identities",
|
||||
"Create, update, activate, and deactivate canonical system identities.",
|
||||
level="system",
|
||||
),
|
||||
_permission(
|
||||
"identity:account_link:admin",
|
||||
"Administer identity account links",
|
||||
"Add, remove, and select the primary platform account for a canonical identity.",
|
||||
level="system",
|
||||
),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
RoleTemplate(
|
||||
slug="identity_viewer",
|
||||
name="Identity viewer",
|
||||
description="Read normalized identities and account links.",
|
||||
permissions=("identity:identity:read",),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="identity_administrator",
|
||||
name="Identity administrator",
|
||||
description="Administer the canonical system identity directory and account links.",
|
||||
permissions=(
|
||||
"identity:identity:read",
|
||||
"identity:identity:admin",
|
||||
"identity:account_link:admin",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _route_factory(context: ModuleContext):
|
||||
del context
|
||||
from govoplan_identity.backend.api.v1.routes import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _identity_directory(context: ModuleContext) -> object:
|
||||
@@ -14,11 +110,52 @@ def _identity_directory(context: ModuleContext) -> object:
|
||||
return SqlIdentityDirectory()
|
||||
|
||||
|
||||
def _dsar_provider(context: ModuleContext) -> IdentityDsarProvider:
|
||||
del context
|
||||
return IdentityDsarProvider()
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="identity",
|
||||
name="Identity",
|
||||
version="0.1.6",
|
||||
migration_spec=MigrationSpec(module_id="identity", metadata=Base.metadata),
|
||||
version="0.1.21",
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
route_factory=_route_factory,
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name=IDENTITY_DSAR_CAPABILITY, version="0.1.0"),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id="identity",
|
||||
package_name="@govoplan/identity-webui",
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="identity.admin.directory",
|
||||
module_id="identity",
|
||||
kind="section",
|
||||
label="Identity directory",
|
||||
order=30,
|
||||
),
|
||||
ViewSurface(
|
||||
id="identity.admin.account-links",
|
||||
module_id="identity",
|
||||
kind="section",
|
||||
label="Identity account links",
|
||||
parent_id="identity.admin.directory",
|
||||
order=20,
|
||||
),
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="identity",
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
migration_after=("access",),
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
identity_models.Identity,
|
||||
@@ -28,8 +165,72 @@ manifest = ModuleManifest(
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_IDENTITY_DIRECTORY: _identity_directory,
|
||||
CAPABILITY_IDENTITY_SEARCH: _identity_directory,
|
||||
IDENTITY_DSAR_CAPABILITY: _dsar_provider,
|
||||
},
|
||||
capability_documentation={
|
||||
IDENTITY_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Identity data-subject request provider",
|
||||
summary=(
|
||||
"Exports a corroborated system identity and matching account link "
|
||||
"without automatically mutating cross-tenant identity state."
|
||||
),
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="identity.data-subject-requests",
|
||||
title="Identity data-subject requests",
|
||||
summary=(
|
||||
"Export a canonical identity only after its exact identity and "
|
||||
"account-link identifiers corroborate each other."
|
||||
),
|
||||
body=(
|
||||
"Identity records are system-scoped rather than tenant-owned. The "
|
||||
"data-subject provider therefore requires an exact identity identifier "
|
||||
"and either its exact linked account or account-link identifier before "
|
||||
"returning display, external-subject, lifecycle, and matching-link data. "
|
||||
"Other links and arbitrary identity settings are excluded. A tenant "
|
||||
"request cannot automatically deactivate the identity or remove the "
|
||||
"link because either action can affect authentication and memberships "
|
||||
"outside that tenant. Erasure is recorded as a manual review requiring "
|
||||
"Identity, Access, tenancy, and retention owners."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "system_admin", "identity_admin", "auditor"),
|
||||
related_modules=("core", "access", "tenancy"),
|
||||
order=23,
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Betroffenenanfragen für Identitäten",
|
||||
"summary": (
|
||||
"Eine kanonische Identität nur exportieren, wenn exakte Identitäts- und Kontoverknüpfungskennungen einander bestätigen."
|
||||
),
|
||||
"body": (
|
||||
"Identity-Datensätze sind systemweit und nicht mandanteneigen. Der Betroffenen-Provider verlangt deshalb eine exakte "
|
||||
"Identitätskennung und entweder das exakt verknüpfte Konto oder die Kennung der Kontoverknüpfung, bevor er Anzeige-, "
|
||||
"externes Subjekt-, Lebenszyklus- und passende Verknüpfungsdaten ausgibt. Andere Verknüpfungen und beliebige "
|
||||
"Identitätseinstellungen sind ausgeschlossen. Eine Mandantenanfrage darf die Identität nicht automatisch deaktivieren "
|
||||
"oder die Verknüpfung entfernen, weil beides Authentifizierung und Mitgliedschaften außerhalb dieses Mandanten beeinflussen "
|
||||
"kann. Eine Löschung wird als manuelle Prüfung unter Beteiligung der Zuständigen für Identity, Access, Tenancy und "
|
||||
"Aufbewahrung erfasst."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"help_contexts": ["privacy.data-subject-requests"],
|
||||
"consequence_classes": {
|
||||
"corroborated_export": (
|
||||
"Discloses one matching identity/account-link pair only."
|
||||
),
|
||||
"manual_erasure_review": (
|
||||
"Prevents a tenant request from changing system-wide identity state."
|
||||
),
|
||||
},
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="identity.model",
|
||||
title="Identity directory",
|
||||
@@ -41,9 +242,131 @@ manifest = ModuleManifest(
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Identitätsverzeichnis",
|
||||
"summary": (
|
||||
"Identity besitzt normalisierte Subjekte und verknüpft sie mit Plattformkonten; Access besitzt die Autorisierung."
|
||||
),
|
||||
"body": (
|
||||
"Eine Identität kann mehrere Konten besitzen. Identitätsmerkmale bleiben von Authentifizierungssitzungen, "
|
||||
"Organisationsfunktionen und Berechtigungsentscheidungen getrennt."
|
||||
),
|
||||
}
|
||||
},
|
||||
order=24,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="identity.administration",
|
||||
title="Administer the canonical identity directory",
|
||||
summary="Manage system-scoped identities and their account links without taking over authentication or access control.",
|
||||
body=(
|
||||
"The Identity administration surface lists, creates, inspects, updates, deactivates, and reactivates canonical identities. These records are system-scoped; the current tenant is shown only as the acting administrative context. Account references remain opaque to Identity and one account can be linked to only one identity through this administration API. The first link becomes primary automatically. A primary link cannot be removed while another link remains: promote the replacement first. Every write and primary-account transition is recorded as a system audit event. Deactivation is reversible and does not suspend authentication, erase links, or change permissions."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("system_admin", "identity_admin", "access_admin"),
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("identity",),
|
||||
any_scopes=(
|
||||
"identity:identity:read",
|
||||
"identity:identity:admin",
|
||||
"identity:account_link:admin",
|
||||
),
|
||||
),
|
||||
),
|
||||
order=26,
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Kanonisches Identitätsverzeichnis administrieren",
|
||||
"summary": (
|
||||
"Systemweite Identitäten und ihre Kontoverknüpfungen verwalten, ohne Authentifizierung oder Zugriffskontrolle zu übernehmen."
|
||||
),
|
||||
"body": (
|
||||
"Die Identity-Administrationsoberfläche listet, erstellt, prüft, aktualisiert, deaktiviert und reaktiviert kanonische "
|
||||
"Identitäten. Diese Datensätze sind systemweit; der aktuelle Mandant wird nur als administrativer Handlungskontext gezeigt. "
|
||||
"Kontoverweise bleiben für Identity undurchsichtig, und ein Konto darf über diese API nur mit einer Identität verknüpft "
|
||||
"sein. Die erste Verknüpfung wird automatisch primär. Eine primäre Verknüpfung kann nicht entfernt werden, solange eine "
|
||||
"weitere besteht; machen Sie zuerst den Ersatz primär. Jeder Schreibvorgang und jeder Wechsel des primären Kontos wird als "
|
||||
"System-Auditereignis festgehalten. Die Deaktivierung ist umkehrbar und sperrt weder die Authentifizierung noch löscht sie "
|
||||
"Verknüpfungen oder verändert Berechtigungen."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": ["identity.admin.directory"],
|
||||
"prerequisites": [
|
||||
"The administrator has system identity administration permission.",
|
||||
"Account IDs are obtained from an authorized Access administration workflow.",
|
||||
],
|
||||
"outcome": "Canonical identity and link state changes atomically with system-scoped audit evidence.",
|
||||
"verification": "Reload the identity, verify its primary marker and lifecycle state, then inspect the corresponding system audit records.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="identity.lifecycle",
|
||||
title="Administer identity and account-link lifecycle",
|
||||
summary="Deactivate identities reversibly and change the primary account without discarding link provenance.",
|
||||
body=(
|
||||
"Deactivation removes an identity from ordinary search while direct resolution retains an explicit inactive record; it preserves all account links and is not account suspension or erasure. "
|
||||
"A primary-account change selects an existing link, atomically demotes the previous primary, preserves multiple-account compatibility, and records actor, old/new account, and link-source evidence. The authorized caller commits or rolls back state and audit evidence together."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
order=25,
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Lebenszyklus von Identitäten und Kontoverknüpfungen administrieren",
|
||||
"summary": (
|
||||
"Identitäten umkehrbar deaktivieren und das primäre Konto ändern, ohne die Herkunft der Verknüpfungen zu verwerfen."
|
||||
),
|
||||
"body": (
|
||||
"Eine Deaktivierung entfernt die Identität aus der gewöhnlichen Suche, während die direkte Auflösung einen ausdrücklich "
|
||||
"inaktiven Datensatz beibehält. Alle Kontoverknüpfungen bleiben erhalten; es handelt sich weder um Kontosperrung noch "
|
||||
"Löschung. Ein Wechsel des primären Kontos wählt eine bestehende Verknüpfung, stuft das bisherige primäre Konto atomar "
|
||||
"zurück, erhält die Kompatibilität mehrerer Konten und zeichnet handelnde Person, altes/neues Konto und "
|
||||
"Verknüpfungsquellennachweis auf. Der berechtigte Aufrufer schreibt Zustand und Auditnachweis gemeinsam fest oder setzt "
|
||||
"beides zurück."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"prerequisites": [
|
||||
"The caller has separately established lifecycle authority.",
|
||||
"The replacement primary account is already linked to the identity.",
|
||||
],
|
||||
"outcome": "Identity visibility or primary-account state changes without deleting account-link provenance.",
|
||||
"verification": "Confirm ordinary versus include-inactive directory results, inspect every retained account link, and review the matching identity lifecycle audit record.",
|
||||
},
|
||||
),
|
||||
),
|
||||
architecture=declared_module_architecture(
|
||||
layer="institutional_foundation",
|
||||
kind="foundation",
|
||||
maturity="vertical_slice",
|
||||
documentation_ref="docs/IDENTITY_MODEL.md",
|
||||
test_ref="tests/test_directory.py",
|
||||
known_limits=(
|
||||
"Identity proofing and external-directory reconciliation are outside the current vertical slice.",
|
||||
),
|
||||
owned_concepts=("identity", "identity-account link"),
|
||||
non_owned_concepts=(
|
||||
"account authentication",
|
||||
"function assignment",
|
||||
"contact point",
|
||||
"organization",
|
||||
),
|
||||
security_docs=("docs/IDENTITY_MODEL.md",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
manifest = with_documentation_structured_translations(
|
||||
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Identity module migrations."""
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
"""identity directory
|
||||
|
||||
Revision ID: 5c6d7e8f9a10
|
||||
Revises: 4a5b6c7d8e9f
|
||||
Create Date: 2026-07-11 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "5c6d7e8f9a10"
|
||||
down_revision = "4a5b6c7d8e9f"
|
||||
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, **kwargs) -> None:
|
||||
if table_name in _tables() and name not in _indexes(table_name):
|
||||
op.create_index(name, table_name, columns, unique=unique, **kwargs)
|
||||
|
||||
|
||||
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:
|
||||
tables = _tables()
|
||||
if "identity_identities" not in tables:
|
||||
op.create_table(
|
||||
"identity_identities",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=255), nullable=True),
|
||||
sa.Column("external_subject", sa.String(length=255), nullable=True),
|
||||
sa.Column("source", sa.String(length=50), nullable=False),
|
||||
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.PrimaryKeyConstraint("id", name=op.f("pk_identity_identities")),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_identity_identities_external_subject"), "identity_identities", ["external_subject"])
|
||||
|
||||
tables = _tables()
|
||||
if "identity_account_links" not in tables:
|
||||
op.create_table(
|
||||
"identity_account_links",
|
||||
sa.Column("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=False),
|
||||
sa.Column("is_primary", sa.Boolean(), nullable=False),
|
||||
sa.Column("source", sa.String(length=50), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["identity_id"],
|
||||
["identity_identities.id"],
|
||||
name=op.f("fk_identity_account_links_identity_id_identity_identities"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_identity_account_links")),
|
||||
sa.UniqueConstraint("identity_id", "account_id", name="uq_identity_module_account_links_identity_account"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_identity_account_links_account_id"), "identity_account_links", ["account_id"])
|
||||
_create_index_if_missing(op.f("ix_identity_account_links_identity_id"), "identity_account_links", ["identity_id"])
|
||||
_create_index_if_missing(
|
||||
"uq_identity_module_account_links_primary_account",
|
||||
"identity_account_links",
|
||||
["account_id"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("is_primary = 1"),
|
||||
postgresql_where=sa.text("is_primary IS TRUE"),
|
||||
)
|
||||
_create_index_if_missing(
|
||||
"uq_identity_module_account_links_primary_identity",
|
||||
"identity_account_links",
|
||||
["identity_id"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("is_primary = 1"),
|
||||
postgresql_where=sa.text("is_primary IS TRUE"),
|
||||
)
|
||||
|
||||
tables = _tables()
|
||||
if {"access_identities", "access_identity_account_links"}.issubset(tables):
|
||||
op.execute("""
|
||||
INSERT INTO identity_identities
|
||||
(id, display_name, external_subject, source, is_active, settings, created_at, updated_at)
|
||||
SELECT id, display_name, external_subject, source, is_active, settings, created_at, updated_at
|
||||
FROM access_identities source_identity
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM identity_identities target_identity
|
||||
WHERE target_identity.id = source_identity.id
|
||||
)
|
||||
""")
|
||||
op.execute("""
|
||||
INSERT INTO identity_account_links
|
||||
(id, identity_id, account_id, is_primary, source, created_at, updated_at)
|
||||
SELECT id, identity_id, account_id, is_primary, source, created_at, updated_at
|
||||
FROM access_identity_account_links source_link
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM identity_identities target_identity
|
||||
WHERE target_identity.id = source_link.identity_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM identity_account_links target_link
|
||||
WHERE target_link.id = source_link.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM identity_account_links target_link
|
||||
WHERE target_link.identity_id = source_link.identity_id
|
||||
AND target_link.account_id = source_link.account_id
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_drop_index_if_exists("uq_identity_module_account_links_primary_identity", "identity_account_links")
|
||||
_drop_index_if_exists("uq_identity_module_account_links_primary_account", "identity_account_links")
|
||||
_drop_index_if_exists(op.f("ix_identity_account_links_identity_id"), "identity_account_links")
|
||||
_drop_index_if_exists(op.f("ix_identity_account_links_account_id"), "identity_account_links")
|
||||
if "identity_account_links" in _tables():
|
||||
op.drop_table("identity_account_links")
|
||||
_drop_index_if_exists(op.f("ix_identity_identities_external_subject"), "identity_identities")
|
||||
if "identity_identities" in _tables():
|
||||
op.drop_table("identity_identities")
|
||||
@@ -0,0 +1 @@
|
||||
"""Identity module migration versions."""
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
"""v0.1.7 identity baseline
|
||||
|
||||
Revision ID: 5c6d7e8f9a10
|
||||
Revises: None
|
||||
Create Date: 2026-07-11 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '5c6d7e8f9a10'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = ('4f2a9c8e7b6d', '4a5b6c7d8e9f')
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('identity_identities',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('display_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('external_subject', sa.String(length=255), nullable=True),
|
||||
sa.Column('source', sa.String(length=50), nullable=False),
|
||||
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.PrimaryKeyConstraint('id', name=op.f('pk_identity_identities'))
|
||||
)
|
||||
op.create_index(op.f('ix_identity_identities_external_subject'), 'identity_identities', ['external_subject'], unique=False)
|
||||
op.create_table('identity_account_links',
|
||||
sa.Column('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=False),
|
||||
sa.Column('is_primary', sa.Boolean(), nullable=False),
|
||||
sa.Column('source', sa.String(length=50), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['identity_id'], ['identity_identities.id'], name=op.f('fk_identity_account_links_identity_id_identity_identities'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_identity_account_links')),
|
||||
sa.UniqueConstraint('identity_id', 'account_id', name='uq_identity_module_account_links_identity_account')
|
||||
)
|
||||
op.create_index(op.f('ix_identity_account_links_account_id'), 'identity_account_links', ['account_id'], unique=False)
|
||||
op.create_index(op.f('ix_identity_account_links_identity_id'), 'identity_account_links', ['identity_id'], unique=False)
|
||||
op.create_index('uq_identity_module_account_links_primary_account', 'identity_account_links', ['account_id'], unique=True, sqlite_where=sa.text('is_primary = 1'), postgresql_where=sa.text('is_primary IS TRUE'))
|
||||
op.create_index('uq_identity_module_account_links_primary_identity', 'identity_account_links', ['identity_id'], unique=True, sqlite_where=sa.text('is_primary = 1'), postgresql_where=sa.text('is_primary IS TRUE'))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('identity_account_links')
|
||||
op.drop_table('identity_identities')
|
||||
@@ -0,0 +1 @@
|
||||
"""Identity module migration versions."""
|
||||
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_identity.backend.api.v1.routes import router
|
||||
|
||||
|
||||
class IdentityAdminApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.root = Path(tempfile.mkdtemp(prefix="govoplan-identity-admin-api-"))
|
||||
self.database = configure_database(f"sqlite:///{self.root / 'identity.db'}")
|
||||
Base.metadata.create_all(self.database.engine)
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
app.dependency_overrides[get_api_principal] = self._principal
|
||||
self.client = TestClient(app)
|
||||
self.audit_patches = (
|
||||
patch("govoplan_identity.backend.api.v1.routes.audit_from_principal"),
|
||||
patch("govoplan_identity.backend.lifecycle.audit_event"),
|
||||
)
|
||||
self.route_audit = self.audit_patches[0].start()
|
||||
self.lifecycle_audit = self.audit_patches[1].start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.client.close()
|
||||
for item in reversed(self.audit_patches):
|
||||
item.stop()
|
||||
reset_database(dispose=True)
|
||||
shutil.rmtree(self.root, ignore_errors=True)
|
||||
|
||||
def test_primary_lifecycle_and_system_scope_are_enforced(self) -> None:
|
||||
created = self.client.post(
|
||||
"/api/v1/identity/identities",
|
||||
json={
|
||||
"display_name": "Ada Example",
|
||||
"external_subject": "subject-ada",
|
||||
"source": "local",
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, created.status_code, created.text)
|
||||
identity = created.json()
|
||||
self.assertEqual("system", identity["management_scope"])
|
||||
identity_id = identity["id"]
|
||||
|
||||
first = self.client.post(
|
||||
f"/api/v1/identity/identities/{identity_id}/account-links",
|
||||
json={"account_id": "account-1", "source": "local"},
|
||||
)
|
||||
self.assertEqual(201, first.status_code, first.text)
|
||||
self.assertEqual("account-1", first.json()["primary_account_id"])
|
||||
|
||||
second = self.client.post(
|
||||
f"/api/v1/identity/identities/{identity_id}/account-links",
|
||||
json={"account_id": "account-2", "source": "idm:accepted"},
|
||||
)
|
||||
self.assertEqual(201, second.status_code, second.text)
|
||||
links = second.json()["account_links"]
|
||||
first_link = next(item for item in links if item["account_id"] == "account-1")
|
||||
second_link = next(item for item in links if item["account_id"] == "account-2")
|
||||
|
||||
blocked = self.client.delete(
|
||||
f"/api/v1/identity/identities/{identity_id}/account-links/{first_link['id']}"
|
||||
)
|
||||
self.assertEqual(409, blocked.status_code, blocked.text)
|
||||
|
||||
promoted = self.client.patch(
|
||||
f"/api/v1/identity/identities/{identity_id}/account-links/{second_link['id']}",
|
||||
json={"is_primary": True, "reason": "Preferred institutional account"},
|
||||
)
|
||||
self.assertEqual(200, promoted.status_code, promoted.text)
|
||||
self.assertEqual("account-2", promoted.json()["primary_account_id"])
|
||||
|
||||
removed = self.client.delete(
|
||||
f"/api/v1/identity/identities/{identity_id}/account-links/{first_link['id']}"
|
||||
)
|
||||
self.assertEqual(204, removed.status_code, removed.text)
|
||||
|
||||
deactivated = self.client.post(
|
||||
f"/api/v1/identity/identities/{identity_id}/deactivate",
|
||||
json={"reason": "Duplicate subject under review"},
|
||||
)
|
||||
self.assertEqual(200, deactivated.status_code, deactivated.text)
|
||||
self.assertEqual("inactive", deactivated.json()["status"])
|
||||
|
||||
default_list = self.client.get("/api/v1/identity/identities")
|
||||
self.assertEqual([], default_list.json()["identities"])
|
||||
inclusive_list = self.client.get(
|
||||
"/api/v1/identity/identities",
|
||||
params={"include_inactive": "true"},
|
||||
)
|
||||
self.assertEqual(identity_id, inclusive_list.json()["identities"][0]["id"])
|
||||
self.assertEqual("system", inclusive_list.json()["management_scope"])
|
||||
self.assertEqual("tenant-1", inclusive_list.json()["tenant_context_id"])
|
||||
|
||||
self.assertTrue(
|
||||
all(call.kwargs["scope"] == "system" for call in self.route_audit.call_args_list)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(call.kwargs["scope"] == "system" for call in self.lifecycle_audit.call_args_list)
|
||||
)
|
||||
|
||||
def test_account_can_only_be_linked_to_one_identity(self) -> None:
|
||||
identity_ids = []
|
||||
for name in ("Ada", "Grace"):
|
||||
response = self.client.post(
|
||||
"/api/v1/identity/identities",
|
||||
json={"display_name": name},
|
||||
)
|
||||
self.assertEqual(201, response.status_code, response.text)
|
||||
identity_ids.append(response.json()["id"])
|
||||
|
||||
first = self.client.post(
|
||||
f"/api/v1/identity/identities/{identity_ids[0]}/account-links",
|
||||
json={"account_id": "account-shared"},
|
||||
)
|
||||
self.assertEqual(201, first.status_code, first.text)
|
||||
conflict = self.client.post(
|
||||
f"/api/v1/identity/identities/{identity_ids[1]}/account-links",
|
||||
json={"account_id": "account-shared"},
|
||||
)
|
||||
self.assertEqual(409, conflict.status_code, conflict.text)
|
||||
|
||||
@staticmethod
|
||||
def _principal() -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-admin",
|
||||
membership_id="user-admin",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset(
|
||||
{
|
||||
"identity:identity:read",
|
||||
"identity:identity:admin",
|
||||
"identity:account_link:admin",
|
||||
}
|
||||
),
|
||||
),
|
||||
account=SimpleNamespace(id="account-admin"),
|
||||
user=SimpleNamespace(id="user-admin"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.identity import IdentitySearchProvider
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
||||
from govoplan_identity.backend.directory import SqlIdentityDirectory
|
||||
|
||||
|
||||
class IdentityDirectoryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.database = configure_database("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.database.engine,
|
||||
tables=[Identity.__table__, IdentityAccountLink.__table__],
|
||||
)
|
||||
with self.database.session() as session:
|
||||
active = Identity(
|
||||
id="identity-active",
|
||||
display_name="Ada Example",
|
||||
external_subject="subject-active",
|
||||
source="local",
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
inactive = Identity(
|
||||
id="identity-inactive",
|
||||
display_name="Inactive Person",
|
||||
external_subject="subject-inactive",
|
||||
source="local",
|
||||
is_active=False,
|
||||
settings={},
|
||||
)
|
||||
session.add_all([active, inactive])
|
||||
session.flush()
|
||||
session.add_all(
|
||||
[
|
||||
IdentityAccountLink(
|
||||
id="link-active",
|
||||
identity_id=active.id,
|
||||
account_id="account-ada",
|
||||
is_primary=True,
|
||||
source="local",
|
||||
),
|
||||
IdentityAccountLink(
|
||||
id="link-inactive",
|
||||
identity_id=inactive.id,
|
||||
account_id="account-inactive",
|
||||
is_primary=True,
|
||||
source="local",
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_database(dispose=True)
|
||||
|
||||
def test_search_capability_filters_inactive_identities_and_searches_account_ids(self) -> None:
|
||||
directory = SqlIdentityDirectory()
|
||||
|
||||
self.assertIsInstance(directory, IdentitySearchProvider)
|
||||
self.assertEqual(
|
||||
("identity-active",),
|
||||
tuple(identity.id for identity in directory.search_identities()),
|
||||
)
|
||||
matches = directory.search_identities("account-ada")
|
||||
self.assertEqual(("identity-active",), tuple(identity.id for identity in matches))
|
||||
self.assertEqual(("account-ada",), matches[0].account_ids)
|
||||
self.assertEqual("account-ada", matches[0].primary_account_id)
|
||||
|
||||
def test_search_capability_can_include_inactive_identities(self) -> None:
|
||||
directory = SqlIdentityDirectory()
|
||||
|
||||
matches = directory.search_identities("inactive", include_inactive=True)
|
||||
|
||||
self.assertEqual(("identity-inactive",), tuple(identity.id for identity in matches))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_identity.backend.manifest import manifest
|
||||
|
||||
|
||||
class IdentityDocumentationContractTests(unittest.TestCase):
|
||||
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||
for topic in manifest.documentation:
|
||||
german = (topic.translations or {}).get("de", {})
|
||||
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
|
||||
self.assertTrue(
|
||||
all(str(value).strip() for value in german.values()), topic.id
|
||||
)
|
||||
|
||||
def test_administration_is_permission_conditioned_workflow(self) -> None:
|
||||
topic = next(
|
||||
item
|
||||
for item in manifest.documentation
|
||||
if item.id == "identity.administration"
|
||||
)
|
||||
self.assertEqual("workflow", topic.metadata["kind"])
|
||||
self.assertTrue(topic.conditions)
|
||||
self.assertIn("user", topic.documentation_types)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,234 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import DsarErasureActionRef, DsarProvider, DsarSubjectRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.privacy.dsar_workflow import (
|
||||
create_data_subject_request,
|
||||
search_data_subject_request,
|
||||
)
|
||||
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
||||
from govoplan_identity.backend.dsar_provider import (
|
||||
IDENTITY_DSAR_CAPABILITY,
|
||||
IdentityDsarProvider,
|
||||
)
|
||||
from govoplan_identity.backend.manifest import manifest
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider: IdentityDsarProvider) -> None:
|
||||
self.provider = provider
|
||||
|
||||
def capability_names(self):
|
||||
return (IDENTITY_DSAR_CAPABILITY,)
|
||||
|
||||
def capability_owner(self, name):
|
||||
if name != IDENTITY_DSAR_CAPABILITY:
|
||||
raise KeyError(name)
|
||||
return "identity"
|
||||
|
||||
def tenant_entitlement_resolver(self):
|
||||
class _Resolver:
|
||||
@staticmethod
|
||||
def resolve(session, tenant_id):
|
||||
del session, tenant_id
|
||||
return type("State", (), {"effective_modules": ("identity",)})()
|
||||
|
||||
return _Resolver()
|
||||
|
||||
def require_tenant_capability(self, name, session, **kwargs):
|
||||
del session, kwargs
|
||||
if name != IDENTITY_DSAR_CAPABILITY:
|
||||
raise KeyError(name)
|
||||
return self.provider
|
||||
|
||||
def manifests(self):
|
||||
return (type("Manifest", (), {"id": "identity"})(),)
|
||||
|
||||
|
||||
class IdentityDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.provider = IdentityDsarProvider()
|
||||
self.assertIsInstance(self.provider, DsarProvider)
|
||||
identity = Identity(
|
||||
id="identity-1",
|
||||
display_name="Ada Example",
|
||||
external_subject="external-ada",
|
||||
source="directory",
|
||||
is_active=True,
|
||||
settings={"secret": "identity-setting-do-not-export"},
|
||||
)
|
||||
other = Identity(
|
||||
id="identity-other",
|
||||
display_name="Other Person",
|
||||
external_subject="external-other",
|
||||
source="local",
|
||||
is_active=True,
|
||||
settings={"private": "other-setting"},
|
||||
)
|
||||
self.session.add_all((identity, other))
|
||||
self.session.flush()
|
||||
self.session.add_all(
|
||||
(
|
||||
IdentityAccountLink(
|
||||
id="link-1",
|
||||
identity_id="identity-1",
|
||||
account_id="account-1",
|
||||
is_primary=True,
|
||||
source="directory",
|
||||
),
|
||||
IdentityAccountLink(
|
||||
id="link-secondary",
|
||||
identity_id="identity-1",
|
||||
account_id="account-secondary",
|
||||
is_primary=False,
|
||||
source="local",
|
||||
),
|
||||
IdentityAccountLink(
|
||||
id="link-other",
|
||||
identity_id="identity-other",
|
||||
account_id="account-other",
|
||||
is_primary=True,
|
||||
source="local",
|
||||
),
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
@staticmethod
|
||||
def _subject() -> DsarSubjectRef:
|
||||
return DsarSubjectRef(identity_id="identity-1", account_id="account-1")
|
||||
|
||||
def test_search_requires_and_exports_one_corroborated_link(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self._subject(),
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(records))
|
||||
exported = json.dumps(records[0].to_dict())
|
||||
self.assertIn("Ada Example", exported)
|
||||
self.assertIn("external-ada", exported)
|
||||
self.assertIn("link-1", exported)
|
||||
self.assertNotIn("account-secondary", exported)
|
||||
self.assertNotIn("Other Person", exported)
|
||||
self.assertNotIn("identity-setting-do-not-export", exported)
|
||||
|
||||
def test_incomplete_conflicting_and_mismatched_selectors_fail_closed(self) -> None:
|
||||
subjects = (
|
||||
DsarSubjectRef(identity_id="identity-1"),
|
||||
DsarSubjectRef(account_id="account-1"),
|
||||
DsarSubjectRef(identity_id="identity-1", account_id="account-other"),
|
||||
DsarSubjectRef(
|
||||
identity_id="identity-1",
|
||||
account_id="account-1",
|
||||
external_references={"identity.account": "account-other"},
|
||||
),
|
||||
)
|
||||
for subject in subjects:
|
||||
with self.subTest(subject=subject):
|
||||
self.assertEqual(
|
||||
(),
|
||||
self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
),
|
||||
)
|
||||
|
||||
def test_exact_link_can_corroborate_identity(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
identity_id="identity-1",
|
||||
external_references={"identity.link": "link-1"},
|
||||
),
|
||||
)
|
||||
self.assertEqual(["identity-1"], [record.resource_id for record in records])
|
||||
|
||||
def test_erasure_requires_cross_tenant_manual_review(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session, tenant_id="tenant-1", subject=self._subject()
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self._subject(),
|
||||
records=records,
|
||||
)
|
||||
self.assertEqual(["manual_review"], [action.kind for action in actions])
|
||||
results = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self._subject(),
|
||||
actions=actions,
|
||||
request_id="dsar-1",
|
||||
)
|
||||
self.assertEqual(["blocked"], [result.status for result in results])
|
||||
self.assertEqual(3, self.session.query(IdentityAccountLink).count())
|
||||
|
||||
def test_foreign_actions_are_rejected(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self._subject(),
|
||||
actions=(
|
||||
DsarErasureActionRef(
|
||||
action_id="other:manual_review:x",
|
||||
provider_id="other",
|
||||
module_id="other",
|
||||
kind="manual_review",
|
||||
resource_type="canonical_identity",
|
||||
resource_id="identity-1",
|
||||
title="Foreign",
|
||||
rationale="Foreign",
|
||||
executable=False,
|
||||
),
|
||||
),
|
||||
request_id="dsar-1",
|
||||
)
|
||||
|
||||
def test_manifest_and_core_workflow_discover_provider(self) -> None:
|
||||
self.assertIn(IDENTITY_DSAR_CAPABILITY, manifest.capability_factories)
|
||||
self.assertIn(
|
||||
IDENTITY_DSAR_CAPABILITY,
|
||||
{item.name for item in manifest.provides_interfaces},
|
||||
)
|
||||
row = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-IDENTITY-1",
|
||||
request_kind="access",
|
||||
subject=self._subject(),
|
||||
purpose="Identity access request",
|
||||
legal_basis=None,
|
||||
due_at=None,
|
||||
requested_by_account_id="operator-1",
|
||||
)
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider),
|
||||
row=row,
|
||||
expected_revision=row.resource_revision,
|
||||
)
|
||||
self.assertEqual("searched", row.status)
|
||||
self.assertEqual(1, row.search_result["record_count"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
||||
from govoplan_identity.backend.directory import SqlIdentityDirectory
|
||||
from govoplan_identity.backend.lifecycle import (
|
||||
IdentityLifecycleError,
|
||||
set_identity_active,
|
||||
set_primary_account,
|
||||
)
|
||||
|
||||
|
||||
class IdentityLifecycleTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.database = configure_database("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.database.engine,
|
||||
tables=[Identity.__table__, IdentityAccountLink.__table__],
|
||||
)
|
||||
with self.database.session() as session:
|
||||
session.add(Identity(id="identity-1", display_name="Ada", source="local", is_active=True, settings={}))
|
||||
session.add_all(
|
||||
[
|
||||
IdentityAccountLink(id="link-1", identity_id="identity-1", account_id="account-1", is_primary=True, source="local"),
|
||||
IdentityAccountLink(id="link-2", identity_id="identity-1", account_id="account-2", is_primary=False, source="idm:accepted"),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_database(dispose=True)
|
||||
|
||||
@patch("govoplan_identity.backend.lifecycle.audit_event")
|
||||
def test_primary_account_change_preserves_all_links_and_audits_source(self, audit) -> None:
|
||||
with self.database.session() as session:
|
||||
result = set_primary_account(
|
||||
session,
|
||||
identity_id="identity-1",
|
||||
account_id="account-2",
|
||||
actor_tenant_id="tenant-1",
|
||||
actor_user_id="user-1",
|
||||
reason="Preferred institutional account",
|
||||
)
|
||||
session.commit()
|
||||
|
||||
self.assertTrue(result.changed)
|
||||
self.assertEqual("account-1", result.previous_primary_account_id)
|
||||
self.assertEqual("account-2", result.primary_account_id)
|
||||
with self.database.session() as session:
|
||||
links = session.query(IdentityAccountLink).filter_by(identity_id="identity-1").order_by(IdentityAccountLink.account_id).all()
|
||||
self.assertEqual([False, True], [item.is_primary for item in links])
|
||||
resolved = SqlIdentityDirectory().get_identity("identity-1")
|
||||
self.assertIsNotNone(resolved)
|
||||
self.assertEqual(("account-1", "account-2"), tuple(sorted(resolved.account_ids)))
|
||||
self.assertEqual("account-2", resolved.primary_account_id)
|
||||
self.assertEqual("identity.primary_account_changed", audit.call_args.kwargs["action"])
|
||||
self.assertEqual("idm:accepted", audit.call_args.kwargs["details"]["link_source"])
|
||||
|
||||
@patch("govoplan_identity.backend.lifecycle.audit_event")
|
||||
def test_deactivation_is_reversible_and_preserves_account_links(self, audit) -> None:
|
||||
with self.database.session() as session:
|
||||
changed = set_identity_active(
|
||||
session,
|
||||
identity_id="identity-1",
|
||||
active=False,
|
||||
actor_tenant_id="tenant-1",
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
session.commit()
|
||||
self.assertTrue(changed.changed)
|
||||
inactive = SqlIdentityDirectory().get_identity("identity-1")
|
||||
self.assertIsNotNone(inactive)
|
||||
self.assertEqual("inactive", inactive.status)
|
||||
self.assertEqual((), SqlIdentityDirectory().search_identities())
|
||||
with self.database.session() as session:
|
||||
self.assertEqual(2, session.query(IdentityAccountLink).filter_by(identity_id="identity-1").count())
|
||||
set_identity_active(
|
||||
session,
|
||||
identity_id="identity-1",
|
||||
active=True,
|
||||
actor_tenant_id="tenant-1",
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
session.commit()
|
||||
self.assertIsNotNone(SqlIdentityDirectory().get_identity("identity-1"))
|
||||
self.assertEqual(["identity.deactivated", "identity.activated"], [call.kwargs["action"] for call in audit.call_args_list])
|
||||
|
||||
@patch("govoplan_identity.backend.lifecycle.audit_event")
|
||||
def test_invalid_primary_change_does_not_mutate_or_audit(self, audit) -> None:
|
||||
with self.database.session() as session:
|
||||
with self.assertRaises(IdentityLifecycleError) as raised:
|
||||
set_primary_account(
|
||||
session,
|
||||
identity_id="identity-1",
|
||||
account_id="not-linked",
|
||||
actor_tenant_id="tenant-1",
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
self.assertEqual("account_not_linked", raised.exception.code)
|
||||
links = session.query(IdentityAccountLink).filter_by(identity_id="identity-1").order_by(IdentityAccountLink.account_id).all()
|
||||
self.assertEqual([True, False], [item.is_primary for item in links])
|
||||
audit.assert_not_called()
|
||||
|
||||
@patch("govoplan_identity.backend.lifecycle.audit_event", side_effect=RuntimeError("audit unavailable"))
|
||||
def test_primary_change_rolls_back_when_audit_cannot_be_recorded(self, _audit) -> None:
|
||||
with self.database.session() as session:
|
||||
with self.assertRaisesRegex(RuntimeError, "audit unavailable"):
|
||||
set_primary_account(
|
||||
session,
|
||||
identity_id="identity-1",
|
||||
account_id="account-2",
|
||||
actor_tenant_id="tenant-1",
|
||||
actor_user_id="user-1",
|
||||
)
|
||||
session.expire_all()
|
||||
links = session.query(IdentityAccountLink).filter_by(identity_id="identity-1").order_by(IdentityAccountLink.account_id).all()
|
||||
self.assertEqual([True, False], [item.is_primary for item in links])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@govoplan/identity-webui",
|
||||
"version": "0.1.21",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./styles/identity.css": "./src/styles/identity.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test:identity-admin-ui": "node tests/identity-admin-ui-structure.test.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
apiFetch,
|
||||
apiPath,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
export type IdentityAccountLink = {
|
||||
id: string;
|
||||
identity_id: string;
|
||||
account_id: string;
|
||||
is_primary: boolean;
|
||||
source: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type IdentityItem = {
|
||||
id: string;
|
||||
display_name?: string | null;
|
||||
external_subject?: string | null;
|
||||
source: string;
|
||||
primary_account_id?: string | null;
|
||||
account_ids: string[];
|
||||
account_links: IdentityAccountLink[];
|
||||
status: "active" | "inactive";
|
||||
is_active: boolean;
|
||||
settings: Record<string, unknown>;
|
||||
management_scope: "system";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type IdentityDraft = {
|
||||
display_name?: string | null;
|
||||
external_subject?: string | null;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export async function listIdentities(
|
||||
settings: ApiSettings,
|
||||
query = "",
|
||||
includeInactive = true
|
||||
): Promise<IdentityItem[]> {
|
||||
const result = await apiFetch<{ identities: IdentityItem[] }>(
|
||||
settings,
|
||||
apiPath("/api/v1/identity/identities", {
|
||||
query: query.trim() || undefined,
|
||||
include_inactive: includeInactive,
|
||||
limit: 500
|
||||
})
|
||||
);
|
||||
return result.identities;
|
||||
}
|
||||
|
||||
export function createIdentity(
|
||||
settings: ApiSettings,
|
||||
payload: IdentityDraft
|
||||
): Promise<IdentityItem> {
|
||||
return apiFetch(settings, "/api/v1/identity/identities", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateIdentity(
|
||||
settings: ApiSettings,
|
||||
identityId: string,
|
||||
payload: Partial<IdentityDraft>
|
||||
): Promise<IdentityItem> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/identity/identities/${encodeURIComponent(identityId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function setIdentityActive(
|
||||
settings: ApiSettings,
|
||||
identityId: string,
|
||||
active: boolean,
|
||||
reason: string
|
||||
): Promise<IdentityItem> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/identity/identities/${encodeURIComponent(identityId)}/${active ? "activate" : "deactivate"}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason: reason.trim() || null })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function addIdentityAccountLink(
|
||||
settings: ApiSettings,
|
||||
identityId: string,
|
||||
payload: {
|
||||
account_id: string;
|
||||
source: string;
|
||||
make_primary: boolean;
|
||||
reason?: string | null;
|
||||
}
|
||||
): Promise<IdentityItem> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/identity/identities/${encodeURIComponent(identityId)}/account-links`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function promoteIdentityAccountLink(
|
||||
settings: ApiSettings,
|
||||
identityId: string,
|
||||
linkId: string,
|
||||
reason: string
|
||||
): Promise<IdentityItem> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/identity/identities/${encodeURIComponent(identityId)}/account-links/${encodeURIComponent(linkId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
is_primary: true,
|
||||
reason: reason.trim() || null
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function removeIdentityAccountLink(
|
||||
settings: ApiSettings,
|
||||
identityId: string,
|
||||
linkId: string
|
||||
): Promise<void> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/identity/identities/${encodeURIComponent(identityId)}/account-links/${encodeURIComponent(linkId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
import {
|
||||
Plus,
|
||||
Star,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
UserMinus
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AdminPageLayout,
|
||||
Button,
|
||||
Card,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
FilterBar,
|
||||
FormField,
|
||||
FormGrid,
|
||||
MetricCard,
|
||||
MetricGrid,
|
||||
PageActionBar,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent,
|
||||
StatePanel,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
WorkspaceLayout,
|
||||
formatDateTime,
|
||||
hasScope,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
addIdentityAccountLink,
|
||||
createIdentity,
|
||||
listIdentities,
|
||||
promoteIdentityAccountLink,
|
||||
removeIdentityAccountLink,
|
||||
setIdentityActive,
|
||||
updateIdentity,
|
||||
type IdentityAccountLink,
|
||||
type IdentityDraft,
|
||||
type IdentityItem
|
||||
} from "../api/identities";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
};
|
||||
|
||||
type LinkDraft = {
|
||||
accountId: string;
|
||||
source: string;
|
||||
makePrimary: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
const EMPTY_DRAFT: IdentityDraft = {
|
||||
display_name: "",
|
||||
external_subject: "",
|
||||
source: "local"
|
||||
};
|
||||
|
||||
const EMPTY_LINK: LinkDraft = {
|
||||
accountId: "",
|
||||
source: "local",
|
||||
makePrimary: false,
|
||||
reason: ""
|
||||
};
|
||||
|
||||
export default function IdentityAdminPage({ settings, auth }: Props) {
|
||||
const [items, setItems] = useState<IdentityItem[]>([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [draft, setDraft] = useState<IdentityDraft>(EMPTY_DRAFT);
|
||||
const [savedKey, setSavedKey] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [showInactive, setShowInactive] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createDraft, setCreateDraft] = useState<IdentityDraft>(EMPTY_DRAFT);
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
const [linkDraft, setLinkDraft] = useState<LinkDraft>(EMPTY_LINK);
|
||||
const [lifecycleOpen, setLifecycleOpen] = useState(false);
|
||||
const [lifecycleReason, setLifecycleReason] = useState("");
|
||||
const [removeLink, setRemoveLink] = useState<IdentityAccountLink | null>(null);
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
|
||||
const selected = items.find((item) => item.id === selectedId) ?? null;
|
||||
const canWrite = hasScope(auth, "identity:identity:admin")
|
||||
|| hasScope(auth, "system:accounts:update")
|
||||
|| hasScope(auth, "access:account:update");
|
||||
const canManageLinks = hasScope(auth, "identity:account_link:admin")
|
||||
|| canWrite;
|
||||
const dirty = Boolean(selected && draftKey(draft) !== savedKey);
|
||||
|
||||
const applyIdentity = useCallback((item: IdentityItem | null) => {
|
||||
const next = item ? draftFromIdentity(item) : EMPTY_DRAFT;
|
||||
setDraft(next);
|
||||
setSavedKey(item ? draftKey(next) : "");
|
||||
}, []);
|
||||
|
||||
const reload = useCallback(async (preferredId?: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const next = await listIdentities(settings, "", true);
|
||||
setItems(next);
|
||||
const nextId = preferredId && next.some((item) => item.id === preferredId)
|
||||
? preferredId
|
||||
: next.some((item) => item.id === selectedId)
|
||||
? selectedId
|
||||
: next[0]?.id ?? "";
|
||||
setSelectedId(nextId);
|
||||
applyIdentity(next.find((item) => item.id === nextId) ?? null);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [applyIdentity, selectedId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const needle = search.trim().toLocaleLowerCase();
|
||||
return items.filter((item) => {
|
||||
if (!showInactive && !item.is_active) return false;
|
||||
if (!needle) return true;
|
||||
return `${item.display_name ?? ""} ${item.external_subject ?? ""} ${item.id} ${item.account_ids.join(" ")}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(needle);
|
||||
});
|
||||
}, [items, search, showInactive]);
|
||||
|
||||
const save = async (): Promise<boolean> => {
|
||||
if (!selected || !canWrite) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await updateIdentity(settings, selected.id, draft);
|
||||
setSuccess("Identity saved.");
|
||||
await reload(updated.id);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: () => applyIdentity(selected),
|
||||
title: "Unsaved identity changes",
|
||||
message: "Save or discard the current identity changes before continuing."
|
||||
});
|
||||
|
||||
const selectIdentity = (item: IdentityItem) => {
|
||||
if (item.id === selectedId) return;
|
||||
requestDiscard(() => {
|
||||
setSelectedId(item.id);
|
||||
applyIdentity(item);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
});
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
if (!createDraft.display_name?.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await createIdentity(settings, createDraft);
|
||||
setCreateOpen(false);
|
||||
setCreateDraft(EMPTY_DRAFT);
|
||||
setSuccess("Identity created.");
|
||||
await reload(created.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addLink = async () => {
|
||||
if (!selected || !linkDraft.accountId.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await addIdentityAccountLink(settings, selected.id, {
|
||||
account_id: linkDraft.accountId.trim(),
|
||||
source: linkDraft.source.trim() || "local",
|
||||
make_primary: linkDraft.makePrimary,
|
||||
reason: linkDraft.reason.trim() || null
|
||||
});
|
||||
setLinkOpen(false);
|
||||
setLinkDraft(EMPTY_LINK);
|
||||
setSuccess("Account link added.");
|
||||
await reload(updated.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const promote = async (link: IdentityAccountLink) => {
|
||||
if (!selected || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await promoteIdentityAccountLink(
|
||||
settings,
|
||||
selected.id,
|
||||
link.id,
|
||||
"Promoted through Identity administration"
|
||||
);
|
||||
setSuccess("Primary account changed.");
|
||||
await reload(updated.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!selected || !removeLink || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await removeIdentityAccountLink(settings, selected.id, removeLink.id);
|
||||
setRemoveLink(null);
|
||||
setSuccess("Account link removed.");
|
||||
await reload(selected.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyLifecycle = async () => {
|
||||
if (!selected || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await setIdentityActive(
|
||||
settings,
|
||||
selected.id,
|
||||
!selected.is_active,
|
||||
lifecycleReason
|
||||
);
|
||||
setLifecycleOpen(false);
|
||||
setLifecycleReason("");
|
||||
setSuccess(updated.is_active ? "Identity reactivated." : "Identity deactivated.");
|
||||
await reload(updated.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const linkColumns = useMemo<DataGridColumn<IdentityAccountLink>[]>(() => [
|
||||
{
|
||||
id: "account",
|
||||
header: "Account ID",
|
||||
width: "1fr",
|
||||
minWidth: 220,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.account_id,
|
||||
render: (row) => <code>{row.account_id}</code>
|
||||
},
|
||||
{
|
||||
id: "primary",
|
||||
header: "Role",
|
||||
width: 130,
|
||||
sortable: true,
|
||||
value: (row) => row.is_primary ? "primary" : "linked",
|
||||
render: (row) => (
|
||||
<StatusBadge
|
||||
status={row.is_primary ? "active" : "neutral"}
|
||||
label={row.is_primary ? "Primary" : "Linked"}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
header: "Source",
|
||||
width: 180,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.source
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
header: "Linked",
|
||||
width: 190,
|
||||
sortable: true,
|
||||
value: (row) => row.created_at,
|
||||
render: (row) => formatDateTime(row.created_at)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 100,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (row) => (
|
||||
<TableActionGroup actions={[
|
||||
{
|
||||
id: "promote",
|
||||
label: "Promote to primary",
|
||||
icon: <Star aria-hidden="true" />,
|
||||
applicable: !row.is_primary,
|
||||
disabled: !canManageLinks || busy,
|
||||
disabledReason: !canManageLinks
|
||||
? "Identity account-link administration permission is required."
|
||||
: undefined,
|
||||
onClick: () => void promote(row)
|
||||
},
|
||||
{
|
||||
id: "remove",
|
||||
label: "Remove account link",
|
||||
icon: <Trash2 aria-hidden="true" />,
|
||||
variant: "danger",
|
||||
disabled: !canManageLinks || busy,
|
||||
disabledReason: row.is_primary && (selected?.account_links.length ?? 0) > 1
|
||||
? "Promote another account before removing the primary link."
|
||||
: !canManageLinks
|
||||
? "Identity account-link administration permission is required."
|
||||
: undefined,
|
||||
onClick: () => setRemoveLink(row)
|
||||
}
|
||||
]} />
|
||||
)
|
||||
}
|
||||
], [busy, canManageLinks, selected?.account_links.length]);
|
||||
|
||||
const actionBar = (
|
||||
<PageActionBar
|
||||
variant="editor"
|
||||
state={busy ? "saving" : dirty ? "dirty" : "clean"}
|
||||
refreshable
|
||||
reloadAction={{
|
||||
onReload: () => void reload(selectedId),
|
||||
loading: loading
|
||||
}}
|
||||
primaryActions={
|
||||
<Button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={!canWrite || busy}
|
||||
disabledReason={!canWrite
|
||||
? "System identity administration permission is required."
|
||||
: undefined}
|
||||
>
|
||||
<Plus aria-hidden="true" /> New identity
|
||||
</Button>
|
||||
}
|
||||
destructiveActions={selected ? (
|
||||
<Button
|
||||
variant={selected.is_active ? "danger" : "secondary"}
|
||||
onClick={() => setLifecycleOpen(true)}
|
||||
disabled={!canWrite || busy}
|
||||
disabledReason={!canWrite
|
||||
? "System identity administration permission is required."
|
||||
: undefined}
|
||||
>
|
||||
{selected.is_active
|
||||
? <><UserMinus aria-hidden="true" /> Deactivate</>
|
||||
: <><UserCheck aria-hidden="true" /> Reactivate</>}
|
||||
</Button>
|
||||
) : null}
|
||||
discardAction={{
|
||||
label: "Discard changes",
|
||||
disabled: !selected,
|
||||
onClick: () => applyIdentity(selected)
|
||||
}}
|
||||
saveAction={{
|
||||
label: "Save",
|
||||
disabled: !selected || !canWrite || busy,
|
||||
disabledReason: !canWrite
|
||||
? "System identity administration permission is required."
|
||||
: undefined,
|
||||
onClick: () => void save()
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminPageLayout
|
||||
archetype="workspace"
|
||||
title="Identity directory"
|
||||
description="Manage canonical system identities and their opaque platform-account links."
|
||||
loading={loading && !items.length}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={actionBar}
|
||||
className="identity-admin-page"
|
||||
helpContextId="identity.admin.directory"
|
||||
>
|
||||
<p className="muted identity-admin-scope-note">
|
||||
<strong>Management scope:</strong> {selected?.management_scope ?? "system"}.
|
||||
{" "}The active tenant is the
|
||||
actor context only; Identity does not grant account access or suspend authentication.
|
||||
</p>
|
||||
|
||||
<MetricGrid columns={3} density="compact" minimum="compact">
|
||||
<MetricCard label="Identities" value={items.length} />
|
||||
<MetricCard
|
||||
label="Active"
|
||||
value={items.filter((item) => item.is_active).length}
|
||||
tone="good"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Without account"
|
||||
value={items.filter((item) => !item.account_links.length).length}
|
||||
tone="warning"
|
||||
/>
|
||||
</MetricGrid>
|
||||
|
||||
<WorkspaceLayout
|
||||
variant="split"
|
||||
primarySize="compact"
|
||||
surface="contained"
|
||||
primaryScrollable={false}
|
||||
contentScrollable={false}
|
||||
primaryLabel="Identities"
|
||||
contentLabel="Identity details"
|
||||
contentClassName="identity-admin-workspace"
|
||||
primary={<div className="identity-admin-list">
|
||||
<FilterBar surface="panel">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search identities or account IDs"
|
||||
aria-label="Search identities"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label="Show inactive"
|
||||
checked={showInactive}
|
||||
onChange={setShowInactive}
|
||||
/>
|
||||
</FilterBar>
|
||||
<SelectionList variant="navigation" label="Identities">
|
||||
{visibleItems.map((item) => (
|
||||
<SelectionListItem
|
||||
key={item.id}
|
||||
selected={item.id === selectedId}
|
||||
onClick={() => selectIdentity(item)}
|
||||
>
|
||||
<SelectionListItemContent
|
||||
title={item.display_name || item.id}
|
||||
description={item.primary_account_id || "No account linked"}
|
||||
/>
|
||||
<StatusBadge status={item.status} />
|
||||
</SelectionListItem>
|
||||
))}
|
||||
{!visibleItems.length
|
||||
? <StatePanel size="compact" description="No matching identities." />
|
||||
: null}
|
||||
</SelectionList>
|
||||
</div>}
|
||||
>
|
||||
{!selected ? (
|
||||
<StatePanel
|
||||
size="fill"
|
||||
title="Identity directory"
|
||||
description="Create or select an identity to inspect it."
|
||||
/>
|
||||
) : (
|
||||
<div className="identity-admin-detail">
|
||||
<Card
|
||||
title={selected.display_name || selected.id}
|
||||
>
|
||||
<p className="muted">System identity · {selected.status}</p>
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField label="Display name">
|
||||
<input
|
||||
value={draft.display_name ?? ""}
|
||||
disabled={!canWrite || busy}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
display_name: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="External subject">
|
||||
<input
|
||||
value={draft.external_subject ?? ""}
|
||||
disabled={!canWrite || busy}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
external_subject: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Source">
|
||||
<input
|
||||
value={draft.source}
|
||||
disabled={!canWrite || busy}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
source: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Identity ID">
|
||||
<input value={selected.id} readOnly />
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Account links"
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => setLinkOpen(true)}
|
||||
disabled={!canManageLinks || busy}
|
||||
disabledReason={!canManageLinks
|
||||
? "Identity account-link administration permission is required."
|
||||
: undefined}
|
||||
>
|
||||
<Plus aria-hidden="true" /> Add account link
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className="muted">
|
||||
One opaque account reference is primary. Authentication and
|
||||
account lookup remain owned by Access.
|
||||
</p>
|
||||
<DataGrid
|
||||
id="identity-account-links"
|
||||
rows={selected.account_links}
|
||||
columns={linkColumns}
|
||||
initialFit="container"
|
||||
getRowKey={(row) => row.id}
|
||||
emptyText="No platform accounts are linked."
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Accepted normalized facts">
|
||||
<pre className="identity-admin-settings">
|
||||
{JSON.stringify(selected.settings, null, 2)}
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</WorkspaceLayout>
|
||||
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
title="Create identity"
|
||||
onClose={() => !busy && setCreateOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={<>
|
||||
<Button onClick={() => setCreateOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void create()}
|
||||
disabled={busy || !createDraft.display_name?.trim()}
|
||||
>
|
||||
Create identity
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField label="Display name">
|
||||
<input
|
||||
value={createDraft.display_name ?? ""}
|
||||
disabled={busy}
|
||||
onChange={(event) => setCreateDraft({
|
||||
...createDraft,
|
||||
display_name: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Source">
|
||||
<input
|
||||
value={createDraft.source}
|
||||
disabled={busy}
|
||||
onChange={(event) => setCreateDraft({
|
||||
...createDraft,
|
||||
source: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="External subject">
|
||||
<input
|
||||
value={createDraft.external_subject ?? ""}
|
||||
disabled={busy}
|
||||
onChange={(event) => setCreateDraft({
|
||||
...createDraft,
|
||||
external_subject: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={linkOpen}
|
||||
title="Add account link"
|
||||
onClose={() => !busy && setLinkOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={<>
|
||||
<Button onClick={() => setLinkOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void addLink()}
|
||||
disabled={busy || !linkDraft.accountId.trim()}
|
||||
>
|
||||
Add link
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField label="Account ID">
|
||||
<input
|
||||
value={linkDraft.accountId}
|
||||
disabled={busy}
|
||||
onChange={(event) => setLinkDraft({
|
||||
...linkDraft,
|
||||
accountId: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Source">
|
||||
<input
|
||||
value={linkDraft.source}
|
||||
disabled={busy}
|
||||
onChange={(event) => setLinkDraft({
|
||||
...linkDraft,
|
||||
source: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<ToggleSwitch
|
||||
label="Make primary"
|
||||
checked={linkDraft.makePrimary}
|
||||
onChange={(makePrimary) => setLinkDraft({ ...linkDraft, makePrimary })}
|
||||
/>
|
||||
<FormField label="Reason">
|
||||
<input
|
||||
value={linkDraft.reason}
|
||||
disabled={busy}
|
||||
onChange={(event) => setLinkDraft({
|
||||
...linkDraft,
|
||||
reason: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
<p className="muted">
|
||||
The first account link becomes primary automatically. Identity stores
|
||||
only the account reference and provenance.
|
||||
</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={lifecycleOpen}
|
||||
title={selected?.is_active ? "Deactivate identity" : "Reactivate identity"}
|
||||
onClose={() => !busy && setLifecycleOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={<>
|
||||
<Button onClick={() => setLifecycleOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button
|
||||
variant={selected?.is_active ? "danger" : "primary"}
|
||||
onClick={() => void applyLifecycle()}
|
||||
disabled={busy}
|
||||
>
|
||||
{selected?.is_active ? "Deactivate" : "Reactivate"}
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<p>
|
||||
{selected?.is_active
|
||||
? "Deactivation hides the identity from ordinary directory search. It does not suspend authentication, erase links, or revoke permissions."
|
||||
: "Reactivation restores the identity to ordinary directory search."}
|
||||
</p>
|
||||
<FormField label="Reason">
|
||||
<textarea
|
||||
rows={3}
|
||||
value={lifecycleReason}
|
||||
disabled={busy}
|
||||
onChange={(event) => setLifecycleReason(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(removeLink)}
|
||||
title="Remove account link"
|
||||
onClose={() => !busy && setRemoveLink(null)}
|
||||
closeDisabled={busy}
|
||||
footer={<>
|
||||
<Button onClick={() => setRemoveLink(null)} disabled={busy}>Cancel</Button>
|
||||
<Button variant="danger" onClick={() => void remove()} disabled={busy}>
|
||||
Remove link
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<p>
|
||||
Remove account <strong>{removeLink?.account_id}</strong> from this
|
||||
identity? The account itself is not deleted.
|
||||
</p>
|
||||
</Dialog>
|
||||
</AdminPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function draftFromIdentity(item: IdentityItem): IdentityDraft {
|
||||
return {
|
||||
display_name: item.display_name ?? "",
|
||||
external_subject: item.external_subject ?? "",
|
||||
source: item.source
|
||||
};
|
||||
}
|
||||
|
||||
function draftKey(value: IdentityDraft): string {
|
||||
return JSON.stringify({
|
||||
display_name: value.display_name?.trim() || null,
|
||||
external_subject: value.external_subject?.trim() || null,
|
||||
source: value.source.trim()
|
||||
});
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default, identityModule } from "./module";
|
||||
export * from "./api/identities";
|
||||
export { default as IdentityAdminPage } from "./features/IdentityAdminPage";
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type {
|
||||
AdminSectionsUiCapability,
|
||||
PlatformWebModule
|
||||
} from "@govoplan/core-webui";
|
||||
import "./styles/identity.css";
|
||||
|
||||
const IdentityAdminPage = lazy(() => import("./features/IdentityAdminPage"));
|
||||
|
||||
const readScopes = [
|
||||
"identity:identity:read",
|
||||
"identity:identity:admin",
|
||||
"identity:account_link:admin",
|
||||
"system:accounts:read"
|
||||
];
|
||||
|
||||
const adminSections: AdminSectionsUiCapability = {
|
||||
sections: [
|
||||
{
|
||||
id: "system-identities",
|
||||
moduleId: "identity",
|
||||
kind: "management",
|
||||
surfaceId: "identity.admin.directory",
|
||||
label: "Identity directory",
|
||||
group: "SYSTEM",
|
||||
order: 30,
|
||||
anyOf: readScopes,
|
||||
render: ({ settings, auth }) =>
|
||||
createElement(IdentityAdminPage, { settings, auth })
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const identityModule: PlatformWebModule = {
|
||||
id: "identity",
|
||||
label: "Identity",
|
||||
version: "0.1.19",
|
||||
dependencies: [],
|
||||
optionalDependencies: ["access", "audit", "idm"],
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "identity.admin.directory",
|
||||
moduleId: "identity",
|
||||
kind: "section",
|
||||
label: "Identity directory",
|
||||
order: 30
|
||||
},
|
||||
{
|
||||
id: "identity.admin.account-links",
|
||||
moduleId: "identity",
|
||||
kind: "section",
|
||||
label: "Identity account links",
|
||||
parentId: "identity.admin.directory",
|
||||
order: 20
|
||||
}
|
||||
],
|
||||
uiCapabilities: {
|
||||
"admin.sections": adminSections
|
||||
}
|
||||
};
|
||||
|
||||
export default identityModule;
|
||||
@@ -0,0 +1,25 @@
|
||||
.identity-admin-page .identity-admin-workspace {
|
||||
min-height: 34rem;
|
||||
}
|
||||
|
||||
.identity-admin-page .identity-admin-list {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.identity-admin-page .identity-admin-detail {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.identity-admin-page .identity-admin-scope-note {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.identity-admin-page .identity-admin-settings {
|
||||
margin: 0;
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const moduleSource = readFileSync("src/module.ts", "utf8");
|
||||
const page = readFileSync("src/features/IdentityAdminPage.tsx", "utf8");
|
||||
const api = readFileSync("src/api/identities.ts", "utf8");
|
||||
|
||||
assert.match(moduleSource, /"admin.sections": adminSections/);
|
||||
assert.match(moduleSource, /identity\.admin\.directory/);
|
||||
assert.match(page, /<AdminPageLayout/);
|
||||
assert.match(page, /<PageActionBar/);
|
||||
assert.match(page, /refreshable/);
|
||||
assert.match(page, /saveAction=/);
|
||||
assert.match(page, /useUnsavedDraftGuard/);
|
||||
assert.match(page, /management_scope/);
|
||||
assert.match(page, /Promote/);
|
||||
assert.match(api, /account-links/);
|
||||
assert.match(api, /include_inactive/);
|
||||
|
||||
console.log("Identity administration UI structural contract passed.");
|
||||
Reference in New Issue
Block a user