Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f8072b4ae | ||
|
|
0f8a05f8b9 | ||
|
|
e55434f406 | ||
|
|
a889071b71 | ||
|
|
8a43b9b676 | ||
|
|
206873b62a | ||
|
|
2d1b1e356e | ||
|
|
fa0c85e03a | ||
|
|
94b604a3af | ||
|
|
d3daf42bd9 | ||
|
|
6052dde760 | ||
|
|
566b3b83ad | ||
|
|
e4bae0121d | ||
|
|
5c6f446cd5 | ||
|
|
2be1dbc132 | ||
|
|
38fc22c06b | ||
|
|
3661fdd370 | ||
|
|
3966c7f33c | ||
|
|
9ba69286f7 | ||
|
|
55d87a7812 | ||
|
|
a861338b9a | ||
|
|
0b9e3751c2 | ||
|
|
e04671034f | ||
|
|
44799b15e5 | ||
|
|
367ffc4564 | ||
|
|
a7ed28f6a3 | ||
|
|
794fcf90f8 | ||
|
|
d5db5c2378 | ||
|
|
4653542247 | ||
|
|
668f7cf108 | ||
|
|
e710cf5fb8 | ||
|
|
998d47ae94 | ||
|
|
1409dbf94d | ||
|
|
fe247999e9 | ||
|
|
9776f862f8 | ||
|
|
e58dcbdd7b | ||
|
|
aeda457fb1 | ||
|
|
a70cc375c2 | ||
|
|
e8ea347654 | ||
|
|
a758c8f2da | ||
|
|
6295cfa840 | ||
|
|
b6c2c89adf | ||
|
|
6b0dd8beab | ||
|
|
984a015704 | ||
|
|
3df4fc5bff | ||
|
|
1b57df7753 | ||
|
|
bf1ecc54b3 | ||
|
|
fdfcfbb440 | ||
|
|
f1d64d247e | ||
|
|
198803d5b9 | ||
|
|
79781662e8 | ||
|
|
4075ac4d73 | ||
|
|
21f77ffc50 | ||
|
|
d0ef0531c0 | ||
|
|
c2917459a4 | ||
|
|
fb1b573855 | ||
|
|
8c74e360d2 | ||
|
|
e91935c03a | ||
|
|
01d082e552 | ||
|
|
90d8f65835 | ||
|
|
1c8e18e109 | ||
|
|
ab07075a67 | ||
|
|
f2afcaa09c | ||
|
|
d08a41fb56 | ||
|
|
002d12e417 | ||
|
|
5b8baa6cde | ||
|
|
d6a0d6241d | ||
|
|
e32841077c | ||
|
|
a7c486788e | ||
|
|
c71de86a1f | ||
|
|
04681f1d75 | ||
|
|
37828fe340 |
@@ -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
|
||||
+265
@@ -8,3 +8,268 @@ dist/
|
||||
.venv/
|
||||
node_modules/
|
||||
*.tsbuildinfo
|
||||
|
||||
# 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
|
||||
jspm_packages/
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
# TypeScript cache
|
||||
# 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.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/
|
||||
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/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
# Environments
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
# mkdocs documentation
|
||||
/site
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
# ---> VisualStudioCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
*.db
|
||||
# GovOPlaN local runtime state
|
||||
runtime/
|
||||
# GovOPlaN WebUI test output
|
||||
webui/.module-test-build/
|
||||
webui/.component-test-build/
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# GovOPlaN Access 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 Access 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 GovOPlaN access platform module seed: identity,
|
||||
|
||||
@@ -1,28 +1,46 @@
|
||||
# GovOPlaN Access
|
||||
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (platform).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`govoplan-access` is the platform module for GovOPlaN identity,
|
||||
authentication, sessions, API keys, RBAC, groups, users, and access
|
||||
administration.
|
||||
|
||||
The repository contains the extracted access seed implementation under
|
||||
`src/govoplan_access/backend`. Session, API-key, and password helper services,
|
||||
interactive auth routes, FastAPI auth dependencies, and the legacy
|
||||
administration router are owned here. Access-side admin service helpers remain
|
||||
interactive auth routes, and administration routers are owned here. Access
|
||||
still exports `govoplan_access.auth` for compatibility, but sibling modules
|
||||
should import the core `govoplan_core.auth` facade so auth can become a
|
||||
provider-neutral capability. Modules must not import the backend dependency
|
||||
module directly. Access-side admin service helpers remain
|
||||
here for users, groups, roles, system accounts, sessions, API keys, tenant
|
||||
access enforcement, admin/audit lookup capabilities, tenant owner
|
||||
provisioning, and governance-template materialization into access-owned groups
|
||||
and roles. Governance-template metadata CRUD lives in `govoplan-admin`. The
|
||||
and roles. The module also enforces narrowly declared managed
|
||||
`RoleTemplate.default_authenticated` baselines. Their explicit permissions are
|
||||
derived from the active manifest set for every authenticated tenant member,
|
||||
without writing assignments during an authorization read. The optional role
|
||||
row is a non-assignable administration projection, not the source of the
|
||||
automatic grant. Domain permissions and resource policy remain separate checks.
|
||||
Governance-template metadata CRUD lives in `govoplan-admin`. The
|
||||
transitional administration WebUI route shell and
|
||||
access-owned panels live under `webui/src` as `@govoplan/access-webui`. Generic
|
||||
system administration panels are contributed by `@govoplan/admin-webui` through
|
||||
core's `admin.sections` UI capability. Live access ORM models are defined here
|
||||
while retaining their historical table names; tenant records live in
|
||||
`govoplan-tenancy`, governance templates in `govoplan-admin`, audit logs in
|
||||
`govoplan-audit`, and system settings in `govoplan-core`. The staged
|
||||
extraction path is documented in:
|
||||
with `access_*` table names. Access stores current scope identifiers in
|
||||
`tenant_id` columns but does not hard-depend on the tenancy module package.
|
||||
Tenancy-specific tenant administration and tenant resolver behavior live in
|
||||
`govoplan-tenancy`; governance templates in `govoplan-admin`, audit logs in
|
||||
`govoplan-audit`, and system settings in `govoplan-core`. The current access
|
||||
boundary is documented in:
|
||||
|
||||
- `/mnt/DATA/git/govoplan-core/docs/ACCESS_EXTRACTION_PLAN.md`
|
||||
- `/mnt/DATA/git/govoplan-core/docs/ACCESS_RBAC_MODEL.md`
|
||||
- `/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`
|
||||
- `docs/ACCESS_MODULE_BOUNDARY.md`
|
||||
- `docs/IDENTITY_ACCOUNT_FUNCTION_MODEL.md`
|
||||
- `docs/OPENDESK_IDENTITY_BOUNDARY.md`
|
||||
|
||||
## Initial Ownership
|
||||
|
||||
@@ -33,22 +51,62 @@ This module will own:
|
||||
- API keys
|
||||
- legacy administration route contribution during the transition
|
||||
- tenant-local users
|
||||
- identity-to-account projection for explainable access decisions
|
||||
- organization-bound functions and function assignments
|
||||
- explicit delegation and acting-in-place facts
|
||||
- groups and memberships
|
||||
- roles and role assignments
|
||||
- principal resolution
|
||||
- permission evaluation
|
||||
- access administration backend routes
|
||||
- access administration WebUI route contributions
|
||||
- published FastAPI auth dependency API
|
||||
- compatibility FastAPI auth dependency API at `govoplan_access.auth`
|
||||
- provider-facing auth facade consumed by sibling modules at `govoplan_core.auth`
|
||||
- access administration, tenant provisioning, and governance materializer
|
||||
capabilities
|
||||
capabilities, including the bounded `access.governanceProjection.v1` bulk
|
||||
reconciliation contract used by Admin for idempotent per-assignment outcomes
|
||||
- access-owned migrations
|
||||
- a provider-neutral tenant-erasure contribution that removes tenant-scoped
|
||||
credentials and authorization projections while preserving shared global
|
||||
accounts and identities
|
||||
|
||||
The governance-template routes under `/admin/system/governance-templates` are
|
||||
contributed by `govoplan-admin`; access must not register those routes.
|
||||
|
||||
`govoplan-access` depends on `govoplan-tenancy`; the registry loads tenancy
|
||||
before access for authenticated platform composition.
|
||||
`govoplan-access` treats `govoplan-tenancy` as optional. Access can run in the
|
||||
single-scope compatibility mode used by the core/access baseline, and tenancy
|
||||
adds tenant administration plus tenant resolver behavior when installed.
|
||||
|
||||
## Principal Context
|
||||
|
||||
The stable principal DTO is `govoplan_core.core.access.PrincipalRef`. Access
|
||||
resolves sessions, API keys, and service-account credentials into that DTO and
|
||||
serializes it as `principal` in auth API responses. Feature modules should use
|
||||
that DTO, primitive IDs, or the core `govoplan_core.auth` dependency facade
|
||||
instead of importing access ORM models or backend dependency internals.
|
||||
|
||||
The detailed module boundary and serialization fields are documented in
|
||||
[docs/ACCESS_MODULE_BOUNDARY.md](docs/ACCESS_MODULE_BOUNDARY.md).
|
||||
The Access-owned administration surfaces, consequence classes, shared control
|
||||
contract, contextual-help references, and verification evidence are recorded
|
||||
in [docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||
|
||||
For scheduled and event-driven work, Access provides
|
||||
`auth.automationPrincipalProvider`. Automation records store only an owner
|
||||
account/membership reference and an explicit least-privilege scope grant, not
|
||||
a session or API token. At delivery time Access rebuilds the principal from
|
||||
current roles, groups, functions, and delegations and intersects that
|
||||
authorization with the stored grant. Missing, inactive, moved, or
|
||||
under-authorized owners fail closed before module work starts.
|
||||
|
||||
Tenant administrators manage non-login service accounts under
|
||||
`Admin > Tenant > Service accounts`. Each service account has a revisioned
|
||||
scope ceiling and independently revocable API credentials. Credential secrets
|
||||
are shown once; runtime authorization intersects the credential grant with the
|
||||
current ceiling. Rotation creates a replacement and revokes the previous
|
||||
credential atomically, while retirement revokes every active credential. See
|
||||
[docs/SERVICE_ACCOUNTS.md](docs/SERVICE_ACCOUNTS.md) for the API and operational
|
||||
contract.
|
||||
|
||||
## WebUI Package
|
||||
|
||||
@@ -74,7 +132,7 @@ available:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./scripts/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-access --apply
|
||||
/mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-access --apply
|
||||
```
|
||||
|
||||
## Development Install
|
||||
@@ -85,3 +143,21 @@ From the core checkout:
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./.venv/bin/python -m pip install -e ../govoplan-access
|
||||
```
|
||||
|
||||
## Login Throttling
|
||||
|
||||
Interactive password login is throttled by normalized global login identity and by
|
||||
the directly connected client address. The deployment defaults are 10 identity
|
||||
failures and 100 client failures in a 15-minute window. Counters use
|
||||
`REDIS_URL` when Redis is reachable, allowing all API workers to share the same
|
||||
limits. Local development and Redis outages fall back automatically to a
|
||||
bounded, process-local counter; authentication remains available, but limits
|
||||
then apply per API process.
|
||||
|
||||
The deployment settings are `AUTH_LOGIN_THROTTLE_ENABLED`,
|
||||
`AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT`, `AUTH_LOGIN_THROTTLE_CLIENT_LIMIT`,
|
||||
`AUTH_LOGIN_THROTTLE_WINDOW_SECONDS`, and
|
||||
`AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS`. Client-supplied forwarding headers
|
||||
are not trusted for throttling. A reverse proxy should pass the real peer
|
||||
address only through the platform's separately configured trusted-proxy
|
||||
boundary.
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
# GovOPlaN Access Module Boundary
|
||||
|
||||
`govoplan-access` is the platform module that owns login identity and runtime
|
||||
authorization state. Core remains the kernel: it composes modules, mounts
|
||||
routes, owns process/database lifecycle, and exposes stable capability
|
||||
contracts.
|
||||
|
||||
## Access-Owned Capabilities
|
||||
|
||||
`govoplan-access` owns the canonical implementation for:
|
||||
|
||||
- accounts and global login identity
|
||||
- interactive authentication routes and session lifecycle
|
||||
- API-key creation, verification, revocation, and scope delegation
|
||||
- tenant-local users, memberships, groups, roles, and role assignments
|
||||
- identity-to-account projection used for explainability
|
||||
- organization-bound functions, function assignments, and delegation facts
|
||||
- principal resolution and request authentication dependencies
|
||||
- permission evaluation for access-owned scopes and legacy access aliases
|
||||
- access-decision explain output with identity/account/function/role/right
|
||||
provenance
|
||||
- access administration backend routes for users, groups, roles, system
|
||||
accounts, sessions, and API keys
|
||||
- access administration WebUI route contribution for `/admin`
|
||||
- tenant owner provisioning and default access bootstrap
|
||||
- materializing governance templates into access-owned groups and roles
|
||||
- access-owned SQLAlchemy metadata and migrations for `access_*` tables
|
||||
- the `tenancy.erasure_provider.access` contribution, which previews and
|
||||
idempotently removes only target-tenant credentials and authorization rows
|
||||
while retaining global accounts and identities shared with other tenants
|
||||
|
||||
The active access tables use the `access_*` namespace while the model classes
|
||||
live in this module: `access_accounts`, `access_users`, `access_groups`,
|
||||
`access_roles`, `access_system_role_assignments`,
|
||||
`access_user_group_memberships`, `access_user_role_assignments`,
|
||||
`access_group_role_assignments`, `access_api_keys`, and
|
||||
`access_auth_sessions`.
|
||||
|
||||
## Kernel-Owned Contracts
|
||||
|
||||
`govoplan-core` owns the stable contracts that let modules interact without
|
||||
importing access internals:
|
||||
|
||||
- `ModuleManifest`, route factories, migration specs, and registry validation
|
||||
- database engine/session lifecycle and migration orchestration
|
||||
- capability registry and capability names in `govoplan_core.core.access`
|
||||
- access DTO/protocol contracts such as `PrincipalRef`, `AccountRef`,
|
||||
`UserRef`, `GroupRef`, `RoleRef`, `IdentityRef`,
|
||||
`OrganizationUnitRef`, `FunctionRef`, `FunctionAssignmentRef`,
|
||||
`FunctionDelegationRef`, `AccessDecisionProvenance`,
|
||||
`ApiPrincipalProvider`, `TenantContextSwitcher`, `PrincipalResolver`,
|
||||
`AccessDirectory`, `AccessSemanticDirectory`, `PermissionEvaluator`,
|
||||
`AccessExplanationService`, `TenantAccessProvisioner`,
|
||||
`AccessAdministration`, and
|
||||
`AccessGovernanceMaterializer`
|
||||
- health, platform metadata, and module startup ordering
|
||||
- generic security helpers that are not access-state semantics, such as
|
||||
secret encryption and UTC time helpers
|
||||
|
||||
Feature modules should depend on these kernel contracts or the core
|
||||
`govoplan_core.auth` request dependency facade, not on access ORM models or
|
||||
`govoplan_access.backend.*` implementation internals. The access package still
|
||||
exports `govoplan_access.auth` for compatibility, but new routers should use
|
||||
the core facade so auth can move behind provider-neutral capabilities.
|
||||
|
||||
Access declares tenancy as an optional module integration. It uses the
|
||||
core-owned `core_scopes` table as the scope table, but it must not import
|
||||
`govoplan_tenancy` or require the tenancy package to start.
|
||||
|
||||
## Tenant-Erasure Boundary
|
||||
|
||||
Access implements the Core tenant-erasure provider contract without importing
|
||||
Tenancy. Its preview counts every Access table with a tenant boundary. The
|
||||
first destructive step removes target-tenant sessions and API keys; the second
|
||||
removes service accounts, memberships, groups, tenant roles, organization
|
||||
units, functions, assignments, and delegations in dependency-safe order.
|
||||
Both steps are database-transactional and idempotent, so reconciliation can
|
||||
repeat them after an interrupted response.
|
||||
|
||||
Global accounts, system-role assignments, identities, and identity-account
|
||||
links are intentionally retained: they are installation-wide facts and may be
|
||||
used by another tenant. Provider previews and receipts contain counts and
|
||||
stable references only, never password hashes, session tokens, API-key hashes,
|
||||
email addresses, or other credential material.
|
||||
|
||||
## Core-Only Startup Contract
|
||||
|
||||
A core-only installation must be able to start far enough to expose process
|
||||
health, module metadata, and the unauthenticated shell needed for installation
|
||||
or recovery work. It is not a usable authenticated product installation.
|
||||
|
||||
Authenticated product use requires the `access` module or another module that
|
||||
provides the same kernel auth capabilities:
|
||||
|
||||
- `auth.apiPrincipalProvider`
|
||||
- `auth.principalResolver`
|
||||
- `auth.permissionEvaluator`
|
||||
- `auth.tenantContextSwitcher`
|
||||
|
||||
Access contributes the default implementations for those capabilities plus the
|
||||
interactive `/api/v1/auth/*` routes. Product modules should express auth needs
|
||||
as required capabilities or route permission requirements instead of importing
|
||||
access internals. Runtime configurations that intentionally omit access should
|
||||
hide authenticated navigation and return capability errors for authenticated
|
||||
product routes rather than failing process startup.
|
||||
|
||||
## Principal Context Contract
|
||||
|
||||
The stable runtime principal is `govoplan_core.core.access.PrincipalRef`.
|
||||
Access resolves request credentials into that DTO and `ApiPrincipal` keeps the
|
||||
legacy ORM objects only for routers that have not yet moved to pure kernel
|
||||
contracts. New module code should pass around `PrincipalRef` or primitive IDs.
|
||||
|
||||
`PrincipalRef.to_dict()` is the canonical API/WebUI serialization shape:
|
||||
|
||||
- `account_id`, `membership_id`, and `tenant_id`
|
||||
- optional `identity_id`
|
||||
- sorted `scopes`, `group_ids`, `role_ids`, `function_assignment_ids`, and
|
||||
`delegation_ids`
|
||||
- `auth_method` plus optional `session_id`, `api_key_id`, or
|
||||
`service_account_id`
|
||||
- optional `acting_for_account_id` for acting-in-place flows
|
||||
- optional display fields `email` and `display_name`
|
||||
|
||||
`govoplan_core.auth` is now backed by the `auth.apiPrincipalProvider`
|
||||
capability. The access module provides that capability; core no longer imports
|
||||
access auth dependencies directly. `/api/v1/auth/me`, `/api/v1/auth/login`,
|
||||
profile refreshes, and tenant switches include this payload as `principal`
|
||||
alongside the existing compatibility fields. Modules that need current user
|
||||
context should prefer `auth.principal`/`AuthInfo.principal` in the WebUI and
|
||||
`principal.to_platform_principal()` in backend request handlers.
|
||||
|
||||
Interactive tenant context switching is exposed through
|
||||
`auth.tenantContextSwitcher`. The existing `/api/v1/auth/switch-tenant` route
|
||||
remains for API compatibility; `govoplan-tenancy` also contributes
|
||||
`/api/v1/tenancy/switch-tenant`. Both delegate to the same access-owned session
|
||||
switch behavior. Lifecycle code must use the capability instead of importing
|
||||
`govoplan_access.backend.security.sessions`.
|
||||
|
||||
## Identity And Function Boundary
|
||||
|
||||
The full semantic model is documented in
|
||||
[IDENTITY_ACCOUNT_FUNCTION_MODEL.md](IDENTITY_ACCOUNT_FUNCTION_MODEL.md).
|
||||
In short:
|
||||
|
||||
- `govoplan-idm` imports and previews external identity and organization facts
|
||||
from IDM systems.
|
||||
- `govoplan-identity` owns canonical identities and identity/account links.
|
||||
- `govoplan-organizations` owns canonical organization units, functions, and
|
||||
account-held function assignments.
|
||||
- `govoplan-access` owns the authorization projection that maps organization
|
||||
and identity facts to roles, rights, delegation enforcement, and explainable
|
||||
permission decisions.
|
||||
|
||||
Function assignments are account-held and organization-scoped. They can apply
|
||||
only to the selected organization unit or to that unit and all subunits.
|
||||
Delegation and acting-in-place must remain explicit facts with audit
|
||||
provenance; modules must not infer either from plain group membership.
|
||||
|
||||
The backend foundation exposes these administration routes:
|
||||
|
||||
- `/api/v1/admin/identities`
|
||||
- `/api/v1/admin/organization-units`
|
||||
- `/api/v1/admin/functions`
|
||||
- `/api/v1/admin/function-assignments`
|
||||
- `/api/v1/admin/function-delegations`
|
||||
|
||||
Dedicated WebUI management panels remain follow-up work on top of these routes.
|
||||
Interactive acting-in-place selection is available through
|
||||
`/api/v1/auth/acting-contexts` and `/api/v1/auth/switch-acting-context`; it
|
||||
persists the exact selected assignment and represented account on the session,
|
||||
audits each switch, and fails closed when the assignment is no longer effective.
|
||||
|
||||
## Removed Compatibility Paths
|
||||
|
||||
These legacy imports were removed from core. Use access-owned modules, the
|
||||
core `govoplan_core.auth` request dependency facade, or kernel capabilities
|
||||
instead:
|
||||
|
||||
- `govoplan_core.security.api_keys`
|
||||
- `govoplan_core.security.sessions`
|
||||
- `govoplan_core.security.passwords`
|
||||
- `govoplan_core.api.v1.auth`
|
||||
- `govoplan_core.api.v1.admin`
|
||||
- `govoplan_core.api.v1.admin_schemas`
|
||||
- `govoplan_core.admin.service`
|
||||
- `govoplan_core.admin.governance`
|
||||
|
||||
HTTP route compatibility remains at the API layer: the access manifest
|
||||
contributes the same `/api/v1/auth/*` and `/api/v1/admin/*` paths through module
|
||||
route aggregation.
|
||||
|
||||
## Route Ownership
|
||||
|
||||
The access manifest contributes the `/api/v1/auth/*` interactive auth routes
|
||||
and the access-owned `/api/v1/admin/*` administration routes through its module
|
||||
route factory. Core default server configuration must not register auth or
|
||||
admin routers as base routers.
|
||||
|
||||
Governance-template metadata CRUD is not access-owned. It is contributed by
|
||||
`govoplan-admin`; access only materializes those templates into access-owned
|
||||
groups and roles. The compatibility `access.governanceMaterializer` capability
|
||||
remains available for single-assignment callers. New Admin orchestration uses
|
||||
`access.governanceProjection.v1`: a bounded request of stable template and
|
||||
assignment DTOs that bulk-loads managed rows and assignment blockers, applies
|
||||
idempotent create/update/remove reconciliation, and returns one provenance-rich
|
||||
outcome per assignment. Admin never imports Access ORM models.
|
||||
|
||||
The configuration-package Admin routes remain in Access as a compatibility
|
||||
surface. Their preflight context is assembled from the active Core registry,
|
||||
including module-owned external-provider declarations. This allows an
|
||||
integration package to validate installed provider authority and maturity
|
||||
without importing provider modules into Access. For dry-run, apply, and export,
|
||||
Access also asks the active registry for tenant-scoped, sanitized runtime
|
||||
provider state using the request database transaction. Package preflight can
|
||||
therefore select an exact stable binding and evaluate its authority, health,
|
||||
freshness, and recovery readiness. When no provider state is available,
|
||||
preflight reports it as unverified rather than inferring health from
|
||||
installation.
|
||||
|
||||
## Verification References
|
||||
|
||||
Focused verification is run from `/mnt/DATA/git/govoplan-core`.
|
||||
|
||||
- `tests.test_module_system` verifies manifest discovery, access startup in
|
||||
module permutations, admin route ownership, governance-template route
|
||||
separation, and legacy compatibility imports.
|
||||
- `tests.test_api_smoke.ApiSmokeTests.test_cookie_session_requires_csrf_for_mutations`
|
||||
verifies the access-owned session/auth route behavior.
|
||||
- `tests.test_api_smoke.ApiSmokeTests.test_tenant_user_group_role_and_api_key_administration`
|
||||
verifies access-owned administration and API-key behavior.
|
||||
- `tests.test_api_smoke.ApiSmokeTests.test_profile_refresh_and_system_role_protection_model`
|
||||
verifies profile/session refresh and protected system role behavior.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Authentication cache boundary hardening
|
||||
|
||||
The authentication cache is an optimization, never an additional authentication
|
||||
method or permission source. The September 2026 review found and reproduced
|
||||
three violations of that boundary in isolated SQLite tests.
|
||||
|
||||
| Finding | Consequence | Resolution |
|
||||
| --- | --- | --- |
|
||||
| Service-account credentials passed through ordinary principal-summary refresh | A narrowed service-account ceiling could be replaced by backing membership roles; the service-account identifier and authentication method were lost. | Dedicated service-account resolution retains its provenance and checks the current ceiling/lifecycle on every request. It does not enter the interactive principal-summary cache. |
|
||||
| A warmed API-key summary accepted the same secret through a session cookie | The warm path accepted a cookie-authenticated mutation without the CSRF rule applied to real browser sessions; the cold path rejected the credential. | API keys require an explicit Bearer or X-API-Key header on both paths. Session cookies still require matching CSRF cookie/header/hash for mutations. |
|
||||
| Tenant-key intersection excluded only the historical `system:` spelling | Module-native system permissions and retained module wildcards could survive the tenant-only intersection. | Resolve wildcard grants to concrete registered tenant permissions and exclude system permissions by catalogue and compatibility aliases. |
|
||||
|
||||
No existing secrets, sessions, assignments, or database schema are changed.
|
||||
Normal header-authenticated API keys and concrete tenant aliases remain
|
||||
compatible. Clients relying on API keys in browser cookies, implicit unknown
|
||||
wildcards, or accidental instance-level rights must correct their authentication
|
||||
method or permission configuration; these are not preserved as compatibility
|
||||
exceptions. Stored grants are not rewritten. Service-account access continues
|
||||
to narrow immediately when its ceiling is reduced or its lifecycle blocks use.
|
||||
|
||||
Regression coverage is in `tests/test_auth_cache_security.py` and
|
||||
`tests/test_permission_catalog_contract.py`; the tests exercise the full
|
||||
credential resolver with principal caching enabled, not only the lower-level
|
||||
API-key lookup. They also verify that valid session CSRF and concrete legacy
|
||||
tenant aliases still work.
|
||||
|
||||
## Remaining coordinated password-change workflow
|
||||
|
||||
`Account.password_reset_required` is currently advisory metadata, not an
|
||||
enforced sign-in restriction. The administrator UI states this limitation,
|
||||
but the authentication-fields documentation previously claimed mandatory
|
||||
replacement; its English and German text now reflects the implementation.
|
||||
A generated password is disclosed once but is not a single-use login secret.
|
||||
|
||||
A follow-up must deliver the password-change endpoint, current-password
|
||||
verification and replacement policy, CSRF and attempt limits, session
|
||||
revocation/rotation and cache invalidation, a restricted reset-required
|
||||
principal, and the corresponding accessible UI/recovery path together.
|
||||
Enabling only a rejection gate would lock affected accounts out without any
|
||||
supported way to finish the change. This review does not enable such a gate or
|
||||
change existing passwords.
|
||||
|
||||
## Betriebshinweise
|
||||
|
||||
API-Schlüssel werden ausschließlich über `Authorization: Bearer` oder
|
||||
`X-API-Key` gesendet, nicht über das Sitzungscookie. Ändernde Cookie-Anfragen
|
||||
benötigen weiterhin einen passenden CSRF-Header samt Cookie und serverseitigem
|
||||
Prüfwert. Dienstkonten behalten ihre eigene Herkunft und werden bei jeder
|
||||
Anfrage gegen den aktuellen Berechtigungsrahmen und Lebenszyklus geprüft.
|
||||
Mitgliedschaftsrollen oder zwischengespeicherte interaktive Rechte dürfen diesen
|
||||
Rahmen nicht ersetzen.
|
||||
|
||||
Mandantenschlüssel erhalten keine instanzweiten Rechte, auch nicht unter
|
||||
modulbezogenen Berechtigungsnamen. Platzhalter werden in konkrete registrierte
|
||||
Mandantenrechte aufgelöst. Bestehende konkrete Mandantenrechte und ihre
|
||||
Kompatibilitätsnamen bleiben erhalten; gespeicherte Geheimnisse und
|
||||
Rollenzuweisungen werden nicht geändert.
|
||||
|
||||
Das Kennzeichen `password_reset_required` erzwingt derzeit keinen
|
||||
Passwortwechsel. Ein einmal angezeigtes Anfangspasswort bleibt zur Anmeldung
|
||||
verwendbar. Die Nachfolgeumsetzung muss Passwortänderung, eng begrenzten
|
||||
Zwischenzugriff, Sitzungswechsel beziehungsweise Widerruf und eine bedienbare
|
||||
Wiederherstellung gemeinsam liefern; eine alleinige Zugriffssperre würde
|
||||
betroffene Konten ohne durchführbaren Passwortwechsel aussperren.
|
||||
@@ -0,0 +1,79 @@
|
||||
# External function mapping schema repair
|
||||
|
||||
Access owns `access_external_function_role_assignments`. Some older databases
|
||||
record the Access baseline (`4a5b6c7d8e9f`) without this table. The mapping list
|
||||
and `/api/v1/admin/external-function-role-mappings/delta` then fail with an
|
||||
undefined-table error. This is a schema/history mismatch, not a reason to change
|
||||
user permissions or recreate tenant data.
|
||||
|
||||
Forward repair revision `d8f1b4e7a0c3` follows Access `c7e0a3d6f9b2` on the
|
||||
release track and `b6d9f2a5c8e1` on the disposable-development track. The latter
|
||||
also requires Core's existing scope-table rename `4f2a9c8e7b6d`; this is a Core
|
||||
contract and does not require the optional Tenancy or Organizations modules.
|
||||
|
||||
Before applying deployment migrations, back up and verify the database backup.
|
||||
Use the configured migration track and ordinary deployment migration workflow,
|
||||
including its deployment-wide advisory lock. Inspect the pending revision plan
|
||||
before any targeted repair. Never replay or stamp the baseline, initialize dev
|
||||
data, reset the database, or switch migration tracks to bypass the error.
|
||||
|
||||
The development launcher can run pending migrations when its file watcher
|
||||
reloads the backend. Prepare and test a migration outside the watched source
|
||||
tree, and complete the backup/preflight before placing a new migration file in
|
||||
that tree. Do not assume that waiting to invoke a migration command prevents a
|
||||
running development instance from applying it automatically.
|
||||
|
||||
The repair:
|
||||
|
||||
- Creates the absent mapping table only, with its baseline columns, role/scope
|
||||
cascade foreign keys, primary key, tenant/source/function/role uniqueness,
|
||||
and four lookup indexes.
|
||||
- Does nothing if the table already exists. It does not alter partial tables;
|
||||
any other schema mismatch needs separate inspection.
|
||||
- Never invents mappings or changes roles, memberships, permissions, or other
|
||||
application records. An empty list means no mappings have been configured.
|
||||
- Keeps the table and any stored mappings on downgrade, because the table
|
||||
belongs to the baseline and removing it would delete authorization policy.
|
||||
|
||||
After migration, verify both mapping list endpoints return success for an
|
||||
authorized user in the active tenant, and check the table's constraints and
|
||||
indexes. Existing read scopes and tenant isolation remain enforced. A missing
|
||||
table cannot reveal whether historical mappings were once removed: this repair
|
||||
does not reconstruct lost policy; investigate backups if mappings were expected.
|
||||
|
||||
Regression coverage in `tests/test_external_function_mapping_migration.py`
|
||||
recreates the observed missing-table failure in isolated databases on both
|
||||
tracks. It checks the HTTP list/delta responses, repeated upgrades, no-op
|
||||
upgrades with existing mappings, downgrade/re-upgrade preservation, unchanged
|
||||
parent rows/permissions, constraints, denied unprivileged reads, and tenant
|
||||
isolation.
|
||||
|
||||
## Deutsch
|
||||
|
||||
Bei älteren Datenbanken kann die Access-Basismigration als angewendet vermerkt
|
||||
sein, obwohl `access_external_function_role_assignments` fehlt. Die Liste der
|
||||
Funktions-Rollenzuordnungen und ihre Delta-API melden dann einen internen Fehler.
|
||||
Dies ist ein Widerspruch zwischen Schema und Migrationsstand, kein Anlass zur
|
||||
Erweiterung von Berechtigungen oder zum Neuerstellen von Mandantendaten.
|
||||
|
||||
Vor der regulären, vorwärtsgerichteten Migration `d8f1b4e7a0c3` eine überprüfte
|
||||
Datenbanksicherung erstellen. Den konfigurierten Migrationstrack und den
|
||||
regulären Bereitstellungsablauf mit installationsweiter Migrationssperre nutzen;
|
||||
bei einer gezielten Reparatur zuvor die ausstehenden Revisionen prüfen.
|
||||
Basismigrationen nicht erneut ausführen oder lediglich als angewendet markieren,
|
||||
keine Entwicklungsdaten initialisieren und die Datenbank nicht zurücksetzen.
|
||||
Der Entwicklungsstarter kann ausstehende Migrationen bereits beim automatischen
|
||||
Neuladen des Backends anwenden. Neue Migrationsdateien deshalb außerhalb des
|
||||
überwachten Quellbaums vorbereiten und testen; Sicherung und Vorprüfung vor dem
|
||||
Kopieren in den überwachten Quellbaum abschließen. Das Warten mit einem manuellen
|
||||
Migrationsaufruf verhindert die automatische Anwendung nicht.
|
||||
|
||||
Die Reparatur erstellt nur die fehlende Tabelle einschließlich Fremdschlüsseln,
|
||||
Eindeutigkeitsbedingung und Indizes. Vorhandene Tabellen und Datensätze bleiben
|
||||
unverändert; auch ein Downgrade entfernt keine Zuordnungsdaten. Teilweise
|
||||
vorhandene Tabellen werden nicht umgebaut und erfordern eine gesonderte Prüfung.
|
||||
Es entstehen keine automatischen Zuordnungen oder neuen Rechte. Anschließend
|
||||
beide Listenendpunkte im aktiven Mandanten mit einer berechtigten Person prüfen.
|
||||
Eine leere Liste bedeutet, dass keine Zuordnungen konfiguriert sind. Falls früher
|
||||
Zuordnungen erwartet wurden, Sicherungen prüfen: Verlorene Berechtigungsregeln
|
||||
lassen sich aus einer fehlenden Tabelle nicht rekonstruieren.
|
||||
@@ -0,0 +1,178 @@
|
||||
# Identity, Account, Function, Role, And Right Model
|
||||
|
||||
GovOPlaN access distinguishes identity facts, organizational responsibility,
|
||||
and authorization decisions. This is groundwork for postboxes, workflows,
|
||||
service directories, portals, delegation, and audit review.
|
||||
|
||||
Directory services, identity providers, and IDM systems can authenticate people
|
||||
and provide external facts. GovOPlaN access owns the normalized runtime
|
||||
projection used for sessions, tenant memberships, groups, functions, roles,
|
||||
delegations, and permission decisions.
|
||||
|
||||
## Semantic Layers
|
||||
|
||||
- Identity: the real person, service, or external subject. An identity can have
|
||||
multiple accounts, for example a normal account and a privileged
|
||||
administration account.
|
||||
- Account: the login or technical account used to authenticate. Access
|
||||
decisions are account-based because the account is the acting credential.
|
||||
- Tenant membership: the account's participation in a tenant.
|
||||
- Organization unit: the administrative unit where responsibility applies.
|
||||
Organization units are hierarchical; access must know when a function applies
|
||||
only to one unit or to that unit and all subunits.
|
||||
- Function: a named responsibility held by an account in an organization unit,
|
||||
such as case clerk, intake desk, treasurer, dean's office assistant, or
|
||||
committee secretary. A function can map to one or more access roles.
|
||||
- Role: a permission bundle or workflow authority attached to a function,
|
||||
group, or explicit assignment.
|
||||
- Right: the concrete scope or action permission evaluated at runtime.
|
||||
|
||||
The UI and API must not collapse these layers into a generic group concept.
|
||||
Directory groups can feed mappings, but they must not silently become business
|
||||
authority without a governed mapping rule.
|
||||
|
||||
## Organizational Function Scope
|
||||
|
||||
A function is meaningful only with organizational scope. The stable contract
|
||||
therefore separates:
|
||||
|
||||
- `FunctionRef`: the organization-bound function definition, including tenant,
|
||||
organization unit, role mappings, and delegation policy flags.
|
||||
- `FunctionAssignmentRef`: the account-held assignment for that function,
|
||||
including identity provenance and whether the assignment applies to all
|
||||
subunits of the function's organization unit.
|
||||
|
||||
The assignment is the runtime authority. A role mapped to a function does not
|
||||
grant rights until an account has an active assignment for that function.
|
||||
|
||||
Example:
|
||||
|
||||
- Identity `Anna Becker` owns accounts `anna` and `anna-admin`.
|
||||
- Account `anna` has function `Registry Clerk` in organization unit
|
||||
`Student Registry`.
|
||||
- The assignment has `applies_to_subunits = true`, so the same function applies
|
||||
to subordinate registry offices unless policy narrows it.
|
||||
- The function maps to role `registry.case_editor`, which grants rights such as
|
||||
`cases:case:update`.
|
||||
|
||||
## Delegation And Acting In Place
|
||||
|
||||
Functions can be delegated only if the function policy permits it. GovOPlaN
|
||||
distinguishes two delegation modes:
|
||||
|
||||
- Delegation: the delegate acts as themself, with provenance showing the
|
||||
delegated function assignment.
|
||||
- Acting in place: the actor performs an action in another holder's function
|
||||
context. Audit and explain responses must show both the real actor account
|
||||
and the account being represented.
|
||||
|
||||
Both modes should be time-bound, revocable, auditable, and visible in access
|
||||
explain output. Module code must not infer delegation from ordinary group
|
||||
membership.
|
||||
|
||||
## IDM Boundary
|
||||
|
||||
`govoplan-idm` owns synchronization with external IDM systems: SCIM, LDAP,
|
||||
SAML/OIDC claims, directory attributes, preview, rollback, and mapping import.
|
||||
It does not own GovOPlaN's internal identity, organization, function, role, or
|
||||
permission evaluation tables.
|
||||
|
||||
`govoplan-identity` owns canonical identity records and identity/account links.
|
||||
`govoplan-organizations` owns canonical organization units, functions, and
|
||||
function assignments. During the transition, access keeps a security projection
|
||||
of those concepts for compatibility and authorization, but new integrations
|
||||
should target the identity and organization capabilities first.
|
||||
|
||||
`govoplan-access` owns the platform projection created from those mappings:
|
||||
|
||||
- accounts and tenant membership projection
|
||||
- identity-to-account links used for explainability
|
||||
- groups, roles, function assignments, and delegation facts
|
||||
- permission decisions and explain responses
|
||||
- access-owned identity and membership change events
|
||||
- mapping effects after an IDM import is accepted
|
||||
|
||||
Access does not own:
|
||||
|
||||
- mailboxes, calendars, files, cases, tasks, postboxes, or other module data
|
||||
- canonical organization structure once `govoplan-organizations` is enabled
|
||||
- canonical identity records once `govoplan-identity` is enabled
|
||||
- module-specific ACL records beyond stable principal/group/role references
|
||||
- external provider internals except where they mutate access-owned state
|
||||
|
||||
## Kernel Contracts
|
||||
|
||||
The stable DTO and protocol surface lives in
|
||||
`govoplan_core.core.access`. The current groundwork adds:
|
||||
|
||||
- `IdentityRef`
|
||||
- `OrganizationUnitRef`
|
||||
- `FunctionRef`
|
||||
- `FunctionAssignmentRef`
|
||||
- `FunctionDelegationRef`
|
||||
- `AccessDecisionProvenance`
|
||||
- `AccessSemanticDirectory`
|
||||
- `AccessExplanationService`
|
||||
|
||||
Feature modules should consume those contracts instead of importing access ORM
|
||||
models. Storage, migration, and admin UI work can evolve behind the contract
|
||||
without changing module integrations.
|
||||
|
||||
## Required Explainability
|
||||
|
||||
Access decisions must be explainable in concrete terms:
|
||||
|
||||
- actor identity and account
|
||||
- tenant membership
|
||||
- organization unit
|
||||
- function assignment or group membership
|
||||
- role source
|
||||
- permission or right checked
|
||||
- delegation or acting-in-place context
|
||||
- policy, lock, or maintenance state that changed the result
|
||||
|
||||
This shape is required for role-bound postboxes, workflow authorization,
|
||||
service directory personalization, delegated administration, and audit review.
|
||||
|
||||
## Consumer Expectations
|
||||
|
||||
- Postbox can grant access to a role-bound or function-bound postbox without
|
||||
tying the postbox to a specific login account.
|
||||
- Portal/service directory can show services relevant to a user's current
|
||||
organization functions and tenant membership.
|
||||
- Workflow can ask whether the current account can act in a function context
|
||||
for a given organization unit.
|
||||
- Audit can show who acted, with which account, under which function, and
|
||||
whether delegation or acting-in-place was involved.
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
Implemented backend foundation:
|
||||
|
||||
- Kernel DTOs/protocols are covered by focused contract tests.
|
||||
- Access-owned storage exists for identities, account links, organization
|
||||
units, functions, function-role mappings, function assignments, and function
|
||||
delegations.
|
||||
- Admin APIs exist under `/api/v1/admin/identities`,
|
||||
`/api/v1/admin/organization-units`, `/api/v1/admin/functions`,
|
||||
`/api/v1/admin/function-assignments`, and
|
||||
`/api/v1/admin/function-delegations`.
|
||||
- `PrincipalRef` population includes identity, role, function assignment, and
|
||||
delegation identifiers when those facts exist.
|
||||
- The access manifest registers `access.semanticDirectory` and
|
||||
`access.explanation` capabilities.
|
||||
- Interactive sessions can list `/api/v1/auth/acting-contexts` and explicitly
|
||||
select or clear one with `/api/v1/auth/switch-acting-context`. Every switch is
|
||||
audited. API keys cannot select an acting context, and a stale, expired,
|
||||
revoked, or account-mismatched assignment fails closed.
|
||||
|
||||
Remaining rollout:
|
||||
|
||||
1. Move canonical identity and organization reads to `identity.directory` and
|
||||
`organizations.directory`, keeping access-owned rows as a compatibility
|
||||
projection until migration is complete.
|
||||
2. Add dedicated WebUI management panels for identities, organization units,
|
||||
functions, assignments, and delegations.
|
||||
3. Retrofit postbox, workflow, portal, and audit consumers to use identity,
|
||||
organization, and access explanation capabilities rather than local access
|
||||
assumptions.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Access interface pattern migration
|
||||
|
||||
This document records the Access-owned surfaces covered by the platform
|
||||
interface pattern language. Shared primitives remain owned by Core and
|
||||
optional Mail, Files, Organizations, IDM, and Docs behavior is consumed only
|
||||
through declared capabilities or metadata.
|
||||
|
||||
## Surface inventory
|
||||
|
||||
| Surface | Archetype | Authority and state model |
|
||||
| --- | --- | --- |
|
||||
| `/admin` tree and unavailable state | Tree-navigated administration workspace | The effective principal and active View determine which branches exist. A missing administration grant is an explained blocker, not an empty route. |
|
||||
| System and tenant users | Server-authoritative directory plus list/detail editor | Delta reads refresh accounts and memberships. Create, update, assignment, suspension, and final-owner safeguards remain independent permissions. |
|
||||
| System and tenant roles | Governed definition directory | Built-in and system-managed definitions remain visible but immutable. Assigned roles cannot be deleted. |
|
||||
| Tenant groups | Governed definition and membership editor | Definition, membership, and role-assignment rights remain independent. Required system groups cannot be deactivated. |
|
||||
| Tenant API keys | Immutable-secret lifecycle directory | A key is created once, its secret is shown once, and revocation is consequential and confirmed. |
|
||||
| Function mappings | Governed cross-module mapping editor | Organizations supplies function choices, IDM supplies accepted facts, and Access maps facts to assignable roles. |
|
||||
| Credential scopes | Adaptive configuration panel | Core owns the reusable credential manager. Access supplies system, tenant, group, and user ownership choices. |
|
||||
| Mail and Files scope panels | Optional capability host | Access supplies owner selection; the owning module supplies configuration UI. A missing capability names the required module, actor, and destination. |
|
||||
|
||||
## Consequence classes
|
||||
|
||||
- Reload, inspect, filter, select, and open-help actions are reversible.
|
||||
- User, group, role, mapping, and credential edits are governed mutations and
|
||||
expose permission or validation blockers before submission.
|
||||
- Account or membership deactivation, group deactivation, role deletion,
|
||||
mapping deletion, API-key revocation, and credential deletion are
|
||||
consequential actions and use the shared confirmation contract.
|
||||
- Secret values and temporary passwords are never placed in list rows or
|
||||
persistent notices. One-time values remain inside dedicated dialogs.
|
||||
|
||||
## Interaction evidence
|
||||
|
||||
- `WorkspaceLayout`, headerless `PageLayout`, `AdminPageLayout`, `TreeSubnav`,
|
||||
`DataGrid`, `Dialog`, `ConfirmDialog`,
|
||||
`TableActionGroup`, `PasswordField`, `ActionBlockerHint`, and
|
||||
`DocumentationHelpLink` come from Core.
|
||||
- The administration tree and its contributed panels now share Core-owned pane
|
||||
sizing, scrolling, content inset, responsive collapse, region labels, and
|
||||
contextual-help identity; Access no longer carries a raw workspace or page
|
||||
frame exception.
|
||||
- Dialog focus trapping and restoration, disabled-action tooltips, keyboard
|
||||
ordering, responsive grid overflow, and alert semantics therefore inherit
|
||||
the tested Core behavior.
|
||||
- All Access-owned labels added by this migration are present in the English
|
||||
and German module catalogs.
|
||||
- The WebUI structural test rejects browser-native confirmation calls, private
|
||||
sibling imports, missing contextual-help references, and unexplained
|
||||
optional-module blockers.
|
||||
|
||||
## Documentation contexts
|
||||
|
||||
- `access.workflow.grant-user-access` covers the user, group, and role path.
|
||||
- `access.reference.admin-access-fields` covers accounts, roles, API keys, and
|
||||
their backing administration fields.
|
||||
- `access.workflow.manage-api-keys` owns exact help for accountable ownership,
|
||||
bounded scopes and expiry, one-time secret custody, and immediate revocation.
|
||||
- `access.workflow.manage-service-account-credentials` owns exact help for the
|
||||
account ceiling, activation state, credential rotation/revocation, one-time
|
||||
secret custody, concurrency, and retirement consequences.
|
||||
- `access.reference.external-function-role-mappings` explains the
|
||||
Organizations, IDM, and Access responsibility split.
|
||||
- Files and Mail blockers link to documentation supplied by the owning module.
|
||||
@@ -0,0 +1,97 @@
|
||||
# OpenDesk Identity Integration Boundary
|
||||
|
||||
OpenDesk-style identity integrations should terminate in `govoplan-access` as
|
||||
canonical accounts, tenant memberships, groups, roles, sessions, and principal
|
||||
claims. Provider protocol details may live in access subpackages or dedicated
|
||||
connector modules, but other GovOPlaN modules must consume identity through
|
||||
access capabilities, typed DTOs, events, and published route dependencies.
|
||||
|
||||
## Boundary Decision
|
||||
|
||||
`govoplan-access` owns the identity projection and authorization effects:
|
||||
|
||||
- external identity links for accounts and memberships
|
||||
- authentication callback/session issuance for federated login
|
||||
- claim, group, and role mapping into access-owned roles and memberships
|
||||
- SCIM-style provisioning effects for accounts, users, groups, and group
|
||||
membership
|
||||
- account suspension/deactivation effects that influence sessions and API keys
|
||||
- audit-relevant identity events emitted through kernel event/audit contracts
|
||||
|
||||
Connector packages may own provider-specific transport and schema logic:
|
||||
|
||||
- LDAP and Active Directory bind/search/sync adapters
|
||||
- OIDC and SAML provider metadata, callback protocol handling, and claim
|
||||
normalization
|
||||
- SCIM client/server protocol specifics
|
||||
- Open-Xchange identity lookup or provisioning clients
|
||||
|
||||
Those connectors should call access capabilities or access-owned service APIs
|
||||
instead of writing access tables directly.
|
||||
|
||||
## Integration Types
|
||||
|
||||
### LDAP And Active Directory
|
||||
|
||||
LDAP/AD adapters may authenticate credentials, search directory entries, and
|
||||
sync group membership. Access owns the resulting account, membership, group,
|
||||
and role mapping. Directory groups should map to access groups or role
|
||||
assignments through explicit mapping rules; they should not grant feature
|
||||
module permissions directly.
|
||||
|
||||
### OIDC And SAML
|
||||
|
||||
OIDC/SAML adapters may handle provider metadata, assertions, tokens, and claim
|
||||
normalization. Access owns external subject linking, session creation, tenant
|
||||
selection, first-login behavior, and claim-to-role/group mapping. Feature
|
||||
modules should see only `PrincipalRef`, `UserRef`, scopes, group IDs, and
|
||||
tenant context.
|
||||
|
||||
### SCIM Provisioning
|
||||
|
||||
SCIM provisioning belongs at the access boundary because it mutates accounts,
|
||||
memberships, groups, and deactivation state. Tenant resolution remains a kernel
|
||||
or tenancy capability concern. SCIM must not provision mailboxes, calendars,
|
||||
campaign ownership, or file spaces directly; those modules may react to
|
||||
access-published identity events when needed.
|
||||
|
||||
### Open-Xchange Touchpoints
|
||||
|
||||
Open-Xchange identity/contact integration should split identity from
|
||||
collaboration data:
|
||||
|
||||
- Access owns external account IDs, email/display-name identity fields,
|
||||
membership state, group references, and auth/session effects.
|
||||
- Mail, calendar, contacts, or connector modules own mailboxes, address books,
|
||||
calendar resources, contact folders, and provider-specific collaboration
|
||||
objects.
|
||||
|
||||
Access may publish identity-change events and stable DTOs that those modules
|
||||
consume, but it should not import their internals or own their provider data.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Access does not own:
|
||||
|
||||
- campaign ACLs, campaign ownership, delivery policy, or recipient contacts
|
||||
- file storage permissions beyond principal/group identity references
|
||||
- mail profile credentials, mailbox state, or reusable mail-server profiles
|
||||
- calendar availability, appointments, rooms, or contact address books
|
||||
- provider-specific UI panels for non-identity configuration
|
||||
|
||||
Those belong to their owning modules and should integrate through capabilities
|
||||
or events.
|
||||
|
||||
## Implementation Shape
|
||||
|
||||
Provider integration should be added in small slices:
|
||||
|
||||
1. Define an access-owned external identity link model and DTO surface.
|
||||
2. Add provider adapter contracts that normalize external subjects, groups,
|
||||
claims, and deactivation signals.
|
||||
3. Route OIDC/SAML login callbacks through access so sessions are issued by
|
||||
the access session service.
|
||||
4. Route LDAP/AD/SCIM provisioning through access administration services and
|
||||
tenant provisioning capabilities.
|
||||
5. Publish identity-change events for optional mail/calendar/contact/file
|
||||
reactions without adding module-to-module imports.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Service accounts
|
||||
|
||||
Service accounts are tenant-owned, non-login principals for automation. Their
|
||||
backing account and membership cannot use a password or browser session.
|
||||
|
||||
## Authorization model
|
||||
|
||||
The service account defines a revisioned scope ceiling. Every credential has
|
||||
its own narrower scope grant. On every authenticated request, Access checks
|
||||
that the tenant, service account, backing account, membership, and credential
|
||||
are active, then grants only the intersection of the current ceiling and the
|
||||
credential scopes. Reducing the ceiling therefore takes effect without
|
||||
reissuing a credential.
|
||||
|
||||
Administrators may grant only scopes they currently hold. Credential creation
|
||||
also follows the tenant API-key governance switch. Secrets are returned once;
|
||||
the database stores a one-way hash and a non-authenticating prefix.
|
||||
|
||||
## Administration
|
||||
|
||||
Open `Admin > Tenant > Service accounts` to create, edit, deactivate, activate,
|
||||
or retire a principal. The detail dialog lists active, expired, and revoked
|
||||
credentials and exposes create, rotate, and revoke actions.
|
||||
|
||||
Every write includes `expected_revision`. A concurrent change returns `409`
|
||||
and the UI reloads the account before another action. Rotation creates the new
|
||||
credential and revokes the old one in a single transaction. Retirement
|
||||
deactivates the principal and revokes all active credentials.
|
||||
|
||||
## API
|
||||
|
||||
- `GET/POST /api/v1/admin/service-accounts`
|
||||
- `GET/PATCH /api/v1/admin/service-accounts/{service_account_id}`
|
||||
- `POST /api/v1/admin/service-accounts/{service_account_id}/retire`
|
||||
- `GET/POST /api/v1/admin/service-accounts/{service_account_id}/credentials`
|
||||
- `POST /api/v1/admin/service-accounts/{service_account_id}/credentials/{credential_id}/rotate`
|
||||
- `POST /api/v1/admin/service-accounts/{service_account_id}/credentials/{credential_id}/revoke`
|
||||
|
||||
Credential list responses never contain a secret. Create and rotate responses
|
||||
contain it once. Audit records include identifiers, prefixes, scopes, and the
|
||||
new service-account revision, but never the secret or its hash.
|
||||
|
||||
## Contextual help
|
||||
|
||||
F1 on the service-account page, its editors, scope controls, one-time secret,
|
||||
rotation and revocation actions, activation state, or retirement confirmation
|
||||
resolves to the Access-owned `access.workflow.manage-service-account-credentials`
|
||||
topic. The German reference content distinguishes reversible deactivation from
|
||||
retirement, explains immediate client impact, and states that secrets cannot be
|
||||
recovered. Tenant API-key controls resolve separately to
|
||||
`access.workflow.manage-api-keys`, because their effective authorization also
|
||||
depends on the accountable human owner's current permissions.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Session And Device Management
|
||||
|
||||
Authenticated users can inspect their active browser sessions under **Settings
|
||||
> Sessions and devices**. Each row exposes only a stable session identifier,
|
||||
current-session marker, bounded user-agent label, creation time, last activity,
|
||||
expiry, and lifecycle state. Session tokens, token and CSRF hashes, cookies, IP
|
||||
addresses, and unrelated request metadata are never returned.
|
||||
|
||||
Users may revoke one other session or all other active sessions. The current
|
||||
session is deliberately protected by these operations; use normal logout to end
|
||||
it. Repeating a revocation is safe. Revoked sessions fail authentication on the
|
||||
next request, including when a principal summary was previously cached.
|
||||
|
||||
The shared WebUI clears reusable API response data on explicit authentication,
|
||||
account, tenant, and permission transitions, changed session/CSRF cookies, and
|
||||
authentication-expiry responses. Late reads cannot repopulate caches after those
|
||||
transitions or after a write finishes. `no-store` responses are not retained;
|
||||
`no-cache` responses require server revalidation, with ETags retained only where
|
||||
storage is allowed. Reload bypasses older cached responses. These safeguards do
|
||||
not erase content already displayed by a page: reload that page to reflect
|
||||
remote changes. The server remains authoritative for every permission check.
|
||||
|
||||
Successful interactive sign-in, including re-login, and local sign-out clear
|
||||
the saved automation API key. It must not shadow the newly established cookie
|
||||
session with a different principal. Explicitly applying an API key in connection
|
||||
settings still selects that credential's identity and triggers a new shell
|
||||
authentication check. Ordinary profile updates in API-key mode retain the key.
|
||||
|
||||
Tenant administrators may list sessions only for a membership in their governed
|
||||
tenant and may revoke only a session belonging to that membership and tenant.
|
||||
The mutation requires both the central membership-update permission and an
|
||||
interactive-session password re-authorization. API-key administration and
|
||||
cross-tenant session disclosure fail closed.
|
||||
|
||||
Audit events retain the actor, target session or account, action, and revoked
|
||||
count where applicable. They do not copy client labels, network addresses, or
|
||||
credentials. Expired and revoked sessions are retained according to Access data
|
||||
retention and are omitted from the active-session list.
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -18,11 +18,11 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.5",
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
+4
-4
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-access"
|
||||
version = "0.1.5"
|
||||
description = "GovOPlaN access platform module with identity, auth, RBAC, and tenancy primitives."
|
||||
version = "0.1.25"
|
||||
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.5",
|
||||
"govoplan-tenancy>=0.1.5",
|
||||
"govoplan-core>=0.1.45",
|
||||
"redis>=5,<6",
|
||||
"SQLAlchemy>=2,<3",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN access platform module."""
|
||||
|
||||
__version__ = "0.1.4"
|
||||
__version__ = "0.1.25"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Public FastAPI access dependency API.
|
||||
|
||||
Feature modules may import this package when they need request principal and
|
||||
scope dependencies. Implementation details remain under ``govoplan_access.backend``.
|
||||
"""
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import (
|
||||
ApiPrincipal,
|
||||
get_api_principal,
|
||||
has_scope,
|
||||
require_any_scope,
|
||||
require_scope,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ApiPrincipal",
|
||||
"get_api_principal",
|
||||
"has_scope",
|
||||
"require_any_scope",
|
||||
"require_scope",
|
||||
]
|
||||
@@ -20,16 +20,18 @@ from govoplan_access.backend.db.models import (
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_access.backend.semantic import ensure_identity_for_account
|
||||
from govoplan_access.backend.security.passwords import hash_password
|
||||
from govoplan_core.security.permissions import (
|
||||
DEFAULT_SYSTEM_ROLES,
|
||||
DEFAULT_TENANT_ROLES,
|
||||
from govoplan_access.backend.permissions.catalog import (
|
||||
delegateable_system_scopes,
|
||||
delegateable_tenant_scopes,
|
||||
normalize_email,
|
||||
scopes_grant,
|
||||
validate_system_permissions,
|
||||
validate_tenant_permissions,
|
||||
role_templates_for_level,
|
||||
)
|
||||
from govoplan_core.tenancy.service import tenant_counts # re-exported compatibility helper
|
||||
from govoplan_core.tenancy.service import tenant_counts # noqa: F401 - re-exported compatibility helper
|
||||
|
||||
_TEMP_PASSWORD_ALPHABET = string.ascii_letters + string.digits + "-_!@#"
|
||||
|
||||
@@ -47,21 +49,23 @@ def generate_temporary_password(length: int = 20) -> str:
|
||||
|
||||
|
||||
def ensure_default_roles(session: Session, tenant: Tenant | None = None) -> dict[str, Role]:
|
||||
definitions = DEFAULT_TENANT_ROLES if tenant is not None else DEFAULT_SYSTEM_ROLES
|
||||
level = "tenant" if tenant is not None else "system"
|
||||
roles: dict[str, Role] = {}
|
||||
for slug, definition in definitions.items():
|
||||
query = session.query(Role).filter(Role.slug == slug)
|
||||
for template in role_templates_for_level(level):
|
||||
query = session.query(Role).filter(Role.slug == template.slug)
|
||||
query = query.filter(Role.tenant_id == tenant.id) if tenant is not None else query.filter(Role.tenant_id.is_(None))
|
||||
role = query.one_or_none()
|
||||
is_builtin = _template_is_builtin(template_managed=template.managed, protected=template.protected, tenant_role=tenant is not None)
|
||||
is_assignable = not template.default_authenticated
|
||||
if role is None:
|
||||
role = Role(
|
||||
tenant_id=tenant.id if tenant is not None else None,
|
||||
slug=slug,
|
||||
name=str(definition["name"]),
|
||||
description=str(definition.get("description") or "") or None,
|
||||
permissions=list(definition["permissions"]),
|
||||
is_builtin=bool(definition.get("is_builtin", True)),
|
||||
is_assignable=bool(definition.get("is_assignable", True)),
|
||||
slug=template.slug,
|
||||
name=template.name,
|
||||
description=template.description or None,
|
||||
permissions=list(template.permissions),
|
||||
is_builtin=is_builtin,
|
||||
is_assignable=is_assignable,
|
||||
)
|
||||
session.add(role)
|
||||
session.flush()
|
||||
@@ -69,18 +73,30 @@ def ensure_default_roles(session: Session, tenant: Tenant | None = None) -> dict
|
||||
# Tenant built-ins and explicitly managed system roles remain
|
||||
# code-defined. Seeded, non-protected system roles are only created
|
||||
# here and can subsequently be administered in the System roles UI.
|
||||
managed = tenant is not None or bool(definition.get("managed", False))
|
||||
managed = _template_updates_existing(template_managed=template.managed, protected=template.protected, tenant_role=tenant is not None)
|
||||
if managed:
|
||||
role.name = str(definition["name"])
|
||||
role.description = str(definition.get("description") or "") or None
|
||||
role.permissions = list(definition["permissions"])
|
||||
role.is_builtin = bool(definition.get("is_builtin", True))
|
||||
role.is_assignable = bool(definition.get("is_assignable", True))
|
||||
role.name = template.name
|
||||
role.description = template.description or None
|
||||
role.permissions = list(template.permissions)
|
||||
role.is_builtin = is_builtin
|
||||
role.is_assignable = is_assignable
|
||||
session.add(role)
|
||||
roles[slug] = role
|
||||
roles[template.slug] = role
|
||||
return roles
|
||||
|
||||
|
||||
def _template_is_builtin(*, template_managed: bool, protected: bool, tenant_role: bool) -> bool:
|
||||
if tenant_role:
|
||||
return template_managed or protected
|
||||
return protected
|
||||
|
||||
|
||||
def _template_updates_existing(*, template_managed: bool, protected: bool, tenant_role: bool) -> bool:
|
||||
if tenant_role:
|
||||
return template_managed or protected
|
||||
return protected
|
||||
|
||||
|
||||
def get_or_create_account(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -98,6 +114,7 @@ def get_or_create_account(
|
||||
raise AdminConflictError(
|
||||
"The global account is disabled. A system administrator must reactivate it before adding a tenant membership."
|
||||
)
|
||||
ensure_identity_for_account(session, account)
|
||||
return account, False, None
|
||||
|
||||
temporary_password = password or generate_temporary_password()
|
||||
@@ -112,6 +129,7 @@ def get_or_create_account(
|
||||
)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
ensure_identity_for_account(session, account)
|
||||
return account, True, temporary_password
|
||||
|
||||
|
||||
@@ -182,7 +200,24 @@ def set_user_groups(session: Session, *, user: User, group_ids: Iterable[str]) -
|
||||
|
||||
|
||||
def set_user_roles(session: Session, *, user: User, role_ids: Iterable[str]) -> None:
|
||||
ids = sorted(set(role_ids))
|
||||
default_slugs = {
|
||||
template.slug
|
||||
for template in role_templates_for_level("tenant")
|
||||
if template.default_authenticated
|
||||
}
|
||||
default_roles = (
|
||||
session.query(Role)
|
||||
.filter(
|
||||
Role.tenant_id == user.tenant_id,
|
||||
Role.slug.in_(default_slugs),
|
||||
)
|
||||
.all()
|
||||
if default_slugs
|
||||
else []
|
||||
)
|
||||
default_role_ids = {role.id for role in default_roles}
|
||||
requested_ids = set(role_ids) - default_role_ids
|
||||
ids = sorted(requested_ids)
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.filter(Role.tenant_id == user.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True))
|
||||
@@ -378,8 +413,6 @@ def delete_system_role(session: Session, role: Role) -> None:
|
||||
|
||||
def assert_can_delegate_system_permissions(actor_scopes: Iterable[str], permissions: Iterable[str]) -> None:
|
||||
"""Prevent a system administrator from defining stronger roles than they hold."""
|
||||
from govoplan_core.security.permissions import delegateable_system_scopes
|
||||
|
||||
requested = set(validate_system_permissions(permissions))
|
||||
if "system:*" in requested:
|
||||
if not scopes_grant(actor_scopes, "system:*"):
|
||||
@@ -543,7 +576,6 @@ def role_assignment_counts(session: Session, role_id: str) -> tuple[int, int]:
|
||||
|
||||
def assert_can_delegate_tenant_permissions(actor_scopes: Iterable[str], permissions: Iterable[str]) -> None:
|
||||
"""Prevent administrators from defining or assigning roles beyond their own effective tenant powers."""
|
||||
from govoplan_core.security.permissions import delegateable_tenant_scopes
|
||||
requested = set(validate_tenant_permissions(permissions))
|
||||
if "tenant:*" in requested:
|
||||
# Only a tenant owner-equivalent can delegate the wildcard.
|
||||
|
||||
@@ -2,14 +2,109 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import case, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, Role, User
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, Group, Role, User
|
||||
from govoplan_core.core.access import AccessAdministration
|
||||
|
||||
|
||||
class SqlAccessAdministration(AccessAdministration):
|
||||
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
|
||||
db = _session(session)
|
||||
users, active_users = (
|
||||
db.query(
|
||||
func.count(User.id),
|
||||
func.coalesce(
|
||||
func.sum(case((User.is_active.is_(True), 1), else_=0)),
|
||||
0,
|
||||
),
|
||||
)
|
||||
.filter(User.tenant_id == tenant_id)
|
||||
.one()
|
||||
)
|
||||
api_keys, active_api_keys = (
|
||||
db.query(
|
||||
func.count(ApiKey.id),
|
||||
func.coalesce(
|
||||
func.sum(case((ApiKey.revoked_at.is_(None), 1), else_=0)),
|
||||
0,
|
||||
),
|
||||
)
|
||||
.filter(ApiKey.tenant_id == tenant_id)
|
||||
.one()
|
||||
)
|
||||
return {
|
||||
"users": int(users),
|
||||
"active_users": int(active_users),
|
||||
"groups": db.query(Group).filter(Group.tenant_id == tenant_id).count(),
|
||||
"api_keys": int(api_keys),
|
||||
"active_api_keys": int(active_api_keys),
|
||||
}
|
||||
|
||||
def tenant_counts_many(
|
||||
self,
|
||||
session: object,
|
||||
tenant_ids: Sequence[str],
|
||||
) -> Mapping[str, Mapping[str, int]]:
|
||||
ids = tuple(dict.fromkeys(str(tenant_id) for tenant_id in tenant_ids if tenant_id))
|
||||
if not ids:
|
||||
return {}
|
||||
db = _session(session)
|
||||
counts: dict[str, dict[str, int]] = {
|
||||
tenant_id: {
|
||||
"users": 0,
|
||||
"active_users": 0,
|
||||
"groups": 0,
|
||||
"api_keys": 0,
|
||||
"active_api_keys": 0,
|
||||
}
|
||||
for tenant_id in ids
|
||||
}
|
||||
user_rows = (
|
||||
db.query(
|
||||
User.tenant_id,
|
||||
func.count(User.id),
|
||||
func.coalesce(
|
||||
func.sum(case((User.is_active.is_(True), 1), else_=0)),
|
||||
0,
|
||||
),
|
||||
)
|
||||
.filter(User.tenant_id.in_(ids))
|
||||
.group_by(User.tenant_id)
|
||||
.all()
|
||||
)
|
||||
for tenant_id, users, active_users in user_rows:
|
||||
counts[tenant_id]["users"] = int(users)
|
||||
counts[tenant_id]["active_users"] = int(active_users)
|
||||
|
||||
group_rows = (
|
||||
db.query(Group.tenant_id, func.count(Group.id))
|
||||
.filter(Group.tenant_id.in_(ids))
|
||||
.group_by(Group.tenant_id)
|
||||
.all()
|
||||
)
|
||||
for tenant_id, groups in group_rows:
|
||||
counts[tenant_id]["groups"] = int(groups)
|
||||
|
||||
api_key_rows = (
|
||||
db.query(
|
||||
ApiKey.tenant_id,
|
||||
func.count(ApiKey.id),
|
||||
func.coalesce(
|
||||
func.sum(case((ApiKey.revoked_at.is_(None), 1), else_=0)),
|
||||
0,
|
||||
),
|
||||
)
|
||||
.filter(ApiKey.tenant_id.in_(ids))
|
||||
.group_by(ApiKey.tenant_id)
|
||||
.all()
|
||||
)
|
||||
for tenant_id, api_keys, active_api_keys in api_key_rows:
|
||||
counts[tenant_id]["api_keys"] = int(api_keys)
|
||||
counts[tenant_id]["active_api_keys"] = int(active_api_keys)
|
||||
return counts
|
||||
|
||||
def system_account_count(self, session: object) -> int:
|
||||
db = _session(session)
|
||||
return db.query(Account).count()
|
||||
@@ -50,6 +145,50 @@ class SqlAccessAdministration(AccessAdministration):
|
||||
)
|
||||
return tuple(user_id for (user_id,) in rows)
|
||||
|
||||
def user_settings(self, session: object, user_id: str, *, tenant_id: str) -> Mapping[str, object] | None:
|
||||
user = _session(session).get(User, user_id)
|
||||
if user is None or user.tenant_id != tenant_id:
|
||||
return None
|
||||
return dict(user.settings or {})
|
||||
|
||||
def set_user_settings(
|
||||
self,
|
||||
session: object,
|
||||
user_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
settings: Mapping[str, object],
|
||||
) -> Mapping[str, object] | None:
|
||||
db = _session(session)
|
||||
user = db.get(User, user_id)
|
||||
if user is None or user.tenant_id != tenant_id:
|
||||
return None
|
||||
user.settings = dict(settings)
|
||||
db.add(user)
|
||||
return dict(user.settings or {})
|
||||
|
||||
def group_settings(self, session: object, group_id: str, *, tenant_id: str) -> Mapping[str, object] | None:
|
||||
group = _session(session).get(Group, group_id)
|
||||
if group is None or group.tenant_id != tenant_id:
|
||||
return None
|
||||
return dict(group.settings or {})
|
||||
|
||||
def set_group_settings(
|
||||
self,
|
||||
session: object,
|
||||
group_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
settings: Mapping[str, object],
|
||||
) -> Mapping[str, object] | None:
|
||||
db = _session(session)
|
||||
group = db.get(Group, group_id)
|
||||
if group is None or group.tenant_id != tenant_id:
|
||||
return None
|
||||
group.settings = dict(settings)
|
||||
db.add(group)
|
||||
return dict(group.settings or {})
|
||||
|
||||
|
||||
def _session(session: object) -> Session:
|
||||
if not isinstance(session, Session):
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.admin.service import (
|
||||
@@ -21,10 +24,14 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
)
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
collect_direct_user_roles,
|
||||
collect_system_roles,
|
||||
collect_user_groups,
|
||||
collect_user_scopes,
|
||||
)
|
||||
from govoplan_access.backend.semantic import (
|
||||
collect_external_function_roles,
|
||||
collect_function_assignment_ids,
|
||||
collect_function_delegation_ids,
|
||||
)
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
@@ -36,15 +43,22 @@ from govoplan_access.backend.db.models import (
|
||||
Tenant,
|
||||
User,
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import OrganizationDirectory
|
||||
from govoplan_access.backend.permissions.catalog import (
|
||||
effective_permission_count,
|
||||
expand_scopes,
|
||||
scopes_grant,
|
||||
)
|
||||
from govoplan_core.security.permissions import effective_permission_count
|
||||
|
||||
|
||||
def _http_admin_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, AdminConflictError):
|
||||
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
if isinstance(exc, AdminValidationError):
|
||||
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc))
|
||||
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
|
||||
@@ -75,13 +89,147 @@ def _resolve_tenant(
|
||||
return tenant
|
||||
|
||||
|
||||
def _role_summary(session: Session, role: Role) -> RoleSummary:
|
||||
def _tenant_role_assignment_counts(session: Session, role_ids: list[str]) -> dict[str, tuple[int, int]]:
|
||||
if not role_ids:
|
||||
return {}
|
||||
user_counts = {
|
||||
role_id: count
|
||||
for role_id, count in session.query(UserRoleAssignment.role_id, func.count(UserRoleAssignment.id))
|
||||
.filter(UserRoleAssignment.role_id.in_(role_ids))
|
||||
.group_by(UserRoleAssignment.role_id)
|
||||
.all()
|
||||
}
|
||||
group_counts = {
|
||||
role_id: count
|
||||
for role_id, count in session.query(GroupRoleAssignment.role_id, func.count(GroupRoleAssignment.id))
|
||||
.filter(GroupRoleAssignment.role_id.in_(role_ids))
|
||||
.group_by(GroupRoleAssignment.role_id)
|
||||
.all()
|
||||
}
|
||||
return {role_id: (int(user_counts.get(role_id, 0)), int(group_counts.get(role_id, 0))) for role_id in role_ids}
|
||||
|
||||
|
||||
def _system_role_assignment_counts(session: Session, role_ids: list[str]) -> dict[str, int]:
|
||||
if not role_ids:
|
||||
return {}
|
||||
return {
|
||||
role_id: int(count)
|
||||
for role_id, count in session.query(SystemRoleAssignment.role_id, func.count(SystemRoleAssignment.id))
|
||||
.filter(SystemRoleAssignment.role_id.in_(role_ids))
|
||||
.group_by(SystemRoleAssignment.role_id)
|
||||
.all()
|
||||
}
|
||||
|
||||
|
||||
def _accounts_by_id(session: Session, account_ids: list[str]) -> dict[str, Account]:
|
||||
if not account_ids:
|
||||
return {}
|
||||
return {
|
||||
account.id: account
|
||||
for account in session.query(Account).filter(Account.id.in_(sorted(set(account_ids)))).all()
|
||||
}
|
||||
|
||||
|
||||
def _accounts_by_user_id(session: Session, user_ids: list[str]) -> dict[str, Account]:
|
||||
if not user_ids:
|
||||
return {}
|
||||
return {
|
||||
user.id: account
|
||||
for user, account in session.query(User, Account)
|
||||
.join(Account, Account.id == User.account_id)
|
||||
.filter(User.id.in_(sorted(set(user_ids))))
|
||||
.all()
|
||||
}
|
||||
|
||||
|
||||
def _group_member_ids_by_group_id(session: Session, *, tenant_id: str, group_ids: list[str]) -> dict[str, list[str]]:
|
||||
grouped: dict[str, list[str]] = defaultdict(list)
|
||||
if not group_ids:
|
||||
return {}
|
||||
for group_id, user_id in (
|
||||
session.query(UserGroupMembership.group_id, UserGroupMembership.user_id)
|
||||
.filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.group_id.in_(sorted(set(group_ids))))
|
||||
.order_by(UserGroupMembership.group_id.asc(), UserGroupMembership.user_id.asc())
|
||||
.all()
|
||||
):
|
||||
grouped[group_id].append(user_id)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def _roles_by_group_id(session: Session, *, tenant_id: str, group_ids: list[str]) -> dict[str, list[Role]]:
|
||||
grouped: dict[str, list[Role]] = defaultdict(list)
|
||||
if not group_ids:
|
||||
return {}
|
||||
for group_id, role in (
|
||||
session.query(GroupRoleAssignment.group_id, Role)
|
||||
.join(Role, Role.id == GroupRoleAssignment.role_id)
|
||||
.filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.group_id.in_(sorted(set(group_ids))), Role.tenant_id == tenant_id)
|
||||
.order_by(GroupRoleAssignment.group_id.asc(), Role.name.asc())
|
||||
.all()
|
||||
):
|
||||
grouped[group_id].append(role)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def _groups_by_user_id(session: Session, *, tenant_id: str, user_ids: list[str]) -> dict[str, list[Group]]:
|
||||
grouped: dict[str, list[Group]] = defaultdict(list)
|
||||
if not user_ids:
|
||||
return {}
|
||||
for user_id, group in (
|
||||
session.query(UserGroupMembership.user_id, Group)
|
||||
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||
.filter(
|
||||
UserGroupMembership.tenant_id == tenant_id,
|
||||
UserGroupMembership.user_id.in_(sorted(set(user_ids))),
|
||||
Group.is_active.is_(True),
|
||||
)
|
||||
.order_by(UserGroupMembership.user_id.asc(), Group.name.asc())
|
||||
.all()
|
||||
):
|
||||
grouped[user_id].append(group)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def _roles_by_user_id(session: Session, *, tenant_id: str, user_ids: list[str]) -> dict[str, list[Role]]:
|
||||
grouped: dict[str, list[Role]] = defaultdict(list)
|
||||
if not user_ids:
|
||||
return {}
|
||||
for user_id, role in (
|
||||
session.query(UserRoleAssignment.user_id, Role)
|
||||
.join(Role, Role.id == UserRoleAssignment.role_id)
|
||||
.filter(
|
||||
UserRoleAssignment.tenant_id == tenant_id,
|
||||
UserRoleAssignment.user_id.in_(sorted(set(user_ids))),
|
||||
Role.tenant_id == tenant_id,
|
||||
)
|
||||
.order_by(UserRoleAssignment.user_id.asc(), Role.name.asc())
|
||||
.all()
|
||||
):
|
||||
grouped[user_id].append(role)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def _role_summary(
|
||||
session: Session,
|
||||
role: Role,
|
||||
*,
|
||||
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
|
||||
system_role_assignment_counts: dict[str, int] | None = None,
|
||||
) -> RoleSummary:
|
||||
group_count = 0
|
||||
if role.tenant_id is None:
|
||||
user_count = session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count()
|
||||
user_count = (
|
||||
system_role_assignment_counts.get(role.id, 0)
|
||||
if system_role_assignment_counts is not None
|
||||
else session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count()
|
||||
)
|
||||
permission_level = "system"
|
||||
else:
|
||||
user_count, group_count = role_assignment_counts(session, role.id)
|
||||
user_count, group_count = (
|
||||
tenant_role_assignment_counts.get(role.id, (0, 0))
|
||||
if tenant_role_assignment_counts is not None
|
||||
else role_assignment_counts(session, role.id)
|
||||
)
|
||||
permission_level = "tenant"
|
||||
permissions = list(role.permissions or [])
|
||||
return RoleSummary(
|
||||
@@ -101,19 +249,35 @@ def _role_summary(session: Session, role: Role) -> RoleSummary:
|
||||
)
|
||||
|
||||
|
||||
def _group_summary(session: Session, group: Group, *, include_members: bool = True) -> GroupSummary:
|
||||
member_ids = [
|
||||
row[0]
|
||||
for row in session.query(UserGroupMembership.user_id)
|
||||
.filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id)
|
||||
.all()
|
||||
]
|
||||
def _group_summary(
|
||||
session: Session,
|
||||
group: Group,
|
||||
*,
|
||||
include_members: bool = True,
|
||||
member_ids_by_group: dict[str, list[str]] | None = None,
|
||||
roles_by_group: dict[str, list[Role]] | None = None,
|
||||
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
|
||||
) -> GroupSummary:
|
||||
member_ids = (
|
||||
member_ids_by_group.get(group.id, [])
|
||||
if member_ids_by_group is not None
|
||||
else [
|
||||
row[0]
|
||||
for row in session.query(UserGroupMembership.user_id)
|
||||
.filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id)
|
||||
.all()
|
||||
]
|
||||
)
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
|
||||
.filter(GroupRoleAssignment.tenant_id == group.tenant_id, GroupRoleAssignment.group_id == group.id)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
roles_by_group.get(group.id, [])
|
||||
if roles_by_group is not None
|
||||
else (
|
||||
session.query(Role)
|
||||
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
|
||||
.filter(GroupRoleAssignment.tenant_id == group.tenant_id, GroupRoleAssignment.group_id == group.id)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
)
|
||||
return GroupSummary(
|
||||
id=group.id,
|
||||
@@ -123,7 +287,7 @@ def _group_summary(session: Session, group: Group, *, include_members: bool = Tr
|
||||
is_active=group.is_active,
|
||||
member_count=len(member_ids),
|
||||
member_ids=member_ids if include_members else [],
|
||||
roles=[_role_summary(session, role) for role in roles],
|
||||
roles=[_role_summary(session, role, tenant_role_assignment_counts=tenant_role_assignment_counts) for role in roles],
|
||||
created_at=group.created_at,
|
||||
updated_at=group.updated_at,
|
||||
system_template_id=group.system_template_id,
|
||||
@@ -131,12 +295,39 @@ def _group_summary(session: Session, group: Group, *, include_members: bool = Tr
|
||||
)
|
||||
|
||||
|
||||
def _user_item(session: Session, user: User, *, owner_ids: set[str] | None = None) -> UserAdminItem:
|
||||
account = session.get(Account, user.account_id)
|
||||
def _user_item(
|
||||
session: Session,
|
||||
user: User,
|
||||
*,
|
||||
owner_ids: set[str] | None = None,
|
||||
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
accounts_by_id: dict[str, Account] | None = None,
|
||||
groups_by_user: dict[str, list[Group]] | None = None,
|
||||
roles_by_user: dict[str, list[Role]] | None = None,
|
||||
group_member_ids_by_group: dict[str, list[str]] | None = None,
|
||||
group_roles_by_group: dict[str, list[Role]] | None = None,
|
||||
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
|
||||
) -> UserAdminItem:
|
||||
account = accounts_by_id.get(user.account_id) if accounts_by_id is not None else session.get(Account, user.account_id)
|
||||
if account is None:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing")
|
||||
groups = collect_user_groups(session, user)
|
||||
roles = collect_direct_user_roles(session, user)
|
||||
groups = groups_by_user.get(user.id, []) if groups_by_user is not None else collect_user_groups(session, user)
|
||||
roles = roles_by_user.get(user.id, []) if roles_by_user is not None else collect_direct_user_roles(session, user)
|
||||
external_roles = (
|
||||
collect_external_function_roles(
|
||||
session,
|
||||
user,
|
||||
idm_assignments,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
if idm_assignments else []
|
||||
)
|
||||
effective_scopes = set(collect_user_scopes(session, user, include_system=False))
|
||||
for role in external_roles:
|
||||
effective_scopes.update(role.permissions or [])
|
||||
function_assignment_ids = collect_function_assignment_ids(session, user)
|
||||
function_assignment_ids.extend(item.id for item in idm_assignments)
|
||||
effective_owner_ids = owner_ids if owner_ids is not None else tenant_owner_user_ids(session, user.tenant_id)
|
||||
return UserAdminItem(
|
||||
id=user.id,
|
||||
@@ -148,9 +339,21 @@ def _user_item(session: Session, user: User, *, owner_ids: set[str] | None = Non
|
||||
account_is_active=account.is_active,
|
||||
password_reset_required=account.password_reset_required,
|
||||
last_login_at=account.last_login_at,
|
||||
groups=[_group_summary(session, group, include_members=False) for group in groups],
|
||||
roles=[_role_summary(session, role) for role in roles],
|
||||
effective_scopes=collect_user_scopes(session, user, include_system=False),
|
||||
groups=[
|
||||
_group_summary(
|
||||
session,
|
||||
group,
|
||||
include_members=False,
|
||||
member_ids_by_group=group_member_ids_by_group,
|
||||
roles_by_group=group_roles_by_group,
|
||||
tenant_role_assignment_counts=tenant_role_assignment_counts,
|
||||
)
|
||||
for group in groups
|
||||
],
|
||||
roles=[_role_summary(session, role, tenant_role_assignment_counts=tenant_role_assignment_counts) for role in roles],
|
||||
function_assignment_ids=sorted(dict.fromkeys(function_assignment_ids)),
|
||||
function_delegation_ids=collect_function_delegation_ids(session, user),
|
||||
effective_scopes=expand_scopes(effective_scopes),
|
||||
is_owner=user.id in effective_owner_ids,
|
||||
is_last_active_owner=user.id in effective_owner_ids and len(effective_owner_ids) == 1,
|
||||
created_at=user.created_at,
|
||||
@@ -158,47 +361,255 @@ def _user_item(session: Session, user: User, *, owner_ids: set[str] | None = Non
|
||||
)
|
||||
|
||||
|
||||
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
|
||||
memberships = (
|
||||
def _system_membership_rows(
|
||||
session: Session,
|
||||
account_ids: list[str],
|
||||
) -> list[tuple[User, Tenant]]:
|
||||
return (
|
||||
session.query(User, Tenant)
|
||||
.join(Tenant, Tenant.id == User.tenant_id)
|
||||
.filter(User.account_id == account.id)
|
||||
.order_by(Tenant.name.asc())
|
||||
.filter(User.account_id.in_(account_ids))
|
||||
.order_by(User.account_id.asc(), Tenant.name.asc(), User.id.asc())
|
||||
.all()
|
||||
)
|
||||
owner_ids_by_tenant = {
|
||||
tenant.id: tenant_owner_user_ids(session, tenant.id)
|
||||
for _, tenant in memberships
|
||||
|
||||
|
||||
def _memberships_by_account(
|
||||
membership_rows: list[tuple[User, Tenant]],
|
||||
) -> dict[str, list[tuple[User, Tenant]]]:
|
||||
memberships_by_account: dict[str, list[tuple[User, Tenant]]] = defaultdict(list)
|
||||
for user, tenant in membership_rows:
|
||||
memberships_by_account[user.account_id].append((user, tenant))
|
||||
return memberships_by_account
|
||||
|
||||
|
||||
def _system_direct_roles_by_user(
|
||||
session: Session,
|
||||
user_ids: list[str],
|
||||
) -> dict[str, list[Role]]:
|
||||
roles_by_user: dict[str, list[Role]] = defaultdict(list)
|
||||
if not user_ids:
|
||||
return roles_by_user
|
||||
rows = (
|
||||
session.query(UserRoleAssignment.user_id, Role)
|
||||
.join(Role, Role.id == UserRoleAssignment.role_id)
|
||||
.filter(UserRoleAssignment.user_id.in_(user_ids))
|
||||
.order_by(UserRoleAssignment.user_id.asc(), Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
for user_id, role in rows:
|
||||
roles_by_user[user_id].append(role)
|
||||
return roles_by_user
|
||||
|
||||
|
||||
def _system_groups_by_user(
|
||||
session: Session,
|
||||
user_ids: list[str],
|
||||
) -> dict[str, list[Group]]:
|
||||
groups_by_user: dict[str, list[Group]] = defaultdict(list)
|
||||
if not user_ids:
|
||||
return groups_by_user
|
||||
rows = (
|
||||
session.query(UserGroupMembership.user_id, Group)
|
||||
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||
.filter(
|
||||
UserGroupMembership.user_id.in_(user_ids),
|
||||
Group.is_active.is_(True),
|
||||
)
|
||||
.order_by(UserGroupMembership.user_id.asc(), Group.name.asc())
|
||||
.all()
|
||||
)
|
||||
for user_id, group in rows:
|
||||
groups_by_user[user_id].append(group)
|
||||
return groups_by_user
|
||||
|
||||
|
||||
def _system_group_roles_by_user(
|
||||
session: Session,
|
||||
user_ids: list[str],
|
||||
) -> dict[str, list[Role]]:
|
||||
group_roles_by_user: dict[str, list[Role]] = defaultdict(list)
|
||||
if not user_ids:
|
||||
return group_roles_by_user
|
||||
rows = (
|
||||
session.query(UserGroupMembership.user_id, Role)
|
||||
.join(
|
||||
GroupRoleAssignment,
|
||||
GroupRoleAssignment.group_id == UserGroupMembership.group_id,
|
||||
)
|
||||
.join(Role, Role.id == GroupRoleAssignment.role_id)
|
||||
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||
.filter(
|
||||
UserGroupMembership.user_id.in_(user_ids),
|
||||
Group.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for user_id, role in rows:
|
||||
group_roles_by_user[user_id].append(role)
|
||||
return group_roles_by_user
|
||||
|
||||
|
||||
def _system_owner_ids_by_tenant(
|
||||
membership_rows: list[tuple[User, Tenant]],
|
||||
*,
|
||||
accounts_by_id: dict[str, Account],
|
||||
direct_roles_by_user: dict[str, list[Role]],
|
||||
group_roles_by_user: dict[str, list[Role]],
|
||||
) -> dict[str, set[str]]:
|
||||
owner_ids_by_tenant: dict[str, set[str]] = defaultdict(set)
|
||||
for user, tenant in membership_rows:
|
||||
account = accounts_by_id[user.account_id]
|
||||
if not user.is_active or not account.is_active:
|
||||
continue
|
||||
effective_permissions = [
|
||||
permission
|
||||
for role in direct_roles_by_user[user.id] + group_roles_by_user[user.id]
|
||||
for permission in (role.permissions or [])
|
||||
]
|
||||
if (
|
||||
scopes_grant(effective_permissions, "admin:roles:write")
|
||||
and scopes_grant(effective_permissions, "campaign:send")
|
||||
):
|
||||
owner_ids_by_tenant[tenant.id].add(user.id)
|
||||
return owner_ids_by_tenant
|
||||
|
||||
|
||||
def _system_roles_for_accounts(
|
||||
session: Session,
|
||||
account_ids: list[str],
|
||||
) -> tuple[dict[str, list[Role]], dict[str, int]]:
|
||||
system_roles_by_account: dict[str, list[Role]] = defaultdict(list)
|
||||
system_role_ids: set[str] = set()
|
||||
rows = (
|
||||
session.query(SystemRoleAssignment.account_id, Role)
|
||||
.join(Role, Role.id == SystemRoleAssignment.role_id)
|
||||
.filter(
|
||||
SystemRoleAssignment.account_id.in_(account_ids),
|
||||
Role.tenant_id.is_(None),
|
||||
)
|
||||
.order_by(SystemRoleAssignment.account_id.asc(), Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
for account_id, role in rows:
|
||||
system_roles_by_account[account_id].append(role)
|
||||
system_role_ids.add(role.id)
|
||||
return (
|
||||
system_roles_by_account,
|
||||
_system_role_assignment_counts(session, sorted(system_role_ids)),
|
||||
)
|
||||
|
||||
|
||||
def _system_membership_item(
|
||||
user: User,
|
||||
tenant: Tenant,
|
||||
*,
|
||||
roles_by_user: dict[str, list[Role]],
|
||||
groups_by_user: dict[str, list[Group]],
|
||||
owner_ids_by_tenant: dict[str, set[str]],
|
||||
) -> dict[str, object]:
|
||||
tenant_owner_ids = owner_ids_by_tenant[tenant.id]
|
||||
return {
|
||||
"tenant_id": tenant.id,
|
||||
"tenant_name": tenant.name,
|
||||
"user_id": user.id,
|
||||
"is_active": user.is_active and tenant.is_active,
|
||||
"role_ids": [role.id for role in roles_by_user[user.id]],
|
||||
"group_ids": [group.id for group in groups_by_user[user.id]],
|
||||
"is_owner": user.id in tenant_owner_ids,
|
||||
"is_last_active_owner": (
|
||||
user.id in tenant_owner_ids and len(tenant_owner_ids) == 1
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _system_account_response_item(
|
||||
session: Session,
|
||||
account: Account,
|
||||
*,
|
||||
memberships: list[tuple[User, Tenant]],
|
||||
roles_by_user: dict[str, list[Role]],
|
||||
groups_by_user: dict[str, list[Group]],
|
||||
owner_ids_by_tenant: dict[str, set[str]],
|
||||
system_roles: list[Role],
|
||||
system_role_counts: dict[str, int],
|
||||
) -> SystemAccountItem:
|
||||
return SystemAccountItem(
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
is_active=account.is_active,
|
||||
memberships=[
|
||||
{
|
||||
"tenant_id": tenant.id,
|
||||
"tenant_name": tenant.name,
|
||||
"user_id": user.id,
|
||||
"is_active": user.is_active and tenant.is_active,
|
||||
"role_ids": [role.id for role in collect_direct_user_roles(session, user)],
|
||||
"group_ids": [group.id for group in collect_user_groups(session, user)],
|
||||
"is_owner": user.id in owner_ids_by_tenant[tenant.id],
|
||||
"is_last_active_owner": (
|
||||
user.id in owner_ids_by_tenant[tenant.id]
|
||||
and len(owner_ids_by_tenant[tenant.id]) == 1
|
||||
),
|
||||
}
|
||||
_system_membership_item(
|
||||
user,
|
||||
tenant,
|
||||
roles_by_user=roles_by_user,
|
||||
groups_by_user=groups_by_user,
|
||||
owner_ids_by_tenant=owner_ids_by_tenant,
|
||||
)
|
||||
for user, tenant in memberships
|
||||
],
|
||||
roles=[_role_summary(session, role) for role in collect_system_roles(session, account)],
|
||||
roles=[
|
||||
_role_summary(
|
||||
session,
|
||||
role,
|
||||
system_role_assignment_counts=system_role_counts,
|
||||
)
|
||||
for role in system_roles
|
||||
],
|
||||
last_login_at=account.last_login_at,
|
||||
)
|
||||
|
||||
|
||||
def _api_key_item(session: Session, item: ApiKey) -> ApiKeyAdminItem:
|
||||
user = session.get(User, item.user_id)
|
||||
account = session.get(Account, user.account_id) if user else None
|
||||
def _system_account_items(
|
||||
session: Session,
|
||||
accounts: list[Account],
|
||||
) -> list[SystemAccountItem]:
|
||||
if not accounts:
|
||||
return []
|
||||
account_ids = [account.id for account in accounts]
|
||||
accounts_by_id = {account.id: account for account in accounts}
|
||||
membership_rows = _system_membership_rows(session, account_ids)
|
||||
memberships_by_account = _memberships_by_account(membership_rows)
|
||||
user_ids = [user.id for user, _tenant in membership_rows]
|
||||
roles_by_user = _system_direct_roles_by_user(session, user_ids)
|
||||
groups_by_user = _system_groups_by_user(session, user_ids)
|
||||
group_roles_by_user = _system_group_roles_by_user(session, user_ids)
|
||||
owner_ids_by_tenant = _system_owner_ids_by_tenant(
|
||||
membership_rows,
|
||||
accounts_by_id=accounts_by_id,
|
||||
direct_roles_by_user=roles_by_user,
|
||||
group_roles_by_user=group_roles_by_user,
|
||||
)
|
||||
system_roles_by_account, system_role_counts = _system_roles_for_accounts(
|
||||
session,
|
||||
account_ids,
|
||||
)
|
||||
return [
|
||||
_system_account_response_item(
|
||||
session,
|
||||
account,
|
||||
memberships=memberships_by_account[account.id],
|
||||
roles_by_user=roles_by_user,
|
||||
groups_by_user=groups_by_user,
|
||||
owner_ids_by_tenant=owner_ids_by_tenant,
|
||||
system_roles=system_roles_by_account[account.id],
|
||||
system_role_counts=system_role_counts,
|
||||
)
|
||||
for account in accounts
|
||||
]
|
||||
|
||||
|
||||
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
|
||||
return _system_account_items(session, [account])[0]
|
||||
|
||||
|
||||
def _api_key_item(session: Session, item: ApiKey, *, accounts_by_user_id: dict[str, Account] | None = None) -> ApiKeyAdminItem:
|
||||
if accounts_by_user_id is not None:
|
||||
account = accounts_by_user_id.get(item.user_id)
|
||||
else:
|
||||
user = session.get(User, item.user_id)
|
||||
account = session.get(Account, user.account_id) if user else None
|
||||
return ApiKeyAdminItem(
|
||||
id=item.id,
|
||||
user_id=item.user_id,
|
||||
|
||||
@@ -3,39 +3,11 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
RETENTION_DAY_KEYS = (
|
||||
"raw_campaign_json_retention_days",
|
||||
"generated_eml_retention_days",
|
||||
"stored_report_detail_retention_days",
|
||||
"mock_mailbox_retention_days",
|
||||
"audit_detail_retention_days",
|
||||
)
|
||||
RETENTION_POLICY_FIELD_KEYS = (
|
||||
"store_raw_campaign_json",
|
||||
*RETENTION_DAY_KEYS,
|
||||
"audit_detail_level",
|
||||
)
|
||||
|
||||
|
||||
def default_allow_lower_level_limits() -> dict[str, bool]:
|
||||
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
|
||||
|
||||
|
||||
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
|
||||
if value in (None, ""):
|
||||
return default_allow_lower_level_limits() if fill_defaults else None
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("allow_lower_level_limits must be an object")
|
||||
normalized = default_allow_lower_level_limits() if fill_defaults else {}
|
||||
for key, allowed in value.items():
|
||||
clean_key = str(key)
|
||||
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
|
||||
raise ValueError(f"Unknown retention policy field: {clean_key}")
|
||||
normalized[clean_key] = bool(allowed)
|
||||
return normalized
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
from govoplan_core.i18n import REFERENCE_LANGUAGE_CODE
|
||||
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem
|
||||
|
||||
|
||||
class PermissionItem(BaseModel):
|
||||
@@ -65,12 +37,43 @@ class AdminOverviewResponse(BaseModel):
|
||||
capabilities: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AdminSessionItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
current: bool
|
||||
status: Literal["active", "expired", "revoked"]
|
||||
created_at: datetime
|
||||
last_seen_at: datetime | None = None
|
||||
expires_at: datetime
|
||||
revoked_at: datetime | None = None
|
||||
client: str | None = None
|
||||
|
||||
|
||||
class AdminSessionListResponse(BaseModel):
|
||||
sessions: list[AdminSessionItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AdminSessionRevocationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
current_password: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class AdminSessionRevocationResponse(BaseModel):
|
||||
session: AdminSessionItem
|
||||
revoked: bool
|
||||
|
||||
|
||||
class TenantAdminItem(BaseModel):
|
||||
id: str
|
||||
slug: str = Field(min_length=1, max_length=100)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
default_locale: str = Field(default="en", min_length=1, max_length=20)
|
||||
default_locale: str = Field(
|
||||
default=REFERENCE_LANGUAGE_CODE,
|
||||
min_length=1,
|
||||
max_length=20,
|
||||
)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
allow_custom_groups: bool | None = None
|
||||
allow_custom_roles: bool | None = None
|
||||
@@ -96,6 +99,13 @@ class TenantOwnerCandidateListResponse(BaseModel):
|
||||
accounts: list[TenantOwnerCandidate]
|
||||
|
||||
|
||||
class PagedListResponse(BaseModel):
|
||||
total: int = 0
|
||||
page: int = 1
|
||||
page_size: int = 500
|
||||
pages: int = 1
|
||||
|
||||
|
||||
class TenantCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -103,7 +113,7 @@ class TenantCreateRequest(BaseModel):
|
||||
name: str
|
||||
owner_account_id: str | None = None
|
||||
description: str | None = None
|
||||
default_locale: str = "en"
|
||||
default_locale: str = REFERENCE_LANGUAGE_CODE
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
allow_custom_groups: bool | None = None
|
||||
allow_custom_roles: bool | None = None
|
||||
@@ -127,7 +137,14 @@ class TenantSettingsItem(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
default_locale: str = Field(default="en", min_length=1, max_length=20)
|
||||
default_locale: str = Field(
|
||||
default=REFERENCE_LANGUAGE_CODE,
|
||||
min_length=1,
|
||||
max_length=20,
|
||||
)
|
||||
available_languages: list[dict[str, Any]] = Field(default_factory=list)
|
||||
system_enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -135,6 +152,7 @@ class TenantSettingsUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
default_locale: str = Field(min_length=1, max_length=20)
|
||||
enabled_language_codes: list[str] | None = None
|
||||
|
||||
|
||||
class RoleSummary(BaseModel):
|
||||
@@ -153,6 +171,265 @@ class RoleSummary(BaseModel):
|
||||
system_required: bool = False
|
||||
|
||||
|
||||
class IdentityAccountLinkItem(BaseModel):
|
||||
id: str
|
||||
account_id: str
|
||||
email: str | None = None
|
||||
display_name: str | None = None
|
||||
is_primary: bool = False
|
||||
source: str = "local"
|
||||
|
||||
|
||||
class IdentityAdminItem(BaseModel):
|
||||
id: str
|
||||
display_name: str | None = None
|
||||
external_subject: str | None = None
|
||||
source: str = "local"
|
||||
is_active: bool = True
|
||||
accounts: list[IdentityAccountLinkItem] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class IdentityListResponse(PagedListResponse):
|
||||
identities: list[IdentityAdminItem]
|
||||
|
||||
|
||||
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", max_length=50)
|
||||
account_ids: list[str] = Field(default_factory=list)
|
||||
primary_account_id: str | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
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, max_length=50)
|
||||
account_ids: list[str] | None = None
|
||||
primary_account_id: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class OrganizationUnitItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
parent_id: str | None = None
|
||||
slug: str = Field(min_length=1, max_length=100)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OrganizationUnitListResponse(PagedListResponse):
|
||||
organization_units: list[OrganizationUnitItem]
|
||||
|
||||
|
||||
class OrganizationUnitCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
slug: str
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
parent_id: str | None = None
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class OrganizationUnitUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
slug: str | None = None
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
parent_id: str | None = None
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class FunctionAdminItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
organization_unit_id: str
|
||||
slug: str = Field(min_length=1, max_length=100)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
role_ids: list[str] = Field(default_factory=list)
|
||||
delegable: bool = False
|
||||
act_in_place_allowed: bool = False
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionListResponse(PagedListResponse):
|
||||
functions: list[FunctionAdminItem]
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
source_module: str
|
||||
function_id: str
|
||||
role_id: str
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingListResponse(PagedListResponse):
|
||||
mappings: list[ExternalFunctionRoleMappingItem]
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingListDeltaResponse(ExternalFunctionRoleMappingListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = True
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
function_id: str
|
||||
role_id: str
|
||||
source_module: str = Field(default="organizations", max_length=50)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
role_id: str | None = None
|
||||
settings: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class FunctionCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
organization_unit_id: str
|
||||
slug: str
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
role_ids: list[str] = Field(default_factory=list)
|
||||
delegable: bool = False
|
||||
act_in_place_allowed: bool = False
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class FunctionUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
organization_unit_id: str | None = None
|
||||
slug: str | None = None
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
role_ids: list[str] | None = None
|
||||
delegable: bool | None = None
|
||||
act_in_place_allowed: bool | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class FunctionAssignmentAdminItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
account_id: str
|
||||
identity_id: str | None = None
|
||||
function_id: str
|
||||
organization_unit_id: str
|
||||
applies_to_subunits: bool = False
|
||||
source: str = "direct"
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionAssignmentListResponse(PagedListResponse):
|
||||
assignments: list[FunctionAssignmentAdminItem]
|
||||
|
||||
|
||||
class FunctionAssignmentCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
account_id: str
|
||||
function_id: str
|
||||
organization_unit_id: str | None = None
|
||||
identity_id: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
source: str = Field(default="direct", max_length=50)
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class FunctionAssignmentUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
identity_id: str | None = None
|
||||
organization_unit_id: str | None = None
|
||||
applies_to_subunits: bool | None = None
|
||||
source: str | None = Field(default=None, max_length=50)
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class FunctionDelegationAdminItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
function_assignment_id: str
|
||||
delegator_account_id: str
|
||||
delegate_account_id: str
|
||||
mode: Literal["delegate", "act_in_place"] = "delegate"
|
||||
reason: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionDelegationListResponse(PagedListResponse):
|
||||
delegations: list[FunctionDelegationAdminItem]
|
||||
|
||||
|
||||
class FunctionDelegationCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
function_assignment_id: str
|
||||
delegate_account_id: str
|
||||
mode: Literal["delegate", "act_in_place"] = "delegate"
|
||||
reason: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class FunctionDelegationUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
reason: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool | None = None
|
||||
revoked: bool | None = None
|
||||
|
||||
|
||||
class GroupSummary(BaseModel):
|
||||
id: str
|
||||
slug: str = Field(min_length=1, max_length=100)
|
||||
@@ -180,6 +457,8 @@ class UserAdminItem(BaseModel):
|
||||
last_login_at: datetime | None = None
|
||||
groups: list[GroupSummary] = Field(default_factory=list)
|
||||
roles: list[RoleSummary] = Field(default_factory=list)
|
||||
function_assignment_ids: list[str] = Field(default_factory=list)
|
||||
function_delegation_ids: list[str] = Field(default_factory=list)
|
||||
effective_scopes: list[str] = Field(default_factory=list)
|
||||
is_owner: bool = False
|
||||
is_last_active_owner: bool = False
|
||||
@@ -187,10 +466,107 @@ class UserAdminItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
AccessRoleSourceType = Literal["direct_role", "group_role", "legacy_function_role", "idm_function_role", "system_role"]
|
||||
|
||||
|
||||
class AccessRoleSourceItem(BaseModel):
|
||||
source_type: AccessRoleSourceType
|
||||
role_id: str
|
||||
role_slug: str
|
||||
role_name: str
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
tenant_id: str | None = None
|
||||
group_id: str | None = None
|
||||
group_name: str | None = None
|
||||
function_assignment_id: str | None = None
|
||||
function_id: str | None = None
|
||||
function_name: str | None = None
|
||||
organization_unit_id: str | None = None
|
||||
organization_unit_name: str | None = None
|
||||
identity_id: str | None = None
|
||||
account_id: str | None = None
|
||||
source_module: str | None = None
|
||||
assignment_source: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
delegated_from_assignment_id: str | None = None
|
||||
delegation_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
|
||||
|
||||
class AccessScopeExplanationItem(BaseModel):
|
||||
scope: str
|
||||
sources: list[AccessRoleSourceItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AccessDecisionProvenanceItem(BaseModel):
|
||||
kind: str
|
||||
id: str | None = None
|
||||
label: str | None = None
|
||||
tenant_id: str | None = None
|
||||
source: str | None = None
|
||||
details: dict[str, object] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class FunctionFactExplanationItem(BaseModel):
|
||||
source_module: str
|
||||
assignment_id: str
|
||||
tenant_id: str
|
||||
identity_id: str | None = None
|
||||
account_id: str | None = None
|
||||
function_id: str
|
||||
function_name: str | None = None
|
||||
organization_unit_id: str
|
||||
organization_unit_name: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
assignment_source: str
|
||||
status: str
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
role_ids: list[str] = Field(default_factory=list)
|
||||
role_names: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserAccessExplanationResponse(BaseModel):
|
||||
user: UserAdminItem
|
||||
role_sources: list[AccessRoleSourceItem] = Field(default_factory=list)
|
||||
scopes: list[AccessScopeExplanationItem] = Field(default_factory=list)
|
||||
function_facts: list[FunctionFactExplanationItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ResourceAccessExplanationResponse(BaseModel):
|
||||
user: UserAdminItem
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
action: str
|
||||
provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ResourceAccessExplanationSubjectItem(BaseModel):
|
||||
id: str
|
||||
email: str | None = None
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
class ResourceAccessExplanationSubjectsResponse(BaseModel):
|
||||
mode: Literal["current_user", "cross_user"]
|
||||
can_select_other_users: bool
|
||||
reason: str
|
||||
source: str
|
||||
required_scope: str | None = None
|
||||
users: list[ResourceAccessExplanationSubjectItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserListResponse(PagedListResponse):
|
||||
users: list[UserAdminItem]
|
||||
|
||||
|
||||
class UserListDeltaResponse(UserListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -218,10 +594,17 @@ class UserUpdateRequest(BaseModel):
|
||||
role_ids: list[str] | None = None
|
||||
|
||||
|
||||
class GroupListResponse(BaseModel):
|
||||
class GroupListResponse(PagedListResponse):
|
||||
groups: list[GroupSummary]
|
||||
|
||||
|
||||
class GroupListDeltaResponse(GroupListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class GroupCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -243,10 +626,17 @@ class GroupUpdateRequest(BaseModel):
|
||||
role_ids: list[str] | None = None
|
||||
|
||||
|
||||
class RoleListResponse(BaseModel):
|
||||
class RoleListResponse(PagedListResponse):
|
||||
roles: list[RoleSummary]
|
||||
|
||||
|
||||
class RoleListDeltaResponse(RoleListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class RoleCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -275,11 +665,18 @@ class SystemAccountItem(BaseModel):
|
||||
last_login_at: datetime | None = None
|
||||
|
||||
|
||||
class SystemAccountListResponse(BaseModel):
|
||||
class SystemAccountListResponse(PagedListResponse):
|
||||
accounts: list[SystemAccountItem]
|
||||
roles: list[RoleSummary]
|
||||
|
||||
|
||||
class SystemAccountListDeltaResponse(SystemAccountListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class SystemAccountUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -334,42 +731,6 @@ class PolicySourceStepItem(BaseModel):
|
||||
policy: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PrivacyRetentionPolicyItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
store_raw_campaign_json: bool = True
|
||||
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
|
||||
generated_eml_retention_days: int | None = Field(default=None, ge=0)
|
||||
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
|
||||
audit_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
|
||||
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
|
||||
|
||||
@field_validator("allow_lower_level_limits", mode="before")
|
||||
@classmethod
|
||||
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
|
||||
return normalize_allow_lower_level_limits(value, fill_defaults=True)
|
||||
|
||||
|
||||
class PrivacyRetentionPolicyPatchItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
store_raw_campaign_json: bool | None = None
|
||||
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
|
||||
generated_eml_retention_days: int | None = Field(default=None, ge=0)
|
||||
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
|
||||
audit_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
|
||||
allow_lower_level_limits: dict[str, bool] | None = None
|
||||
|
||||
@field_validator("allow_lower_level_limits", mode="before")
|
||||
@classmethod
|
||||
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
|
||||
return normalize_allow_lower_level_limits(value, fill_defaults=False)
|
||||
|
||||
|
||||
class PrivacyRetentionPolicyScopeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -396,12 +757,126 @@ class RetentionRunResponse(BaseModel):
|
||||
result: dict[str, Any]
|
||||
|
||||
|
||||
class ConfigurationSafetyFieldItem(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
owner_module: str
|
||||
scope: Literal["system", "tenant", "user", "group", "campaign"]
|
||||
storage: str
|
||||
ui_managed: bool
|
||||
risk: Literal["low", "medium", "high", "destructive"]
|
||||
secret_handling: Literal["none", "reference_only", "env_only"] = "none" # noqa: S105 - policy vocabulary.
|
||||
required_scopes: list[str] = Field(default_factory=list)
|
||||
dry_run_required: bool = False
|
||||
validation_required: bool = True
|
||||
policy_explanation_required: bool = False
|
||||
audit_event: str | None = None
|
||||
maintenance_required: bool = False
|
||||
two_person_approval_required: bool = False
|
||||
rollback_history_required: bool = False
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ConfigurationSafetyCatalogResponse(BaseModel):
|
||||
fields: list[ConfigurationSafetyFieldItem]
|
||||
|
||||
|
||||
class ConfigurationChangeSafetyPlanRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str
|
||||
value: Any = None
|
||||
dry_run: bool = False
|
||||
maintenance_mode: bool = False
|
||||
approval_count: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class ConfigurationChangeSafetyPlanResponse(BaseModel):
|
||||
plan: dict[str, Any]
|
||||
|
||||
|
||||
class ConfigurationChangeRequestCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str
|
||||
value: Any = None
|
||||
dry_run: bool = False
|
||||
target: dict[str, Any] = Field(default_factory=dict)
|
||||
reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class ConfigurationChangeRequestApproveRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class ConfigurationChangeRequestResponse(BaseModel):
|
||||
request: dict[str, Any]
|
||||
|
||||
|
||||
class ConfigurationControlSnapshotResponse(BaseModel):
|
||||
requests: list[dict[str, Any]] = Field(default_factory=list)
|
||||
history: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConfigurationControlDeltaResponse(ConfigurationControlSnapshotResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class ConfigurationPackageCatalogResponse(BaseModel):
|
||||
validation: dict[str, Any]
|
||||
|
||||
|
||||
class ConfigurationPackageRunRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
package: dict[str, Any]
|
||||
tenant_id: str | None = None
|
||||
supplied_data: dict[str, Any] = Field(default_factory=dict)
|
||||
change_request_id: str | None = None
|
||||
|
||||
|
||||
class ConfigurationPackageDryRunResponse(BaseModel):
|
||||
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||
required_data: list[dict[str, Any]] = Field(default_factory=list)
|
||||
plan: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConfigurationPackageApplyResponse(BaseModel):
|
||||
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||
created_refs: dict[str, str] = Field(default_factory=dict)
|
||||
updated_refs: dict[str, str] = Field(default_factory=dict)
|
||||
rollback: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ConfigurationPackageExportRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
tenant_id: str | None = None
|
||||
scopes: list[str] = Field(default_factory=list)
|
||||
module_ids: list[str] = Field(default_factory=list)
|
||||
object_refs: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConfigurationPackageExportResponse(BaseModel):
|
||||
fragments: list[dict[str, Any]] = Field(default_factory=list)
|
||||
data_requirements: list[dict[str, Any]] = Field(default_factory=list)
|
||||
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||
provenance: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SystemSettingsItem(BaseModel):
|
||||
default_locale: str = "en"
|
||||
default_locale: str = REFERENCE_LANGUAGE_CODE
|
||||
allow_tenant_custom_groups: bool = True
|
||||
allow_tenant_custom_roles: bool = True
|
||||
allow_tenant_api_keys: bool = True
|
||||
privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem)
|
||||
available_languages: list[dict[str, Any]] = Field(default_factory=list)
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -413,6 +888,8 @@ class SystemSettingsUpdateRequest(BaseModel):
|
||||
allow_tenant_custom_roles: bool
|
||||
allow_tenant_api_keys: bool
|
||||
privacy_retention_policy: PrivacyRetentionPolicyItem | None = None
|
||||
available_languages: list[dict[str, Any]] | None = None
|
||||
enabled_language_codes: list[str] | None = None
|
||||
|
||||
|
||||
class ApiKeyAdminItem(BaseModel):
|
||||
@@ -428,10 +905,17 @@ class ApiKeyAdminItem(BaseModel):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ApiKeyListResponse(BaseModel):
|
||||
class ApiKeyListResponse(PagedListResponse):
|
||||
api_keys: list[ApiKeyAdminItem]
|
||||
|
||||
|
||||
class ApiKeyListDeltaResponse(ApiKeyListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class AdminApiKeyCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -445,6 +929,113 @@ class AdminApiKeyCreateResponse(ApiKeyAdminItem):
|
||||
secret: str
|
||||
|
||||
|
||||
class ServiceAccountItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
scope_ceiling: list[str] = Field(default_factory=list)
|
||||
is_active: bool
|
||||
revision: int
|
||||
created_by_account_id: str | None = None
|
||||
updated_by_account_id: str | None = None
|
||||
retired_at: datetime | None = None
|
||||
credential_count: int = 0
|
||||
active_credential_count: int = 0
|
||||
last_credential_used_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ServiceAccountListResponse(BaseModel):
|
||||
items: list[ServiceAccountItem]
|
||||
|
||||
|
||||
class ServiceAccountCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=4000)
|
||||
scope_ceiling: list[str] = Field(
|
||||
default_factory=list,
|
||||
max_length=200,
|
||||
)
|
||||
|
||||
|
||||
class ServiceAccountUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=4000)
|
||||
scope_ceiling: list[str] | None = Field(
|
||||
default=None,
|
||||
max_length=200,
|
||||
)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class ServiceAccountRetireRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class ServiceAccountCredentialItem(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
prefix: str
|
||||
scopes: list[str] = Field(default_factory=list)
|
||||
expires_at: datetime | None = None
|
||||
last_used_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ServiceAccountCredentialListResponse(BaseModel):
|
||||
service_account_revision: int
|
||||
items: list[ServiceAccountCredentialItem]
|
||||
|
||||
|
||||
class ServiceAccountCredentialCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
scopes: list[str] = Field(min_length=1, max_length=200)
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class ServiceAccountCredentialRotateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
scopes: list[str] | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=200,
|
||||
)
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class ServiceAccountCredentialRevokeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class ServiceAccountCredentialMutationResponse(BaseModel):
|
||||
service_account_revision: int
|
||||
credential: ServiceAccountCredentialItem
|
||||
|
||||
|
||||
class ServiceAccountCredentialSecretResponse(
|
||||
ServiceAccountCredentialMutationResponse
|
||||
):
|
||||
secret: str
|
||||
|
||||
|
||||
class AuditAdminItem(BaseModel):
|
||||
id: str
|
||||
scope: Literal["tenant", "system"] = "tenant"
|
||||
@@ -463,3 +1054,5 @@ class AuditAdminListResponse(BaseModel):
|
||||
page: int = 1
|
||||
page_size: int = 100
|
||||
pages: int = 1
|
||||
cursor: str | None = None
|
||||
next_cursor: str | None = None
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,467 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.admin.governance import assert_api_keys_allowed
|
||||
from govoplan_access.backend.api.v1.admin_common import _resolve_tenant
|
||||
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
ServiceAccountCreateRequest,
|
||||
ServiceAccountCredentialCreateRequest,
|
||||
ServiceAccountCredentialItem,
|
||||
ServiceAccountCredentialListResponse,
|
||||
ServiceAccountCredentialMutationResponse,
|
||||
ServiceAccountCredentialRevokeRequest,
|
||||
ServiceAccountCredentialRotateRequest,
|
||||
ServiceAccountCredentialSecretResponse,
|
||||
ServiceAccountItem,
|
||||
ServiceAccountListResponse,
|
||||
ServiceAccountRetireRequest,
|
||||
ServiceAccountUpdateRequest,
|
||||
)
|
||||
from govoplan_access.backend.service_accounts import (
|
||||
ServiceAccountConflictError,
|
||||
ServiceAccountCredentialNotFoundError,
|
||||
ServiceAccountCredentialSummary,
|
||||
ServiceAccountError,
|
||||
ServiceAccountNotFoundError,
|
||||
create_service_account,
|
||||
create_service_account_credential,
|
||||
get_service_account,
|
||||
list_service_accounts,
|
||||
list_service_account_credentials,
|
||||
revoke_service_account_credential,
|
||||
retire_service_account,
|
||||
rotate_service_account_credential,
|
||||
service_account_credential_summaries,
|
||||
update_service_account,
|
||||
)
|
||||
from govoplan_core.admin.common import AdminConflictError
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||
from govoplan_core.db.session import get_session
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/service-accounts",
|
||||
tags=["admin", "service-accounts"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ServiceAccountListResponse)
|
||||
def list_managed_service_accounts(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:read")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
items = list_service_accounts(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
summaries = service_account_credential_summaries(
|
||||
session,
|
||||
service_accounts=items,
|
||||
)
|
||||
return ServiceAccountListResponse(
|
||||
items=[
|
||||
_service_account_item(item, summaries.get(item.id))
|
||||
for item in items
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{service_account_id}",
|
||||
response_model=ServiceAccountItem,
|
||||
)
|
||||
def get_managed_service_account(
|
||||
service_account_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:read")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
item = get_service_account(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
)
|
||||
except ServiceAccountError as exc:
|
||||
raise _service_account_http_error(exc) from exc
|
||||
summary = service_account_credential_summaries(
|
||||
session,
|
||||
service_accounts=(item,),
|
||||
)[item.id]
|
||||
return _service_account_item(item, summary)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ServiceAccountItem,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_managed_service_account(
|
||||
payload: ServiceAccountCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
item = create_service_account(
|
||||
session,
|
||||
tenant=tenant,
|
||||
principal=principal,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
scope_ceiling=payload.scope_ceiling,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.created",
|
||||
scope="tenant",
|
||||
object_type="service_account",
|
||||
object_id=item.id,
|
||||
details={
|
||||
"name": item.name,
|
||||
"scope_ceiling": list(item.scope_ceiling),
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return _service_account_item(item)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{service_account_id}",
|
||||
response_model=ServiceAccountItem,
|
||||
)
|
||||
def update_managed_service_account(
|
||||
service_account_id: str,
|
||||
payload: ServiceAccountUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
changes = {
|
||||
field: getattr(payload, field)
|
||||
for field in payload.model_fields_set
|
||||
if field != "expected_revision"
|
||||
}
|
||||
for field in ("name", "scope_ceiling", "is_active"):
|
||||
if field in changes and changes[field] is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"{field} cannot be null",
|
||||
)
|
||||
try:
|
||||
item = update_service_account(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
changes=changes,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.updated",
|
||||
scope="tenant",
|
||||
object_type="service_account",
|
||||
object_id=item.id,
|
||||
details={
|
||||
"changed_fields": sorted(changes),
|
||||
"revision": item.revision,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return _service_account_item(item)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{service_account_id}/retire",
|
||||
response_model=ServiceAccountItem,
|
||||
)
|
||||
def retire_managed_service_account(
|
||||
service_account_id: str,
|
||||
payload: ServiceAccountRetireRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
item = retire_service_account(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.retired",
|
||||
scope="tenant",
|
||||
object_type="service_account",
|
||||
object_id=item.id,
|
||||
details={"revision": item.revision},
|
||||
)
|
||||
session.commit()
|
||||
return _service_account_item(item)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{service_account_id}/credentials",
|
||||
response_model=ServiceAccountCredentialListResponse,
|
||||
)
|
||||
def list_managed_service_account_credentials(
|
||||
service_account_id: str,
|
||||
include_revoked: bool = Query(default=True),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:read")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
item, credentials = list_service_account_credentials(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
include_revoked=include_revoked,
|
||||
)
|
||||
except ServiceAccountError as exc:
|
||||
raise _service_account_http_error(exc) from exc
|
||||
return ServiceAccountCredentialListResponse(
|
||||
service_account_revision=item.revision,
|
||||
items=[_credential_item(credential) for credential in credentials],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{service_account_id}/credentials",
|
||||
response_model=ServiceAccountCredentialSecretResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_managed_service_account_credential(
|
||||
service_account_id: str,
|
||||
payload: ServiceAccountCredentialCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
assert_api_keys_allowed(session, tenant)
|
||||
item, created = create_service_account_credential(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
name=payload.name,
|
||||
scopes=payload.scopes,
|
||||
expires_at=payload.expires_at,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError, AdminConflictError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.credential_created",
|
||||
scope="tenant",
|
||||
object_type="service_account_credential",
|
||||
object_id=created.model.id,
|
||||
details={
|
||||
"service_account_id": item.id,
|
||||
"prefix": created.model.prefix,
|
||||
"scopes": list(created.model.scopes),
|
||||
"service_account_revision": item.revision,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return ServiceAccountCredentialSecretResponse(
|
||||
service_account_revision=item.revision,
|
||||
credential=_credential_item(created.model),
|
||||
secret=created.secret,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{service_account_id}/credentials/{credential_id}/rotate",
|
||||
response_model=ServiceAccountCredentialSecretResponse,
|
||||
)
|
||||
def rotate_managed_service_account_credential(
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
payload: ServiceAccountCredentialRotateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
assert_api_keys_allowed(session, tenant)
|
||||
item, previous, created = rotate_service_account_credential(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
credential_id=credential_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
name=payload.name,
|
||||
scopes=payload.scopes,
|
||||
expires_at=payload.expires_at,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError, AdminConflictError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.credential_rotated",
|
||||
scope="tenant",
|
||||
object_type="service_account_credential",
|
||||
object_id=created.model.id,
|
||||
details={
|
||||
"service_account_id": item.id,
|
||||
"previous_credential_id": previous.id,
|
||||
"prefix": created.model.prefix,
|
||||
"scopes": list(created.model.scopes),
|
||||
"service_account_revision": item.revision,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return ServiceAccountCredentialSecretResponse(
|
||||
service_account_revision=item.revision,
|
||||
credential=_credential_item(created.model),
|
||||
secret=created.secret,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{service_account_id}/credentials/{credential_id}/revoke",
|
||||
response_model=ServiceAccountCredentialMutationResponse,
|
||||
)
|
||||
def revoke_managed_service_account_credential(
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
payload: ServiceAccountCredentialRevokeRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
item, credential = revoke_service_account_credential(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
credential_id=credential_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.credential_revoked",
|
||||
scope="tenant",
|
||||
object_type="service_account_credential",
|
||||
object_id=credential.id,
|
||||
details={
|
||||
"service_account_id": item.id,
|
||||
"prefix": credential.prefix,
|
||||
"service_account_revision": item.revision,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return ServiceAccountCredentialMutationResponse(
|
||||
service_account_revision=item.revision,
|
||||
credential=_credential_item(credential),
|
||||
)
|
||||
|
||||
|
||||
def _service_account_item(
|
||||
item: object,
|
||||
summary: ServiceAccountCredentialSummary | None = None,
|
||||
) -> ServiceAccountItem:
|
||||
values = ServiceAccountItem.model_validate(
|
||||
item, from_attributes=True
|
||||
)
|
||||
if summary is None:
|
||||
return values
|
||||
return values.model_copy(
|
||||
update={
|
||||
"credential_count": summary.credential_count,
|
||||
"active_credential_count": summary.active_credential_count,
|
||||
"last_credential_used_at": summary.last_credential_used_at,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _credential_item(item: object) -> ServiceAccountCredentialItem:
|
||||
return ServiceAccountCredentialItem.model_validate(
|
||||
item,
|
||||
from_attributes=True,
|
||||
)
|
||||
|
||||
|
||||
def _service_account_http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, ServiceAccountNotFoundError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
if isinstance(exc, ServiceAccountCredentialNotFoundError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
if isinstance(exc, ServiceAccountConflictError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
)
|
||||
if isinstance(exc, PermissionError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(exc),
|
||||
)
|
||||
if isinstance(exc, AdminConflictError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
)
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from threading import Lock
|
||||
from time import monotonic
|
||||
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.principal_cache import AuthPrincipalRevision
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CachedPrincipal:
|
||||
principal: PrincipalRef
|
||||
revision: AuthPrincipalRevision
|
||||
stored_at: float
|
||||
|
||||
|
||||
class PrincipalSummaryCache:
|
||||
"""A bounded process-local cache containing no ORM or secret objects."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._entries: OrderedDict[str, CachedPrincipal] = OrderedDict()
|
||||
self._lock = Lock()
|
||||
|
||||
def get(
|
||||
self,
|
||||
token_digest: str,
|
||||
*,
|
||||
session_ttl_seconds: int,
|
||||
api_key_ttl_seconds: int,
|
||||
) -> CachedPrincipal | None:
|
||||
with self._lock:
|
||||
entry = self._entries.get(token_digest)
|
||||
if entry is None:
|
||||
return None
|
||||
ttl = (
|
||||
api_key_ttl_seconds
|
||||
if entry.principal.auth_method == "api_key"
|
||||
else session_ttl_seconds
|
||||
)
|
||||
if ttl <= 0 or monotonic() - entry.stored_at > ttl:
|
||||
self._entries.pop(token_digest, None)
|
||||
return None
|
||||
self._entries.move_to_end(token_digest)
|
||||
return entry
|
||||
|
||||
def put(
|
||||
self,
|
||||
token_digest: str,
|
||||
*,
|
||||
principal: PrincipalRef,
|
||||
revision: AuthPrincipalRevision,
|
||||
max_entries: int,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._entries[token_digest] = CachedPrincipal(
|
||||
principal=principal,
|
||||
revision=revision,
|
||||
stored_at=monotonic(),
|
||||
)
|
||||
self._entries.move_to_end(token_digest)
|
||||
while len(self._entries) > max(1, max_entries):
|
||||
self._entries.popitem(last=False)
|
||||
|
||||
def discard(self, token_digest: str) -> None:
|
||||
with self._lock:
|
||||
self._entries.pop(token_digest, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._entries.clear()
|
||||
|
||||
|
||||
principal_summary_cache = PrincipalSummaryCache()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CachedPrincipal",
|
||||
"PrincipalSummaryCache",
|
||||
"principal_summary_cache",
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import TenantContextSwitchRef
|
||||
|
||||
from govoplan_access.backend.db.models import AuthSession
|
||||
from govoplan_access.backend.security.sessions import switch_auth_session_tenant
|
||||
|
||||
|
||||
class AccessTenantContextSwitcher:
|
||||
def switch_tenant_context(self, session: object, *, principal: object, tenant_id: str) -> TenantContextSwitchRef:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("AccessTenantContextSwitcher requires a SQLAlchemy session")
|
||||
if not isinstance(principal, ApiPrincipal):
|
||||
raise TypeError("AccessTenantContextSwitcher requires an API principal")
|
||||
if not isinstance(principal.auth_session, AuthSession):
|
||||
raise ValueError("API keys cannot switch tenant context")
|
||||
membership = switch_auth_session_tenant(session, principal.auth_session, tenant_id)
|
||||
return TenantContextSwitchRef(
|
||||
account_id=principal.account_id,
|
||||
membership_id=membership.id,
|
||||
tenant_id=membership.tenant_id,
|
||||
session_id=principal.session_id,
|
||||
)
|
||||
@@ -0,0 +1,376 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.admin.service import slugify
|
||||
from govoplan_access.backend.db.models import Group, GroupRoleAssignment, Role
|
||||
from govoplan_core.core.configuration_packages import (
|
||||
ConfigurationApplyResult,
|
||||
ConfigurationDiagnostic,
|
||||
ConfigurationExportResult,
|
||||
ConfigurationExportSelection,
|
||||
ConfigurationPackageFragment,
|
||||
ConfigurationPlanItem,
|
||||
ConfigurationPreflightContext,
|
||||
ConfigurationPreflightResult,
|
||||
ConfigurationProvider,
|
||||
ConfigurationProviderDescription,
|
||||
)
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
|
||||
ACCESS_CONFIGURATION_CAPABILITY = "access.configuration"
|
||||
|
||||
|
||||
class SqlAccessConfigurationProvider(ConfigurationProvider):
|
||||
module_id = "access"
|
||||
|
||||
def describe(self) -> ConfigurationProviderDescription:
|
||||
return ConfigurationProviderDescription(
|
||||
module_id=self.module_id,
|
||||
fragment_types=("roles", "groups", "group_role_assignments"),
|
||||
schema_refs={
|
||||
"roles": "govoplan/access/configuration/roles.v1",
|
||||
"groups": "govoplan/access/configuration/groups.v1",
|
||||
"group_role_assignments": "govoplan/access/configuration/group-role-assignments.v1",
|
||||
},
|
||||
exported_scopes=("system", "tenant"),
|
||||
)
|
||||
|
||||
def preflight(self, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
|
||||
with get_database().session() as session:
|
||||
return _preflight_fragment(session, fragment, context)
|
||||
|
||||
def apply(self, fragment: ConfigurationPackageFragment, supplied_data: Mapping[str, Any], context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
|
||||
del supplied_data
|
||||
with get_database().session() as session:
|
||||
result = _apply_fragment(session, fragment, context)
|
||||
if not any(item.severity == "blocker" for item in result.diagnostics):
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
def export(self, selection: ConfigurationExportSelection, context: ConfigurationPreflightContext) -> ConfigurationExportResult:
|
||||
del context
|
||||
with get_database().session() as session:
|
||||
return _export_access_configuration(session, selection)
|
||||
|
||||
def health(self, import_result: ConfigurationApplyResult, context: ConfigurationPreflightContext) -> tuple[ConfigurationDiagnostic, ...]:
|
||||
del context
|
||||
if import_result.diagnostics:
|
||||
return tuple(item for item in import_result.diagnostics if item.severity == "blocker")
|
||||
return ()
|
||||
|
||||
|
||||
def _preflight_fragment(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
|
||||
if fragment.fragment_type == "roles":
|
||||
return _preflight_roles(session, fragment, context)
|
||||
if fragment.fragment_type == "groups":
|
||||
return _preflight_groups(session, fragment, context)
|
||||
if fragment.fragment_type == "group_role_assignments":
|
||||
return _preflight_group_role_assignments(session, fragment, context)
|
||||
return ConfigurationPreflightResult(diagnostics=(_unsupported(fragment),))
|
||||
|
||||
|
||||
def _apply_fragment(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
|
||||
if fragment.fragment_type == "roles":
|
||||
return _apply_roles(session, fragment, context)
|
||||
if fragment.fragment_type == "groups":
|
||||
return _apply_groups(session, fragment, context)
|
||||
if fragment.fragment_type == "group_role_assignments":
|
||||
return _apply_group_role_assignments(session, fragment, context)
|
||||
return ConfigurationApplyResult(diagnostics=(_unsupported(fragment),))
|
||||
|
||||
|
||||
def _preflight_roles(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
plan: list[ConfigurationPlanItem] = []
|
||||
for item in _payload_items(fragment):
|
||||
slug = slugify(_required(item, "slug"))
|
||||
level = str(item.get("level") or "tenant").strip().casefold()
|
||||
tenant_id = _tenant_id(context, item, level=level)
|
||||
if level == "tenant" and tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, slug))
|
||||
plan.append(_plan("blocked", fragment, slug, "Tenant role needs a tenant_id."))
|
||||
continue
|
||||
existing = _role_by_slug(session, slug, tenant_id)
|
||||
plan.append(_plan("update" if existing else "create", fragment, slug, f"{'Update' if existing else 'Create'} role {slug}."))
|
||||
return ConfigurationPreflightResult(diagnostics=tuple(diagnostics), plan=tuple(plan))
|
||||
|
||||
|
||||
def _apply_roles(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
created: dict[str, str] = {}
|
||||
updated: dict[str, str] = {}
|
||||
for item in _payload_items(fragment):
|
||||
slug = slugify(_required(item, "slug"))
|
||||
level = str(item.get("level") or "tenant").strip().casefold()
|
||||
tenant_id = _tenant_id(context, item, level=level)
|
||||
if level == "tenant" and tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, slug))
|
||||
continue
|
||||
role = _role_by_slug(session, slug, tenant_id)
|
||||
target = updated if role else created
|
||||
if role is None:
|
||||
role = Role(tenant_id=tenant_id, slug=slug, name=_required(item, "name"), permissions=[])
|
||||
session.add(role)
|
||||
session.flush()
|
||||
role.name = str(item.get("name") or role.name).strip()
|
||||
role.description = _optional(item, "description")
|
||||
role.permissions = _string_list(item.get("permissions"))
|
||||
role.is_assignable = _bool(item.get("is_assignable"), default=True)
|
||||
role.system_required = _bool(item.get("required"), default=role.system_required)
|
||||
target[slug] = f"role:{role.id}"
|
||||
return ConfigurationApplyResult(diagnostics=tuple(diagnostics), created_refs=created, updated_refs=updated)
|
||||
|
||||
|
||||
def _preflight_groups(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
plan: list[ConfigurationPlanItem] = []
|
||||
for item in _payload_items(fragment):
|
||||
slug = slugify(_required(item, "slug"))
|
||||
tenant_id = _tenant_id(context, item, level="tenant")
|
||||
if tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, slug))
|
||||
plan.append(_plan("blocked", fragment, slug, "Group needs a tenant_id."))
|
||||
continue
|
||||
existing = _group_by_slug(session, slug, tenant_id)
|
||||
plan.append(_plan("update" if existing else "create", fragment, slug, f"{'Update' if existing else 'Create'} group {slug}."))
|
||||
return ConfigurationPreflightResult(diagnostics=tuple(diagnostics), plan=tuple(plan))
|
||||
|
||||
|
||||
def _apply_groups(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
created: dict[str, str] = {}
|
||||
updated: dict[str, str] = {}
|
||||
for item in _payload_items(fragment):
|
||||
slug = slugify(_required(item, "slug"))
|
||||
tenant_id = _tenant_id(context, item, level="tenant")
|
||||
if tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, slug))
|
||||
continue
|
||||
group = _group_by_slug(session, slug, tenant_id)
|
||||
target = updated if group else created
|
||||
if group is None:
|
||||
group = Group(tenant_id=tenant_id, slug=slug, name=_required(item, "name"))
|
||||
session.add(group)
|
||||
session.flush()
|
||||
group.name = str(item.get("name") or group.name).strip()
|
||||
group.description = _optional(item, "description")
|
||||
group.is_active = _bool(item.get("is_active"), default=True)
|
||||
group.system_required = _bool(item.get("required"), default=group.system_required)
|
||||
target[slug] = f"group:{group.id}"
|
||||
return ConfigurationApplyResult(diagnostics=tuple(diagnostics), created_refs=created, updated_refs=updated)
|
||||
|
||||
|
||||
def _preflight_group_role_assignments(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
plan: list[ConfigurationPlanItem] = []
|
||||
for item in _payload_items(fragment):
|
||||
tenant_id = _tenant_id(context, item, level="tenant")
|
||||
group_slug = slugify(_required(item, "group"))
|
||||
role_slug = slugify(_required(item, "role"))
|
||||
if tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, f"{group_slug}:{role_slug}"))
|
||||
plan.append(_plan("blocked", fragment, f"{group_slug}:{role_slug}", "Group-role assignment needs a tenant_id."))
|
||||
continue
|
||||
group = _group_by_slug(session, group_slug, tenant_id)
|
||||
role = _role_by_slug(session, role_slug, tenant_id)
|
||||
if group is None or role is None:
|
||||
missing = ", ".join(ref for ref, exists in ((f"group:{group_slug}", group is not None), (f"role:{role_slug}", role is not None)) if not exists)
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="info",
|
||||
code="access_reference_pending",
|
||||
message=f"Access assignment references are not present yet and may be created by earlier package fragments: {missing}.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=f"{group_slug}:{role_slug}",
|
||||
resolution="Keep role and group fragments before assignment fragments in the package.",
|
||||
))
|
||||
plan.append(_plan("bind", fragment, f"{group_slug}:{role_slug}", f"Bind {group_slug} to {role_slug} after referenced objects exist."))
|
||||
continue
|
||||
exists = session.query(GroupRoleAssignment).filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.group_id == group.id, GroupRoleAssignment.role_id == role.id).count()
|
||||
plan.append(_plan("skip" if exists else "bind", fragment, f"{group_slug}:{role_slug}", f"{'Keep' if exists else 'Bind'} {group_slug} to {role_slug}."))
|
||||
return ConfigurationPreflightResult(diagnostics=tuple(diagnostics), plan=tuple(plan))
|
||||
|
||||
|
||||
def _apply_group_role_assignments(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
created: dict[str, str] = {}
|
||||
for item in _payload_items(fragment):
|
||||
tenant_id = _tenant_id(context, item, level="tenant")
|
||||
group_slug = slugify(_required(item, "group"))
|
||||
role_slug = slugify(_required(item, "role"))
|
||||
object_ref = f"{group_slug}:{role_slug}"
|
||||
if tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, object_ref))
|
||||
continue
|
||||
group = _group_by_slug(session, group_slug, tenant_id)
|
||||
role = _role_by_slug(session, role_slug, tenant_id)
|
||||
if group is None:
|
||||
diagnostics.append(_missing_ref(fragment, f"group:{group_slug}"))
|
||||
if role is None:
|
||||
diagnostics.append(_missing_ref(fragment, f"role:{role_slug}"))
|
||||
if group is None or role is None:
|
||||
continue
|
||||
assignment = session.query(GroupRoleAssignment).filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.group_id == group.id, GroupRoleAssignment.role_id == role.id).one_or_none()
|
||||
if assignment is None:
|
||||
assignment = GroupRoleAssignment(tenant_id=tenant_id, group_id=group.id, role_id=role.id)
|
||||
session.add(assignment)
|
||||
session.flush()
|
||||
created[object_ref] = f"group_role_assignment:{assignment.id}"
|
||||
return ConfigurationApplyResult(diagnostics=tuple(diagnostics), created_refs=created)
|
||||
|
||||
|
||||
def _export_access_configuration(session: Session, selection: ConfigurationExportSelection) -> ConfigurationExportResult:
|
||||
tenant_id = selection.tenant_id
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
fragments: list[ConfigurationPackageFragment] = []
|
||||
if tenant_id is None and "system" not in selection.scopes:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="tenant_required",
|
||||
message="Access configuration export needs selection.tenant_id unless exporting system scope.",
|
||||
module_id="access",
|
||||
resolution="Choose a tenant before exporting tenant roles and groups.",
|
||||
))
|
||||
return ConfigurationExportResult(diagnostics=tuple(diagnostics))
|
||||
|
||||
if tenant_id is not None:
|
||||
roles = session.query(Role).filter(Role.tenant_id == tenant_id).order_by(Role.slug.asc()).all()
|
||||
groups = session.query(Group).filter(Group.tenant_id == tenant_id).order_by(Group.slug.asc()).all()
|
||||
assignments = (
|
||||
session.query(GroupRoleAssignment, Group, Role)
|
||||
.join(Group, Group.id == GroupRoleAssignment.group_id)
|
||||
.join(Role, Role.id == GroupRoleAssignment.role_id)
|
||||
.filter(GroupRoleAssignment.tenant_id == tenant_id)
|
||||
.order_by(Group.slug.asc(), Role.slug.asc())
|
||||
.all()
|
||||
)
|
||||
fragments.extend((
|
||||
ConfigurationPackageFragment(module_id="access", fragment_type="roles", payload={"items": [_role_payload(role) for role in roles]}),
|
||||
ConfigurationPackageFragment(module_id="access", fragment_type="groups", payload={"items": [_group_payload(group) for group in groups]}),
|
||||
ConfigurationPackageFragment(module_id="access", fragment_type="group_role_assignments", payload={"items": [{"group": group.slug, "role": role.slug} for _assignment, group, role in assignments]}),
|
||||
))
|
||||
if "system" in selection.scopes:
|
||||
system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.slug.asc()).all()
|
||||
fragments.append(ConfigurationPackageFragment(module_id="access", fragment_type="roles", payload={"items": [_role_payload(role, level="system") for role in system_roles]}))
|
||||
return ConfigurationExportResult(fragments=tuple(fragments), diagnostics=tuple(diagnostics))
|
||||
|
||||
|
||||
def _payload_items(fragment: ConfigurationPackageFragment) -> list[Mapping[str, Any]]:
|
||||
raw_items = fragment.payload.get("items")
|
||||
if raw_items is None:
|
||||
raw_items = fragment.payload.get(fragment.fragment_type)
|
||||
if raw_items is None and fragment.payload:
|
||||
raw_items = [fragment.payload]
|
||||
if not isinstance(raw_items, list):
|
||||
raise ValueError(f"Access configuration fragment {fragment.fragment_type!r} requires an items list.")
|
||||
return [item for item in raw_items if isinstance(item, Mapping)]
|
||||
|
||||
|
||||
def _tenant_id(context: ConfigurationPreflightContext, item: Mapping[str, Any], *, level: str) -> str | None:
|
||||
if level == "system":
|
||||
return None
|
||||
value = item.get("tenant_id") or context.tenant_id
|
||||
return str(value).strip() if value is not None and str(value).strip() else None
|
||||
|
||||
|
||||
def _role_by_slug(session: Session, slug: str, tenant_id: str | None) -> Role | None:
|
||||
query = session.query(Role).filter(Role.slug == slug)
|
||||
query = query.filter(Role.tenant_id.is_(None)) if tenant_id is None else query.filter(Role.tenant_id == tenant_id)
|
||||
return query.one_or_none()
|
||||
|
||||
|
||||
def _group_by_slug(session: Session, slug: str, tenant_id: str) -> Group | None:
|
||||
return session.query(Group).filter(Group.tenant_id == tenant_id, Group.slug == slug).one_or_none()
|
||||
|
||||
|
||||
def _role_payload(role: Role, *, level: str = "tenant") -> dict[str, object]:
|
||||
return {
|
||||
"slug": role.slug,
|
||||
"name": role.name,
|
||||
"description": role.description,
|
||||
"permissions": list(role.permissions or []),
|
||||
"level": "system" if role.tenant_id is None else level,
|
||||
"is_assignable": role.is_assignable,
|
||||
"required": role.system_required,
|
||||
}
|
||||
|
||||
|
||||
def _group_payload(group: Group) -> dict[str, object]:
|
||||
return {
|
||||
"slug": group.slug,
|
||||
"name": group.name,
|
||||
"description": group.description,
|
||||
"is_active": group.is_active,
|
||||
"required": group.system_required,
|
||||
}
|
||||
|
||||
|
||||
def _required(item: Mapping[str, Any], key: str) -> str:
|
||||
value = item.get(key)
|
||||
if value is None or not str(value).strip():
|
||||
raise ValueError(f"Access configuration item requires {key!r}.")
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _optional(item: Mapping[str, Any], key: str) -> str | None:
|
||||
value = item.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _string_list(value: object) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("Access configuration permissions must be a list.")
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
|
||||
|
||||
def _bool(value: object, *, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().casefold() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _plan(action: str, fragment: ConfigurationPackageFragment, object_ref: str, summary: str) -> ConfigurationPlanItem:
|
||||
return ConfigurationPlanItem(action=action, module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=object_ref, summary=summary) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _tenant_required(fragment: ConfigurationPackageFragment, object_ref: str) -> ConfigurationDiagnostic:
|
||||
return ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="tenant_required",
|
||||
message="Access tenant configuration requires a tenant_id.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=object_ref,
|
||||
resolution="Run the package for a selected tenant or set tenant_id on the fragment item.",
|
||||
)
|
||||
|
||||
|
||||
def _missing_ref(fragment: ConfigurationPackageFragment, object_ref: str) -> ConfigurationDiagnostic:
|
||||
return ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="access_reference_missing",
|
||||
message=f"Access configuration references missing object {object_ref!r}.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=object_ref,
|
||||
resolution="Create referenced groups and roles earlier in the package plan.",
|
||||
)
|
||||
|
||||
|
||||
def _unsupported(fragment: ConfigurationPackageFragment) -> ConfigurationDiagnostic:
|
||||
return ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_type_unsupported",
|
||||
message=f"Access configuration does not support fragment type {fragment.fragment_type!r}.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=fragment.fragment_id or fragment.fragment_type,
|
||||
)
|
||||
@@ -4,11 +4,22 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, JSON, text
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase, TimestampMixin
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
@@ -18,7 +29,7 @@ def new_uuid() -> str:
|
||||
class Account(AccessBase, TimestampMixin):
|
||||
"""Global login identity shared by one or more tenant memberships."""
|
||||
|
||||
__tablename__ = "accounts"
|
||||
__tablename__ = "access_accounts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
email: Mapped[str] = mapped_column(String(320), nullable=False)
|
||||
@@ -31,22 +42,70 @@ class Account(AccessBase, TimestampMixin):
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
memberships: Mapped[list[User]] = relationship(back_populates="account")
|
||||
identity_links: Mapped[list[IdentityAccountLink]] = relationship(
|
||||
back_populates="account", cascade="all, delete-orphan"
|
||||
)
|
||||
auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="account", cascade="all, delete-orphan")
|
||||
system_role_assignments: Mapped[list[SystemRoleAssignment]] = relationship(
|
||||
back_populates="account", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Identity(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_identities"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||
external_subject: Mapped[str | None] = mapped_column(String(255), index=True)
|
||||
source: Mapped[str] = mapped_column(String(50), default="local", nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
account_links: Mapped[list[IdentityAccountLink]] = relationship(
|
||||
back_populates="identity", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class IdentityAccountLink(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_identity_account_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("identity_id", "account_id", name="uq_identity_account_links_identity_account"),
|
||||
Index(
|
||||
"uq_identity_account_links_primary_account",
|
||||
"account_id",
|
||||
unique=True,
|
||||
sqlite_where=text("is_primary = 1"),
|
||||
postgresql_where=text("is_primary IS TRUE"),
|
||||
),
|
||||
Index(
|
||||
"uq_identity_account_links_primary_identity",
|
||||
"identity_id",
|
||||
unique=True,
|
||||
sqlite_where=text("is_primary = 1"),
|
||||
postgresql_where=text("is_primary IS TRUE"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
identity_id: Mapped[str] = mapped_column(ForeignKey("access_identities.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
source: Mapped[str] = mapped_column(String(50), default="local", nullable=False)
|
||||
|
||||
identity: Mapped[Identity] = relationship(back_populates="account_links")
|
||||
account: Mapped[Account] = relationship(back_populates="identity_links")
|
||||
|
||||
|
||||
class User(AccessBase, TimestampMixin):
|
||||
__tablename__ = "users"
|
||||
__tablename__ = "access_users"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
|
||||
UniqueConstraint("tenant_id", "account_id", name="uq_users_tenant_account"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
email: Mapped[str] = mapped_column(String(320), nullable=False, index=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
@@ -57,18 +116,102 @@ class User(AccessBase, TimestampMixin):
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
tenant: Mapped[Tenant] = relationship(back_populates="users")
|
||||
account: Mapped[Account] = relationship(back_populates="memberships")
|
||||
api_keys: Mapped[list[ApiKey]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ServiceAccount(AccessBase, TimestampMixin):
|
||||
"""Managed non-login principal for current-authority automation."""
|
||||
|
||||
__tablename__ = "access_service_accounts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"normalized_name",
|
||||
name="uq_access_service_accounts_tenant_name",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"account_id",
|
||||
name="uq_access_service_accounts_account",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"membership_id",
|
||||
name="uq_access_service_accounts_membership",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
default=new_uuid,
|
||||
)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
account_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("access_accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
membership_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
normalized_name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
scope_ceiling: Mapped[list[str]] = mapped_column(
|
||||
JSON,
|
||||
default=list,
|
||||
nullable=False,
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
revision: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=1,
|
||||
nullable=False,
|
||||
)
|
||||
created_by_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_accounts.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
updated_by_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_accounts.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
retired_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class Group(AccessBase, TimestampMixin):
|
||||
__tablename__ = "groups"
|
||||
__tablename__ = "access_groups"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_groups_tenant_slug"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
@@ -80,7 +223,7 @@ class Group(AccessBase, TimestampMixin):
|
||||
|
||||
|
||||
class Role(AccessBase, TimestampMixin):
|
||||
__tablename__ = "roles"
|
||||
__tablename__ = "access_roles"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "slug", name="uq_roles_tenant_slug"),
|
||||
Index(
|
||||
@@ -93,7 +236,7 @@ class Role(AccessBase, TimestampMixin):
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
@@ -104,54 +247,168 @@ class Role(AccessBase, TimestampMixin):
|
||||
system_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
|
||||
class OrganizationUnit(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_organization_units"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organization_units_tenant_slug"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
parent_id: Mapped[str | None] = mapped_column(ForeignKey("access_organization_units.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class Function(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_functions"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "organization_unit_id", "slug", name="uq_functions_tenant_ou_slug"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
organization_unit_id: Mapped[str] = mapped_column(ForeignKey("access_organization_units.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
delegable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
act_in_place_allowed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class FunctionRoleAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_function_role_assignments"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "function_id", "role_id", name="uq_function_role_assignments"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
function_id: Mapped[str] = mapped_column(ForeignKey("access_functions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
|
||||
class ExternalFunctionRoleAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_external_function_role_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"source_module",
|
||||
"function_id",
|
||||
"role_id",
|
||||
name="uq_external_function_role_assignments",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
source_module: Mapped[str] = mapped_column(String(50), default="organizations", nullable=False, index=True)
|
||||
function_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class FunctionAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_function_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"account_id",
|
||||
"function_id",
|
||||
"organization_unit_id",
|
||||
name="uq_function_assignments_account_scope",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
identity_id: Mapped[str | None] = mapped_column(ForeignKey("access_identities.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
function_id: Mapped[str] = mapped_column(ForeignKey("access_functions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
organization_unit_id: Mapped[str] = mapped_column(ForeignKey("access_organization_units.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
applies_to_subunits: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
source: Mapped[str] = mapped_column(String(50), default="direct", nullable=False)
|
||||
delegated_from_assignment_id: Mapped[str | None] = mapped_column(ForeignKey("access_function_assignments.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
acting_for_account_id: Mapped[str | None] = mapped_column(ForeignKey("access_accounts.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class FunctionDelegation(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_function_delegations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"function_assignment_id",
|
||||
"delegate_account_id",
|
||||
"mode",
|
||||
name="uq_function_delegations_assignment_delegate_mode",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
function_assignment_id: Mapped[str] = mapped_column(ForeignKey("access_function_assignments.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
delegator_account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
delegate_account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
mode: Mapped[str] = mapped_column(String(30), default="delegate", nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(Text)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class SystemRoleAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "system_role_assignments"
|
||||
__tablename__ = "access_system_role_assignments"
|
||||
__table_args__ = (UniqueConstraint("account_id", "role_id", name="uq_system_role_assignments"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
account: Mapped[Account] = relationship(back_populates="system_role_assignments")
|
||||
role: Mapped[Role] = relationship()
|
||||
|
||||
|
||||
class UserGroupMembership(AccessBase, TimestampMixin):
|
||||
__tablename__ = "user_group_memberships"
|
||||
__tablename__ = "access_user_group_memberships"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "user_id", "group_id", name="uq_user_group_memberships"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("access_groups.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
|
||||
class UserRoleAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "user_role_assignments"
|
||||
__tablename__ = "access_user_role_assignments"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "user_id", "role_id", name="uq_user_role_assignments"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
|
||||
class GroupRoleAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "group_role_assignments"
|
||||
__tablename__ = "access_group_role_assignments"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "group_id", "role_id", name="uq_group_role_assignments"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("access_groups.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
|
||||
class ApiKey(AccessBase, TimestampMixin):
|
||||
__tablename__ = "api_keys"
|
||||
__tablename__ = "access_api_keys"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
prefix: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
|
||||
key_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
@@ -164,12 +421,14 @@ class ApiKey(AccessBase, TimestampMixin):
|
||||
|
||||
|
||||
class AuthSession(AccessBase, TimestampMixin):
|
||||
__tablename__ = "auth_sessions"
|
||||
__tablename__ = "access_auth_sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
acting_assignment_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
acting_for_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
|
||||
csrf_token_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
@@ -186,9 +445,18 @@ __all__ = [
|
||||
"Account",
|
||||
"ApiKey",
|
||||
"AuthSession",
|
||||
"Function",
|
||||
"FunctionAssignment",
|
||||
"FunctionDelegation",
|
||||
"FunctionRoleAssignment",
|
||||
"ExternalFunctionRoleAssignment",
|
||||
"Group",
|
||||
"GroupRoleAssignment",
|
||||
"Identity",
|
||||
"IdentityAccountLink",
|
||||
"OrganizationUnit",
|
||||
"Role",
|
||||
"ServiceAccount",
|
||||
"SystemRoleAssignment",
|
||||
"Tenant",
|
||||
"User",
|
||||
|
||||
@@ -2,9 +2,38 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
from govoplan_core.core.access import AccessDirectory, AccessSubjectRef, AccountRef, GroupRef, UserRef
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_core.core.access import (
|
||||
AccessSemanticDirectory,
|
||||
AccessSubjectRef,
|
||||
AccountRef,
|
||||
FunctionAssignmentRef,
|
||||
FunctionRef,
|
||||
GroupRef,
|
||||
IdentityRef,
|
||||
OrganizationUnitRef,
|
||||
UserRef,
|
||||
)
|
||||
from govoplan_core.core.identity import IdentityDirectory, IdentityRef as DirectoryIdentityRef
|
||||
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef as IdmFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import (
|
||||
ORGANIZATIONS_MODULE_ID,
|
||||
OrganizationDirectory,
|
||||
OrganizationFunctionRef as DirectoryFunctionRef,
|
||||
OrganizationUnitRef as DirectoryOrganizationUnitRef,
|
||||
)
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
Function,
|
||||
FunctionAssignment,
|
||||
FunctionRoleAssignment,
|
||||
Group,
|
||||
Identity,
|
||||
IdentityAccountLink,
|
||||
OrganizationUnit,
|
||||
User,
|
||||
)
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_access.backend.semantic import active_function_assignments_for_account
|
||||
|
||||
|
||||
def _status(active: bool) -> str:
|
||||
@@ -40,7 +69,122 @@ def _group_ref(group: Group) -> GroupRef:
|
||||
)
|
||||
|
||||
|
||||
class SqlAccessDirectory(AccessDirectory):
|
||||
def _identity_ref(identity: Identity, account_links: list[IdentityAccountLink]) -> IdentityRef:
|
||||
primary_account_id = next((link.account_id for link in account_links if link.is_primary), None)
|
||||
return IdentityRef(
|
||||
id=identity.id,
|
||||
display_name=identity.display_name,
|
||||
primary_account_id=primary_account_id,
|
||||
account_ids=tuple(link.account_id for link in account_links),
|
||||
status=_status(identity.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _directory_identity_ref(identity: DirectoryIdentityRef) -> IdentityRef:
|
||||
return IdentityRef(
|
||||
id=identity.id,
|
||||
display_name=identity.display_name,
|
||||
primary_account_id=identity.primary_account_id,
|
||||
account_ids=tuple(identity.account_ids),
|
||||
status=identity.status, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _organization_unit_ref(item: OrganizationUnit) -> OrganizationUnitRef:
|
||||
return OrganizationUnitRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
name=item.name,
|
||||
parent_id=item.parent_id,
|
||||
status=_status(item.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _directory_organization_unit_ref(item: DirectoryOrganizationUnitRef) -> OrganizationUnitRef:
|
||||
return OrganizationUnitRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
name=item.name,
|
||||
parent_id=item.parent_id,
|
||||
status=item.status, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _function_ref(function: Function, role_ids: Iterable[str]) -> FunctionRef:
|
||||
return FunctionRef(
|
||||
id=function.id,
|
||||
tenant_id=function.tenant_id,
|
||||
organization_unit_id=function.organization_unit_id,
|
||||
slug=function.slug,
|
||||
name=function.name,
|
||||
role_ids=tuple(role_ids),
|
||||
delegable=function.delegable,
|
||||
act_in_place_allowed=function.act_in_place_allowed,
|
||||
status=_status(function.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _directory_function_ref(function: DirectoryFunctionRef, role_ids: Iterable[str]) -> FunctionRef:
|
||||
return FunctionRef(
|
||||
id=function.id,
|
||||
tenant_id=function.tenant_id,
|
||||
organization_unit_id=function.organization_unit_id,
|
||||
slug=function.slug,
|
||||
name=function.name,
|
||||
role_ids=tuple(role_ids),
|
||||
delegable=function.delegable,
|
||||
act_in_place_allowed=function.act_in_place_allowed,
|
||||
status=function.status, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _function_assignment_ref(item: FunctionAssignment) -> FunctionAssignmentRef:
|
||||
return FunctionAssignmentRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
account_id=item.account_id,
|
||||
identity_id=item.identity_id,
|
||||
function_id=item.function_id,
|
||||
organization_unit_id=item.organization_unit_id,
|
||||
applies_to_subunits=item.applies_to_subunits,
|
||||
source=item.source, # type: ignore[arg-type]
|
||||
delegated_from_assignment_id=item.delegated_from_assignment_id,
|
||||
acting_for_account_id=item.acting_for_account_id,
|
||||
valid_from=item.valid_from,
|
||||
valid_until=item.valid_until,
|
||||
status=_status(item.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _idm_function_assignment_ref(item: IdmFunctionAssignmentRef, *, account_id: str) -> FunctionAssignmentRef:
|
||||
return FunctionAssignmentRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
account_id=item.account_id or account_id,
|
||||
identity_id=item.identity_id,
|
||||
function_id=item.function_id,
|
||||
organization_unit_id=item.organization_unit_id,
|
||||
applies_to_subunits=item.applies_to_subunits,
|
||||
source=item.source, # type: ignore[arg-type]
|
||||
delegated_from_assignment_id=item.delegated_from_assignment_id,
|
||||
acting_for_account_id=item.acting_for_account_id,
|
||||
valid_from=item.valid_from,
|
||||
valid_until=item.valid_until,
|
||||
status=item.status, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
class SqlAccessDirectory(AccessSemanticDirectory):
|
||||
def __init__(
|
||||
self,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> None:
|
||||
self._idm_directory = idm_directory
|
||||
self._identity_directory = identity_directory
|
||||
self._organization_directory = organization_directory
|
||||
|
||||
def get_account(self, account_id: str) -> AccountRef | None:
|
||||
with get_database().session() as session:
|
||||
account = session.get(Account, account_id)
|
||||
@@ -113,6 +257,9 @@ class SqlAccessDirectory(AccessDirectory):
|
||||
return tuple(_group_ref(group) for group in groups)
|
||||
|
||||
def display_label(self, subject: AccessSubjectRef) -> str | None:
|
||||
if subject.kind == "identity":
|
||||
identity = self.get_identity(subject.id)
|
||||
return identity.display_name if identity else subject.label
|
||||
if subject.kind == "account":
|
||||
account = self.get_account(subject.id)
|
||||
return account.display_name or account.email if account else subject.label
|
||||
@@ -122,4 +269,199 @@ class SqlAccessDirectory(AccessDirectory):
|
||||
if subject.kind == "group":
|
||||
group = self.get_group(subject.id)
|
||||
return group.name if group else subject.label
|
||||
if subject.kind == "organization_unit":
|
||||
item = self.get_organization_unit(subject.id)
|
||||
return item.name if item else subject.label
|
||||
if subject.kind == "function":
|
||||
item = self.get_function(subject.id)
|
||||
return item.name if item else subject.label
|
||||
return subject.label or subject.id
|
||||
|
||||
def get_identity(self, identity_id: str) -> IdentityRef | None:
|
||||
if self._identity_directory is not None:
|
||||
identity = self._identity_directory.get_identity(identity_id)
|
||||
if identity is not None:
|
||||
return _directory_identity_ref(identity)
|
||||
with get_database().session() as session:
|
||||
identity = session.get(Identity, identity_id)
|
||||
if identity is None:
|
||||
return None
|
||||
links = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(IdentityAccountLink.identity_id == identity.id)
|
||||
.order_by(IdentityAccountLink.is_primary.desc(), IdentityAccountLink.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
return _identity_ref(identity, links)
|
||||
|
||||
def accounts_for_identity(self, identity_id: str) -> tuple[AccountRef, ...]:
|
||||
if self._identity_directory is not None:
|
||||
links = tuple(self._identity_directory.accounts_for_identity(identity_id))
|
||||
if links:
|
||||
account_ids = [link.account_id for link in links]
|
||||
with get_database().session() as session:
|
||||
accounts = {
|
||||
account.id: account
|
||||
for account in session.query(Account).filter(Account.id.in_(account_ids)).all()
|
||||
}
|
||||
return tuple(_account_ref(accounts[account_id]) for account_id in account_ids if account_id in accounts)
|
||||
with get_database().session() as session:
|
||||
accounts = (
|
||||
session.query(Account)
|
||||
.join(IdentityAccountLink, IdentityAccountLink.account_id == Account.id)
|
||||
.filter(IdentityAccountLink.identity_id == identity_id)
|
||||
.order_by(IdentityAccountLink.is_primary.desc(), Account.email.asc())
|
||||
.all()
|
||||
)
|
||||
return tuple(_account_ref(account) for account in accounts)
|
||||
|
||||
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
||||
if self._organization_directory is not None:
|
||||
item = self._organization_directory.get_organization_unit(organization_unit_id)
|
||||
if item is not None:
|
||||
return _directory_organization_unit_ref(item)
|
||||
with get_database().session() as session:
|
||||
item = session.get(OrganizationUnit, organization_unit_id)
|
||||
return _organization_unit_ref(item) if item is not None else None
|
||||
|
||||
def organization_units_for_tenant(self, tenant_id: str) -> tuple[OrganizationUnitRef, ...]:
|
||||
if self._organization_directory is not None:
|
||||
items = tuple(self._organization_directory.organization_units_for_tenant(tenant_id))
|
||||
if items:
|
||||
return tuple(_directory_organization_unit_ref(item) for item in items)
|
||||
with get_database().session() as session:
|
||||
items = (
|
||||
session.query(OrganizationUnit)
|
||||
.filter(OrganizationUnit.tenant_id == tenant_id)
|
||||
.order_by(OrganizationUnit.name.asc())
|
||||
.all()
|
||||
)
|
||||
return tuple(_organization_unit_ref(item) for item in items)
|
||||
|
||||
def get_function(self, function_id: str) -> FunctionRef | None:
|
||||
if self._organization_directory is not None:
|
||||
function = self._organization_directory.get_function(function_id)
|
||||
if function is not None:
|
||||
return _directory_function_ref(function, self._external_function_role_ids(function.id, tenant_id=function.tenant_id))
|
||||
with get_database().session() as session:
|
||||
function = session.get(Function, function_id)
|
||||
if function is None:
|
||||
return None
|
||||
role_ids = [
|
||||
row[0]
|
||||
for row in session.query(FunctionRoleAssignment.role_id)
|
||||
.filter(FunctionRoleAssignment.function_id == function.id)
|
||||
.order_by(FunctionRoleAssignment.created_at.asc())
|
||||
.all()
|
||||
]
|
||||
return _function_ref(function, role_ids)
|
||||
|
||||
def functions_for_organization_unit(
|
||||
self,
|
||||
organization_unit_id: str,
|
||||
*,
|
||||
include_subunits: bool = False,
|
||||
) -> tuple[FunctionRef, ...]:
|
||||
if self._organization_directory is not None:
|
||||
functions = tuple(
|
||||
self._organization_directory.functions_for_organization_unit(
|
||||
organization_unit_id,
|
||||
include_subunits=include_subunits,
|
||||
)
|
||||
)
|
||||
if functions:
|
||||
role_ids_by_function = self._external_function_role_ids_by_function(
|
||||
[item.id for item in functions],
|
||||
tenant_id=functions[0].tenant_id,
|
||||
)
|
||||
return tuple(
|
||||
_directory_function_ref(item, role_ids_by_function.get(item.id, ()))
|
||||
for item in functions
|
||||
)
|
||||
with get_database().session() as session:
|
||||
unit_ids = {organization_unit_id}
|
||||
if include_subunits:
|
||||
pending = [organization_unit_id]
|
||||
while pending:
|
||||
parent_id = pending.pop()
|
||||
children = [
|
||||
row[0]
|
||||
for row in session.query(OrganizationUnit.id)
|
||||
.filter(OrganizationUnit.parent_id == parent_id)
|
||||
.all()
|
||||
]
|
||||
for child_id in children:
|
||||
if child_id not in unit_ids:
|
||||
unit_ids.add(child_id)
|
||||
pending.append(child_id)
|
||||
functions = (
|
||||
session.query(Function)
|
||||
.filter(Function.organization_unit_id.in_(unit_ids))
|
||||
.order_by(Function.name.asc())
|
||||
.all()
|
||||
)
|
||||
role_rows = (
|
||||
session.query(FunctionRoleAssignment.function_id, FunctionRoleAssignment.role_id)
|
||||
.filter(FunctionRoleAssignment.function_id.in_([item.id for item in functions]))
|
||||
.all()
|
||||
if functions else []
|
||||
)
|
||||
role_ids_by_function: dict[str, list[str]] = {}
|
||||
for function_id, role_id in role_rows:
|
||||
role_ids_by_function.setdefault(function_id, []).append(role_id)
|
||||
return tuple(_function_ref(item, role_ids_by_function.get(item.id, [])) for item in functions)
|
||||
|
||||
def get_function_assignment(self, assignment_id: str) -> FunctionAssignmentRef | None:
|
||||
with get_database().session() as session:
|
||||
item = session.get(FunctionAssignment, assignment_id)
|
||||
return _function_assignment_ref(item) if item is not None else None
|
||||
|
||||
def function_assignments_for_account(
|
||||
self,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> tuple[FunctionAssignmentRef, ...]:
|
||||
with get_database().session() as session:
|
||||
items = active_function_assignments_for_account(session, account_id, tenant_id=tenant_id)
|
||||
assignments = [_function_assignment_ref(item) for item in items]
|
||||
if self._idm_directory is not None:
|
||||
assignments.extend(
|
||||
_idm_function_assignment_ref(item, account_id=account_id)
|
||||
for item in self._idm_directory.organization_function_assignments_for_account(
|
||||
account_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
)
|
||||
deduped = {item.id: item for item in assignments}
|
||||
return tuple(deduped.values())
|
||||
|
||||
def _external_function_role_ids(self, function_id: str, *, tenant_id: str) -> tuple[str, ...]:
|
||||
return self._external_function_role_ids_by_function([function_id], tenant_id=tenant_id).get(function_id, ())
|
||||
|
||||
def _external_function_role_ids_by_function(
|
||||
self,
|
||||
function_ids: Iterable[str],
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> dict[str, tuple[str, ...]]:
|
||||
ids = sorted({str(function_id) for function_id in function_ids if function_id})
|
||||
if not ids:
|
||||
return {}
|
||||
from govoplan_access.backend.db.models import ExternalFunctionRoleAssignment
|
||||
|
||||
with get_database().session() as session:
|
||||
rows = (
|
||||
session.query(ExternalFunctionRoleAssignment.function_id, ExternalFunctionRoleAssignment.role_id)
|
||||
.filter(
|
||||
ExternalFunctionRoleAssignment.tenant_id == tenant_id,
|
||||
ExternalFunctionRoleAssignment.source_module == ORGANIZATIONS_MODULE_ID,
|
||||
ExternalFunctionRoleAssignment.function_id.in_(ids),
|
||||
)
|
||||
.order_by(ExternalFunctionRoleAssignment.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
result: dict[str, list[str]] = {}
|
||||
for function_id, role_id in rows:
|
||||
result.setdefault(function_id, []).append(role_id)
|
||||
return {function_id: tuple(role_ids) for function_id, role_ids in result.items()}
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
AuthSession,
|
||||
Function,
|
||||
FunctionAssignment,
|
||||
Group,
|
||||
Identity,
|
||||
IdentityAccountLink,
|
||||
OrganizationUnit,
|
||||
Role,
|
||||
SystemRoleAssignment,
|
||||
User,
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
)
|
||||
|
||||
|
||||
ACCESS_DSAR_CAPABILITY = "privacy.dsar.access"
|
||||
|
||||
|
||||
class AccessDsarProvider:
|
||||
provider_id = "access"
|
||||
module_id = "access"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
users = _subject_users(db, tenant_id=tenant_id, subject=subject)
|
||||
records: list[DsarRecordRef] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
def append(record: DsarRecordRef) -> None:
|
||||
key = (record.resource_type, record.resource_id)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
records.append(record)
|
||||
|
||||
for user in users:
|
||||
append(
|
||||
_record(
|
||||
"membership",
|
||||
user.id,
|
||||
"profile",
|
||||
user.display_name or user.email,
|
||||
{
|
||||
"account_id": user.account_id,
|
||||
"email": user.email,
|
||||
"display_name": user.display_name,
|
||||
"is_active": user.is_active,
|
||||
"auth_provider": user.auth_provider,
|
||||
"last_login_at": _iso(user.last_login_at),
|
||||
"created_at": _iso(user.created_at),
|
||||
"updated_at": _iso(user.updated_at),
|
||||
},
|
||||
observed_at=user.updated_at,
|
||||
source_path=f"/admin?section=tenant-users&user={user.id}",
|
||||
)
|
||||
)
|
||||
account = db.get(Account, user.account_id)
|
||||
if account is not None:
|
||||
append(
|
||||
_record(
|
||||
"account",
|
||||
account.id,
|
||||
"global_identity",
|
||||
account.display_name or account.email,
|
||||
{
|
||||
"email": account.email,
|
||||
"display_name": account.display_name,
|
||||
"is_active": account.is_active,
|
||||
"auth_provider": account.auth_provider,
|
||||
"last_login_at": _iso(account.last_login_at),
|
||||
"created_at": _iso(account.created_at),
|
||||
},
|
||||
observed_at=account.updated_at,
|
||||
source_path=f"/admin?section=system-users&account={account.id}",
|
||||
)
|
||||
)
|
||||
_append_identity_records(db, append, account)
|
||||
_append_system_role_records(db, append, account)
|
||||
_append_api_key_records(db, append, user)
|
||||
_append_session_records(db, append, user)
|
||||
_append_group_records(db, append, user)
|
||||
_append_role_records(db, append, user)
|
||||
_append_function_records(db, append, user)
|
||||
return tuple(records)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
del session, subject
|
||||
actions: list[DsarErasureActionRef] = []
|
||||
for record in records:
|
||||
if record.resource_type == "membership":
|
||||
actions.append(
|
||||
_action(
|
||||
f"access:anonymize:membership:{record.resource_id}",
|
||||
"anonymize",
|
||||
record,
|
||||
"Anonymize and deactivate the tenant membership",
|
||||
"Tenant-local profile data can be removed without deleting stable evidence identifiers.",
|
||||
executable=True,
|
||||
irreversible=True,
|
||||
metadata={"tenant_id": tenant_id},
|
||||
)
|
||||
)
|
||||
elif record.resource_type == "api_key" and record.data.get("active"):
|
||||
actions.append(
|
||||
_action(
|
||||
f"access:revoke:api-key:{record.resource_id}",
|
||||
"revoke",
|
||||
record,
|
||||
"Revoke API key",
|
||||
"An active credential associated with the data subject must no longer authenticate.",
|
||||
executable=True,
|
||||
)
|
||||
)
|
||||
elif record.resource_type == "auth_session" and record.data.get("active"):
|
||||
actions.append(
|
||||
_action(
|
||||
f"access:revoke:session:{record.resource_id}",
|
||||
"revoke",
|
||||
record,
|
||||
"Revoke login session",
|
||||
"An active session associated with the data subject must no longer authenticate.",
|
||||
executable=True,
|
||||
)
|
||||
)
|
||||
elif record.resource_type in {"account", "identity"}:
|
||||
actions.append(
|
||||
_action(
|
||||
f"access:review:{record.resource_type}:{record.resource_id}",
|
||||
"manual_review",
|
||||
record,
|
||||
f"Review global {record.resource_type}",
|
||||
"Global identities may serve other tenants or legal obligations and require a system-level decision.",
|
||||
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 subject
|
||||
db = _session(session)
|
||||
now = datetime.now(timezone.utc)
|
||||
results: list[DsarExecutionResultRef] = []
|
||||
for action in actions:
|
||||
if action.action_id.startswith("access:anonymize:membership:"):
|
||||
row = db.get(User, action.resource_id)
|
||||
if row is None or row.tenant_id != tenant_id:
|
||||
results.append(_blocked(action, "Tenant membership is no longer available."))
|
||||
continue
|
||||
replacement = _erased_email(tenant_id, row.id)
|
||||
unchanged = (
|
||||
row.email == replacement
|
||||
and row.display_name == "Erased data subject"
|
||||
and not row.is_active
|
||||
)
|
||||
row.email = replacement
|
||||
row.display_name = "Erased data subject"
|
||||
row.is_active = False
|
||||
row.is_tenant_admin = False
|
||||
row.password_hash = None
|
||||
row.last_login_at = None
|
||||
row.settings = {}
|
||||
row.mail_profile_policy = {}
|
||||
results.append(
|
||||
_result(
|
||||
action,
|
||||
"unchanged" if unchanged else "executed",
|
||||
"Tenant membership was already anonymized."
|
||||
if unchanged
|
||||
else "Tenant membership was anonymized and deactivated.",
|
||||
{"request_id": request_id, "replacement_email": replacement},
|
||||
)
|
||||
)
|
||||
elif action.action_id.startswith("access:revoke:api-key:"):
|
||||
row = db.get(ApiKey, action.resource_id)
|
||||
if row is None or row.tenant_id != tenant_id:
|
||||
results.append(_blocked(action, "API key is no longer available."))
|
||||
continue
|
||||
unchanged = row.revoked_at is not None
|
||||
if row.revoked_at is None:
|
||||
row.revoked_at = now
|
||||
results.append(
|
||||
_result(
|
||||
action,
|
||||
"unchanged" if unchanged else "executed",
|
||||
"API key was already revoked." if unchanged else "API key was revoked.",
|
||||
{"request_id": request_id, "revoked_at": _iso(row.revoked_at)},
|
||||
)
|
||||
)
|
||||
elif action.action_id.startswith("access:revoke:session:"):
|
||||
row = db.get(AuthSession, action.resource_id)
|
||||
if row is None or row.tenant_id != tenant_id:
|
||||
results.append(_blocked(action, "Login session is no longer available."))
|
||||
continue
|
||||
unchanged = row.revoked_at is not None and not row.ip_address and not row.user_agent
|
||||
if row.revoked_at is None:
|
||||
row.revoked_at = now
|
||||
row.ip_address = None
|
||||
row.user_agent = None
|
||||
row.csrf_token_hash = None
|
||||
results.append(
|
||||
_result(
|
||||
action,
|
||||
"unchanged" if unchanged else "executed",
|
||||
"Login session was already revoked and redacted."
|
||||
if unchanged
|
||||
else "Login session was revoked and client metadata was redacted.",
|
||||
{"request_id": request_id, "revoked_at": _iso(row.revoked_at)},
|
||||
)
|
||||
)
|
||||
else:
|
||||
results.append(_blocked(action, "Access does not execute this action kind."))
|
||||
db.flush()
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def _subject_users(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> tuple[User, ...]:
|
||||
candidate_sets: list[set[str]] = []
|
||||
if subject.membership_id:
|
||||
candidate_sets.append({
|
||||
row[0]
|
||||
for row in session.query(User.id).filter(
|
||||
User.tenant_id == tenant_id,
|
||||
User.id == subject.membership_id,
|
||||
)
|
||||
})
|
||||
if subject.account_id:
|
||||
candidate_sets.append({
|
||||
row[0]
|
||||
for row in session.query(User.id).filter(
|
||||
User.tenant_id == tenant_id,
|
||||
User.account_id == subject.account_id,
|
||||
)
|
||||
})
|
||||
if subject.identity_id:
|
||||
account_ids = {
|
||||
row[0]
|
||||
for row in session.query(IdentityAccountLink.account_id).filter(
|
||||
IdentityAccountLink.identity_id == subject.identity_id
|
||||
)
|
||||
}
|
||||
candidate_sets.append(
|
||||
{
|
||||
row[0]
|
||||
for row in session.query(User.id).filter(
|
||||
User.tenant_id == tenant_id,
|
||||
User.account_id.in_(account_ids),
|
||||
)
|
||||
}
|
||||
if account_ids
|
||||
else set()
|
||||
)
|
||||
for key, value in subject.external_references.items():
|
||||
if key in {"access.account", "account_id"}:
|
||||
candidate_sets.append({
|
||||
row[0]
|
||||
for row in session.query(User.id).filter(
|
||||
User.tenant_id == tenant_id,
|
||||
User.account_id == value,
|
||||
)
|
||||
})
|
||||
elif key in {"access.membership", "membership_id"}:
|
||||
candidate_sets.append({
|
||||
row[0]
|
||||
for row in session.query(User.id).filter(
|
||||
User.tenant_id == tenant_id,
|
||||
User.id == value,
|
||||
)
|
||||
})
|
||||
if subject.email:
|
||||
normalized = subject.email.strip().casefold()
|
||||
matching_accounts = {
|
||||
row[0]
|
||||
for row in session.query(Account.id).filter(
|
||||
Account.normalized_email == normalized
|
||||
)
|
||||
}
|
||||
email_matches = {
|
||||
row[0]
|
||||
for row in session.query(User.id).filter(
|
||||
User.tenant_id == tenant_id,
|
||||
func.lower(User.email) == normalized,
|
||||
)
|
||||
}
|
||||
if matching_accounts:
|
||||
email_matches.update(
|
||||
row[0]
|
||||
for row in session.query(User.id).filter(
|
||||
User.tenant_id == tenant_id,
|
||||
User.account_id.in_(matching_accounts),
|
||||
)
|
||||
)
|
||||
candidate_sets.append(email_matches)
|
||||
if not candidate_sets:
|
||||
return ()
|
||||
user_ids = set.intersection(*candidate_sets)
|
||||
if not user_ids:
|
||||
return ()
|
||||
return tuple(
|
||||
session.query(User)
|
||||
.filter(User.tenant_id == tenant_id, User.id.in_(user_ids))
|
||||
.order_by(User.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _append_identity_records(session: Session, append: object, account: Account) -> None:
|
||||
for link, identity in (
|
||||
session.query(IdentityAccountLink, Identity)
|
||||
.join(Identity, Identity.id == IdentityAccountLink.identity_id)
|
||||
.filter(IdentityAccountLink.account_id == account.id)
|
||||
.all()
|
||||
):
|
||||
append( # type: ignore[operator]
|
||||
_record(
|
||||
"identity",
|
||||
identity.id,
|
||||
"global_identity",
|
||||
identity.display_name or identity.id,
|
||||
{
|
||||
"display_name": identity.display_name,
|
||||
"external_subject": identity.external_subject,
|
||||
"source": identity.source,
|
||||
"is_active": identity.is_active,
|
||||
"is_primary_link": link.is_primary,
|
||||
},
|
||||
observed_at=identity.updated_at,
|
||||
source_path=f"/admin?section=system-users&identity={identity.id}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _append_system_role_records(session: Session, append: object, account: Account) -> None:
|
||||
for assignment, role in (
|
||||
session.query(SystemRoleAssignment, Role)
|
||||
.join(Role, Role.id == SystemRoleAssignment.role_id)
|
||||
.filter(SystemRoleAssignment.account_id == account.id)
|
||||
.all()
|
||||
):
|
||||
append( # type: ignore[operator]
|
||||
_record(
|
||||
"system_role_assignment",
|
||||
assignment.id,
|
||||
"governance_evidence",
|
||||
f"System role: {role.name}",
|
||||
{"role_id": role.id, "role_name": role.name},
|
||||
observed_at=assignment.updated_at,
|
||||
immutable=True,
|
||||
retention_reason="System authorization history is institutional evidence.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _append_api_key_records(session: Session, append: object, user: User) -> None:
|
||||
for item in session.query(ApiKey).filter(ApiKey.user_id == user.id).all():
|
||||
append( # type: ignore[operator]
|
||||
_record(
|
||||
"api_key",
|
||||
item.id,
|
||||
"credential",
|
||||
item.name,
|
||||
{
|
||||
"prefix": item.prefix,
|
||||
"scopes": list(item.scopes or ()),
|
||||
"active": item.revoked_at is None,
|
||||
"expires_at": _iso(item.expires_at),
|
||||
"last_used_at": _iso(item.last_used_at),
|
||||
"revoked_at": _iso(item.revoked_at),
|
||||
},
|
||||
observed_at=item.updated_at,
|
||||
source_path="/admin?section=tenant-api-keys",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _append_session_records(session: Session, append: object, user: User) -> None:
|
||||
for item in session.query(AuthSession).filter(AuthSession.user_id == user.id).all():
|
||||
append( # type: ignore[operator]
|
||||
_record(
|
||||
"auth_session",
|
||||
item.id,
|
||||
"authentication",
|
||||
f"Login session {item.id[:8]}",
|
||||
{
|
||||
"active": item.revoked_at is None,
|
||||
"expires_at": _iso(item.expires_at),
|
||||
"last_seen_at": _iso(item.last_seen_at),
|
||||
"revoked_at": _iso(item.revoked_at),
|
||||
},
|
||||
observed_at=item.updated_at,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _append_group_records(session: Session, append: object, user: User) -> None:
|
||||
for assignment, group in (
|
||||
session.query(UserGroupMembership, Group)
|
||||
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||
.filter(UserGroupMembership.user_id == user.id)
|
||||
.all()
|
||||
):
|
||||
append( # type: ignore[operator]
|
||||
_record(
|
||||
"group_membership",
|
||||
assignment.id,
|
||||
"governance_evidence",
|
||||
f"Group: {group.name}",
|
||||
{"group_id": group.id, "group_name": group.name},
|
||||
observed_at=assignment.updated_at,
|
||||
immutable=True,
|
||||
retention_reason="Group assignment history is institutional access evidence.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _append_role_records(session: Session, append: object, user: User) -> None:
|
||||
for assignment, role in (
|
||||
session.query(UserRoleAssignment, Role)
|
||||
.join(Role, Role.id == UserRoleAssignment.role_id)
|
||||
.filter(UserRoleAssignment.user_id == user.id)
|
||||
.all()
|
||||
):
|
||||
append( # type: ignore[operator]
|
||||
_record(
|
||||
"role_assignment",
|
||||
assignment.id,
|
||||
"governance_evidence",
|
||||
f"Role: {role.name}",
|
||||
{"role_id": role.id, "role_name": role.name},
|
||||
observed_at=assignment.updated_at,
|
||||
immutable=True,
|
||||
retention_reason="Role assignment history is institutional access evidence.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _append_function_records(session: Session, append: object, user: User) -> None:
|
||||
rows = (
|
||||
session.query(FunctionAssignment, Function, OrganizationUnit)
|
||||
.join(Function, Function.id == FunctionAssignment.function_id)
|
||||
.join(OrganizationUnit, OrganizationUnit.id == FunctionAssignment.organization_unit_id)
|
||||
.filter(
|
||||
FunctionAssignment.tenant_id == user.tenant_id,
|
||||
FunctionAssignment.account_id == user.account_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for assignment, function, unit in rows:
|
||||
append( # type: ignore[operator]
|
||||
_record(
|
||||
"function_assignment",
|
||||
assignment.id,
|
||||
"governance_evidence",
|
||||
f"{function.name} in {unit.name}",
|
||||
{
|
||||
"function_id": function.id,
|
||||
"function_name": function.name,
|
||||
"organization_unit_id": unit.id,
|
||||
"organization_unit_name": unit.name,
|
||||
"source": assignment.source,
|
||||
"valid_from": _iso(assignment.valid_from),
|
||||
"valid_until": _iso(assignment.valid_until),
|
||||
"is_active": assignment.is_active,
|
||||
},
|
||||
observed_at=assignment.updated_at,
|
||||
immutable=True,
|
||||
retention_reason="Function incumbency is effective-dated institutional evidence.",
|
||||
source_path="/admin?section=tenant-function-role-mappings",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _record(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
category: str,
|
||||
title: str,
|
||||
data: dict[str, object],
|
||||
*,
|
||||
observed_at: datetime | None = None,
|
||||
immutable: bool = False,
|
||||
retention_reason: str | None = None,
|
||||
source_path: str | None = None,
|
||||
) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="access",
|
||||
module_id="access",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
category=category,
|
||||
title=title,
|
||||
data=data,
|
||||
observed_at=observed_at,
|
||||
immutable_evidence=immutable,
|
||||
retention_reason=retention_reason,
|
||||
source_path=source_path,
|
||||
)
|
||||
|
||||
|
||||
def _action(
|
||||
action_id: str,
|
||||
kind: str,
|
||||
record: DsarRecordRef,
|
||||
title: str,
|
||||
rationale: str,
|
||||
*,
|
||||
executable: bool,
|
||||
irreversible: bool = False,
|
||||
metadata: dict[str, object] | None = None,
|
||||
) -> DsarErasureActionRef:
|
||||
return DsarErasureActionRef(
|
||||
action_id=action_id,
|
||||
provider_id="access",
|
||||
module_id="access",
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=title,
|
||||
rationale=rationale,
|
||||
executable=executable,
|
||||
irreversible=irreversible,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
|
||||
def _result(
|
||||
action: DsarErasureActionRef,
|
||||
result_status: str,
|
||||
summary: str,
|
||||
evidence: dict[str, object] | None = None,
|
||||
) -> DsarExecutionResultRef:
|
||||
return DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status=result_status, # type: ignore[arg-type]
|
||||
summary=summary,
|
||||
evidence=evidence or {},
|
||||
)
|
||||
|
||||
|
||||
def _blocked(action: DsarErasureActionRef, summary: str) -> DsarExecutionResultRef:
|
||||
return _result(action, "blocked", summary)
|
||||
|
||||
|
||||
def _erased_email(tenant_id: str, membership_id: str) -> str:
|
||||
digest = hashlib.sha256(f"{tenant_id}\0{membership_id}".encode()).hexdigest()[:24]
|
||||
return f"erased+{digest}@invalid.govoplan"
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Access DSAR provider requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
__all__ = ["ACCESS_DSAR_CAPABILITY", "AccessDsarProvider"]
|
||||
@@ -0,0 +1,650 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ExternalFunctionRoleAssignment,
|
||||
Function,
|
||||
FunctionAssignment,
|
||||
FunctionDelegation,
|
||||
FunctionRoleAssignment,
|
||||
Group,
|
||||
GroupRoleAssignment,
|
||||
Role,
|
||||
SystemRoleAssignment,
|
||||
User,
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_access.backend.semantic import (
|
||||
active_function_assignments_for_account,
|
||||
active_function_delegations_for_account,
|
||||
identity_id_for_account,
|
||||
)
|
||||
from govoplan_core.core.access import AccessDecisionProvenance, AccessExplanationService, PrincipalRef, ResourceAccessExplanationProvider
|
||||
from govoplan_core.core.identity import IdentityDirectory
|
||||
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import ORGANIZATIONS_MODULE_ID, OrganizationDirectory
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_access.backend.permissions.catalog import expand_scopes, scopes_grant
|
||||
|
||||
|
||||
AccessRoleSourceType = Literal[
|
||||
"direct_role",
|
||||
"group_role",
|
||||
"legacy_function_role",
|
||||
"idm_function_role",
|
||||
"system_role",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccessRoleSourceExplanation:
|
||||
source_type: AccessRoleSourceType
|
||||
role_id: str
|
||||
role_slug: str
|
||||
role_name: str
|
||||
permissions: tuple[str, ...]
|
||||
tenant_id: str | None = None
|
||||
group_id: str | None = None
|
||||
group_name: str | None = None
|
||||
function_assignment_id: str | None = None
|
||||
function_id: str | None = None
|
||||
function_name: str | None = None
|
||||
organization_unit_id: str | None = None
|
||||
organization_unit_name: str | None = None
|
||||
identity_id: str | None = None
|
||||
account_id: str | None = None
|
||||
source_module: str | None = None
|
||||
assignment_source: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
delegated_from_assignment_id: str | None = None
|
||||
delegation_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"source_type": self.source_type,
|
||||
"role_id": self.role_id,
|
||||
"role_slug": self.role_slug,
|
||||
"role_name": self.role_name,
|
||||
"permissions": list(self.permissions),
|
||||
"tenant_id": self.tenant_id,
|
||||
"group_id": self.group_id,
|
||||
"group_name": self.group_name,
|
||||
"function_assignment_id": self.function_assignment_id,
|
||||
"function_id": self.function_id,
|
||||
"function_name": self.function_name,
|
||||
"organization_unit_id": self.organization_unit_id,
|
||||
"organization_unit_name": self.organization_unit_name,
|
||||
"identity_id": self.identity_id,
|
||||
"account_id": self.account_id,
|
||||
"source_module": self.source_module,
|
||||
"assignment_source": self.assignment_source,
|
||||
"applies_to_subunits": self.applies_to_subunits,
|
||||
"delegated_from_assignment_id": self.delegated_from_assignment_id,
|
||||
"delegation_id": self.delegation_id,
|
||||
"acting_for_account_id": self.acting_for_account_id,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccessScopeExplanation:
|
||||
scope: str
|
||||
sources: tuple[AccessRoleSourceExplanation, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {"scope": self.scope, "sources": [source.to_dict() for source in self.sources]}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FunctionFactExplanation:
|
||||
source_module: str
|
||||
assignment_id: str
|
||||
tenant_id: str
|
||||
identity_id: str | None
|
||||
account_id: str | None
|
||||
function_id: str
|
||||
function_name: str | None
|
||||
organization_unit_id: str
|
||||
organization_unit_name: str | None
|
||||
applies_to_subunits: bool
|
||||
assignment_source: str
|
||||
status: str
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
role_ids: tuple[str, ...] = ()
|
||||
role_names: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"source_module": self.source_module,
|
||||
"assignment_id": self.assignment_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"identity_id": self.identity_id,
|
||||
"account_id": self.account_id,
|
||||
"function_id": self.function_id,
|
||||
"function_name": self.function_name,
|
||||
"organization_unit_id": self.organization_unit_id,
|
||||
"organization_unit_name": self.organization_unit_name,
|
||||
"applies_to_subunits": self.applies_to_subunits,
|
||||
"assignment_source": self.assignment_source,
|
||||
"status": self.status,
|
||||
"delegated_from_assignment_id": self.delegated_from_assignment_id,
|
||||
"acting_for_account_id": self.acting_for_account_id,
|
||||
"role_ids": list(self.role_ids),
|
||||
"role_names": list(self.role_names),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UserAccessExplanation:
|
||||
role_sources: tuple[AccessRoleSourceExplanation, ...] = ()
|
||||
scopes: tuple[AccessScopeExplanation, ...] = ()
|
||||
function_facts: tuple[FunctionFactExplanation, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"role_sources": [source.to_dict() for source in self.role_sources],
|
||||
"scopes": [scope.to_dict() for scope in self.scopes],
|
||||
"function_facts": [fact.to_dict() for fact in self.function_facts],
|
||||
}
|
||||
|
||||
|
||||
class SqlAccessExplanationService(AccessExplanationService):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
resource_explanation_providers: Iterable[ResourceAccessExplanationProvider] = (),
|
||||
) -> None:
|
||||
self._identity_directory = identity_directory
|
||||
self._idm_directory = idm_directory
|
||||
self._organization_directory = organization_directory
|
||||
self._resource_explanation_providers = tuple(resource_explanation_providers)
|
||||
|
||||
def explain_scope_provenance(
|
||||
self,
|
||||
principal: PrincipalRef,
|
||||
required_scope: str,
|
||||
) -> tuple[AccessDecisionProvenance, ...]:
|
||||
with get_database().session() as session:
|
||||
return tuple(
|
||||
_scope_provenance(
|
||||
session,
|
||||
principal,
|
||||
required_scope,
|
||||
identity_directory=self._identity_directory,
|
||||
idm_directory=self._idm_directory,
|
||||
organization_directory=self._organization_directory,
|
||||
)
|
||||
)
|
||||
|
||||
def explain_resource_provenance(
|
||||
self,
|
||||
principal: PrincipalRef,
|
||||
*,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
action: str,
|
||||
) -> tuple[AccessDecisionProvenance, ...]:
|
||||
with get_database().session() as session:
|
||||
items = _scope_provenance(
|
||||
session,
|
||||
principal,
|
||||
action,
|
||||
identity_directory=self._identity_directory,
|
||||
idm_directory=self._idm_directory,
|
||||
organization_directory=self._organization_directory,
|
||||
)
|
||||
for provider in self._resource_explanation_providers:
|
||||
provider_items = tuple(
|
||||
provider.explain_resource_provenance(
|
||||
session,
|
||||
principal,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
action=action,
|
||||
)
|
||||
)
|
||||
if provider_items:
|
||||
items.extend(provider_items)
|
||||
break
|
||||
return tuple(_dedupe_provenance(items))
|
||||
|
||||
|
||||
def build_user_access_explanation(
|
||||
session: Session,
|
||||
user: User,
|
||||
*,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
include_system: bool = False,
|
||||
) -> UserAccessExplanation:
|
||||
role_sources: list[AccessRoleSourceExplanation] = []
|
||||
role_sources.extend(_direct_role_sources(session, user))
|
||||
role_sources.extend(_group_role_sources(session, user))
|
||||
role_sources.extend(_legacy_function_role_sources(session, user))
|
||||
idm_sources, function_facts = _idm_function_role_sources(
|
||||
session,
|
||||
user,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
role_sources.extend(idm_sources)
|
||||
if include_system:
|
||||
account = session.get(Account, user.account_id)
|
||||
if account is not None:
|
||||
role_sources.extend(_system_role_sources(session, account))
|
||||
return UserAccessExplanation(
|
||||
role_sources=tuple(role_sources),
|
||||
scopes=_scope_explanations(role_sources),
|
||||
function_facts=tuple(function_facts),
|
||||
)
|
||||
|
||||
|
||||
def _scope_provenance(
|
||||
session: Session,
|
||||
principal: PrincipalRef,
|
||||
required_scope: str,
|
||||
*,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> list[AccessDecisionProvenance]:
|
||||
items: list[AccessDecisionProvenance] = []
|
||||
account = session.get(Account, principal.account_id)
|
||||
identity_id = principal.identity_id or identity_id_for_account(
|
||||
session,
|
||||
principal.account_id,
|
||||
identity_directory=identity_directory,
|
||||
)
|
||||
if identity_id:
|
||||
items.append(AccessDecisionProvenance(kind="identity", id=identity_id, source="identity_account_link"))
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="account",
|
||||
id=principal.account_id,
|
||||
label=(account.display_name or account.email) if account else None,
|
||||
source=principal.auth_method,
|
||||
)
|
||||
)
|
||||
user = session.get(User, principal.membership_id) if principal.membership_id else None
|
||||
if user is not None:
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="tenant_membership",
|
||||
id=user.id,
|
||||
label=user.display_name or user.email,
|
||||
tenant_id=user.tenant_id,
|
||||
source="membership",
|
||||
)
|
||||
)
|
||||
explanation = build_user_access_explanation(
|
||||
session,
|
||||
user,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
include_system=False,
|
||||
)
|
||||
_append_source_provenance(
|
||||
items,
|
||||
[source for source in explanation.role_sources if scopes_grant(source.permissions, required_scope)],
|
||||
)
|
||||
if account is not None:
|
||||
for source in _system_role_sources(session, account):
|
||||
if scopes_grant(source.permissions, required_scope):
|
||||
_append_source_provenance(items, [source])
|
||||
items.append(AccessDecisionProvenance(kind="right", id=required_scope, label=required_scope))
|
||||
return _dedupe_provenance(items)
|
||||
|
||||
|
||||
def _direct_role_sources(session: Session, user: User) -> list[AccessRoleSourceExplanation]:
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
|
||||
.filter(UserRoleAssignment.tenant_id == user.tenant_id, UserRoleAssignment.user_id == user.id)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
AccessRoleSourceExplanation(
|
||||
source_type="direct_role",
|
||||
role_id=role.id,
|
||||
role_slug=role.slug,
|
||||
role_name=role.name,
|
||||
permissions=tuple(role.permissions or ()),
|
||||
tenant_id=user.tenant_id,
|
||||
)
|
||||
for role in roles
|
||||
]
|
||||
|
||||
|
||||
def _group_role_sources(session: Session, user: User) -> list[AccessRoleSourceExplanation]:
|
||||
rows = (
|
||||
session.query(Group, Role)
|
||||
.join(UserGroupMembership, UserGroupMembership.group_id == Group.id)
|
||||
.join(GroupRoleAssignment, GroupRoleAssignment.group_id == Group.id)
|
||||
.join(Role, Role.id == GroupRoleAssignment.role_id)
|
||||
.filter(
|
||||
UserGroupMembership.tenant_id == user.tenant_id,
|
||||
UserGroupMembership.user_id == user.id,
|
||||
GroupRoleAssignment.tenant_id == user.tenant_id,
|
||||
Group.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
AccessRoleSourceExplanation(
|
||||
source_type="group_role",
|
||||
role_id=role.id,
|
||||
role_slug=role.slug,
|
||||
role_name=role.name,
|
||||
permissions=tuple(role.permissions or ()),
|
||||
tenant_id=user.tenant_id,
|
||||
group_id=group.id,
|
||||
group_name=group.name,
|
||||
)
|
||||
for group, role in rows
|
||||
]
|
||||
|
||||
|
||||
def _legacy_function_role_sources(session: Session, user: User) -> list[AccessRoleSourceExplanation]:
|
||||
sources: list[AccessRoleSourceExplanation] = []
|
||||
direct_assignments = active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)
|
||||
for assignment in direct_assignments:
|
||||
sources.extend(_legacy_function_assignment_sources(session, assignment, assignment_source="function_assignment"))
|
||||
|
||||
delegations = active_function_delegations_for_account(session, user.account_id, tenant_id=user.tenant_id, modes=("delegate",))
|
||||
for delegation in delegations:
|
||||
assignment = session.get(FunctionAssignment, delegation.function_assignment_id)
|
||||
if assignment is None:
|
||||
continue
|
||||
sources.extend(
|
||||
_legacy_function_assignment_sources(
|
||||
session,
|
||||
assignment,
|
||||
assignment_source="delegated_function",
|
||||
delegation=delegation,
|
||||
)
|
||||
)
|
||||
return sources
|
||||
|
||||
|
||||
def _legacy_function_assignment_sources(
|
||||
session: Session,
|
||||
assignment: FunctionAssignment,
|
||||
*,
|
||||
assignment_source: str,
|
||||
delegation: FunctionDelegation | None = None,
|
||||
) -> list[AccessRoleSourceExplanation]:
|
||||
function = session.get(Function, assignment.function_id)
|
||||
if function is None:
|
||||
return []
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.join(FunctionRoleAssignment, FunctionRoleAssignment.role_id == Role.id)
|
||||
.filter(FunctionRoleAssignment.tenant_id == assignment.tenant_id, FunctionRoleAssignment.function_id == function.id)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
AccessRoleSourceExplanation(
|
||||
source_type="legacy_function_role",
|
||||
role_id=role.id,
|
||||
role_slug=role.slug,
|
||||
role_name=role.name,
|
||||
permissions=tuple(role.permissions or ()),
|
||||
tenant_id=assignment.tenant_id,
|
||||
function_assignment_id=assignment.id,
|
||||
function_id=function.id,
|
||||
function_name=function.name,
|
||||
organization_unit_id=assignment.organization_unit_id,
|
||||
identity_id=assignment.identity_id,
|
||||
account_id=assignment.account_id,
|
||||
assignment_source=assignment_source,
|
||||
applies_to_subunits=assignment.applies_to_subunits,
|
||||
delegated_from_assignment_id=assignment.delegated_from_assignment_id,
|
||||
delegation_id=delegation.id if delegation else None,
|
||||
acting_for_account_id=assignment.acting_for_account_id,
|
||||
)
|
||||
for role in roles
|
||||
]
|
||||
|
||||
|
||||
def _idm_function_role_sources(
|
||||
session: Session,
|
||||
user: User,
|
||||
*,
|
||||
idm_directory: IdmDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> tuple[list[AccessRoleSourceExplanation], list[FunctionFactExplanation]]:
|
||||
if idm_directory is None:
|
||||
return [], []
|
||||
assignments = [
|
||||
assignment
|
||||
for assignment in idm_directory.organization_function_assignments_for_account(user.account_id, tenant_id=user.tenant_id)
|
||||
if assignment.tenant_id == user.tenant_id and assignment.status == "active"
|
||||
]
|
||||
if organization_directory is not None:
|
||||
assignments = [
|
||||
assignment
|
||||
for assignment in assignments
|
||||
if _organization_function_active(organization_directory, assignment)
|
||||
]
|
||||
if not assignments:
|
||||
return [], []
|
||||
function_ids = sorted({assignment.function_id for assignment in assignments})
|
||||
rows = (
|
||||
session.query(ExternalFunctionRoleAssignment, Role)
|
||||
.join(Role, ExternalFunctionRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
ExternalFunctionRoleAssignment.tenant_id == user.tenant_id,
|
||||
ExternalFunctionRoleAssignment.source_module == ORGANIZATIONS_MODULE_ID,
|
||||
ExternalFunctionRoleAssignment.function_id.in_(function_ids),
|
||||
Role.tenant_id == user.tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
mappings_by_function: dict[str, list[tuple[ExternalFunctionRoleAssignment, Role]]] = {}
|
||||
for mapping, role in rows:
|
||||
mappings_by_function.setdefault(mapping.function_id, []).append((mapping, role))
|
||||
|
||||
role_sources: list[AccessRoleSourceExplanation] = []
|
||||
function_facts: list[FunctionFactExplanation] = []
|
||||
for assignment in assignments:
|
||||
function_name, organization_unit_name = _organization_labels(organization_directory, assignment)
|
||||
mappings = mappings_by_function.get(assignment.function_id, [])
|
||||
function_facts.append(
|
||||
FunctionFactExplanation(
|
||||
source_module=ORGANIZATIONS_MODULE_ID,
|
||||
assignment_id=assignment.id,
|
||||
tenant_id=assignment.tenant_id,
|
||||
identity_id=assignment.identity_id,
|
||||
account_id=assignment.account_id,
|
||||
function_id=assignment.function_id,
|
||||
function_name=function_name,
|
||||
organization_unit_id=assignment.organization_unit_id,
|
||||
organization_unit_name=organization_unit_name,
|
||||
applies_to_subunits=assignment.applies_to_subunits,
|
||||
assignment_source=assignment.source,
|
||||
status=assignment.status,
|
||||
delegated_from_assignment_id=assignment.delegated_from_assignment_id,
|
||||
acting_for_account_id=assignment.acting_for_account_id,
|
||||
role_ids=tuple(role.id for _, role in mappings),
|
||||
role_names=tuple(role.name for _, role in mappings),
|
||||
)
|
||||
)
|
||||
for mapping, role in mappings:
|
||||
role_sources.append(
|
||||
AccessRoleSourceExplanation(
|
||||
source_type="idm_function_role",
|
||||
role_id=role.id,
|
||||
role_slug=role.slug,
|
||||
role_name=role.name,
|
||||
permissions=tuple(role.permissions or ()),
|
||||
tenant_id=assignment.tenant_id,
|
||||
function_assignment_id=assignment.id,
|
||||
function_id=assignment.function_id,
|
||||
function_name=function_name,
|
||||
organization_unit_id=assignment.organization_unit_id,
|
||||
organization_unit_name=organization_unit_name,
|
||||
identity_id=assignment.identity_id,
|
||||
account_id=assignment.account_id,
|
||||
source_module=mapping.source_module,
|
||||
assignment_source=assignment.source,
|
||||
applies_to_subunits=assignment.applies_to_subunits,
|
||||
delegated_from_assignment_id=assignment.delegated_from_assignment_id,
|
||||
acting_for_account_id=assignment.acting_for_account_id,
|
||||
)
|
||||
)
|
||||
return role_sources, function_facts
|
||||
|
||||
|
||||
def _organization_labels(
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
assignment: OrganizationFunctionAssignmentRef,
|
||||
) -> tuple[str | None, str | None]:
|
||||
if organization_directory is None:
|
||||
return None, None
|
||||
function = organization_directory.get_function(assignment.function_id)
|
||||
unit = organization_directory.get_organization_unit(assignment.organization_unit_id)
|
||||
return (function.name if function is not None else None, unit.name if unit is not None else None)
|
||||
|
||||
|
||||
def _organization_function_active(
|
||||
organization_directory: OrganizationDirectory,
|
||||
assignment: OrganizationFunctionAssignmentRef,
|
||||
) -> bool:
|
||||
function = organization_directory.get_function(assignment.function_id)
|
||||
return function is not None and function.tenant_id == assignment.tenant_id and function.status == "active"
|
||||
|
||||
|
||||
def _system_role_sources(session: Session, account: Account) -> list[AccessRoleSourceExplanation]:
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
|
||||
.filter(SystemRoleAssignment.account_id == account.id, Role.tenant_id.is_(None))
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
AccessRoleSourceExplanation(
|
||||
source_type="system_role",
|
||||
role_id=role.id,
|
||||
role_slug=role.slug,
|
||||
role_name=role.name,
|
||||
permissions=tuple(role.permissions or ()),
|
||||
)
|
||||
for role in roles
|
||||
]
|
||||
|
||||
|
||||
def _scope_explanations(role_sources: Iterable[AccessRoleSourceExplanation]) -> tuple[AccessScopeExplanation, ...]:
|
||||
sources_by_scope: dict[str, list[AccessRoleSourceExplanation]] = {}
|
||||
for source in role_sources:
|
||||
for scope in expand_scopes(source.permissions):
|
||||
sources_by_scope.setdefault(scope, []).append(source)
|
||||
return tuple(
|
||||
AccessScopeExplanation(scope=scope, sources=tuple(sources))
|
||||
for scope, sources in sorted(sources_by_scope.items(), key=lambda item: item[0])
|
||||
)
|
||||
|
||||
|
||||
def _append_source_provenance(
|
||||
items: list[AccessDecisionProvenance],
|
||||
sources: Iterable[AccessRoleSourceExplanation],
|
||||
) -> None:
|
||||
for source in sources:
|
||||
if source.group_id:
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="group",
|
||||
id=source.group_id,
|
||||
label=source.group_name,
|
||||
tenant_id=source.tenant_id,
|
||||
source="group_membership",
|
||||
)
|
||||
)
|
||||
if source.delegation_id:
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="delegation",
|
||||
id=source.delegation_id,
|
||||
label=source.assignment_source,
|
||||
tenant_id=source.tenant_id,
|
||||
source=source.source_type,
|
||||
details={
|
||||
"function_assignment_id": source.function_assignment_id,
|
||||
"delegated_from_assignment_id": source.delegated_from_assignment_id,
|
||||
"acting_for_account_id": source.acting_for_account_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
if source.function_id:
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="organization_unit",
|
||||
id=source.organization_unit_id,
|
||||
label=source.organization_unit_name,
|
||||
tenant_id=source.tenant_id,
|
||||
source=source.source_type,
|
||||
details={"applies_to_subunits": source.applies_to_subunits},
|
||||
)
|
||||
)
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="function",
|
||||
id=source.function_assignment_id,
|
||||
label=source.function_name,
|
||||
tenant_id=source.tenant_id,
|
||||
source=source.source_type,
|
||||
details={
|
||||
"function_id": source.function_id,
|
||||
"organization_unit_id": source.organization_unit_id,
|
||||
"identity_id": source.identity_id,
|
||||
"account_id": source.account_id,
|
||||
"source_module": source.source_module,
|
||||
"assignment_source": source.assignment_source,
|
||||
},
|
||||
)
|
||||
)
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="role",
|
||||
id=source.role_id,
|
||||
label=source.role_name,
|
||||
tenant_id=source.tenant_id,
|
||||
source=source.source_type,
|
||||
details={
|
||||
"role_slug": source.role_slug,
|
||||
"group_id": source.group_id,
|
||||
"function_assignment_id": source.function_assignment_id,
|
||||
"function_id": source.function_id,
|
||||
"source_module": source.source_module,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _dedupe_provenance(items: Iterable[AccessDecisionProvenance]) -> list[AccessDecisionProvenance]:
|
||||
seen: set[tuple[object, ...]] = set()
|
||||
deduped: list[AccessDecisionProvenance] = []
|
||||
for item in items:
|
||||
key = (
|
||||
item.kind,
|
||||
item.id,
|
||||
item.tenant_id,
|
||||
item.source,
|
||||
tuple(sorted((str(key), str(value)) for key, value in item.details.items())),
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(item)
|
||||
return deduped
|
||||
@@ -0,0 +1,326 @@
|
||||
"""German translations for public structured documentation metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'access.reference.admin-access-fields': {'fields': [{'admin_description': 'Auf dem Konto und den '
|
||||
'Mitglieder-Payloads '
|
||||
'gespeichert. Es muss '
|
||||
'normalisiert und '
|
||||
'eindeutig für das '
|
||||
'entsprechende '
|
||||
'Login-Konto sein.',
|
||||
'api_field': 'email',
|
||||
'api_path': '/api/v1/admin/users',
|
||||
'field_id': 'access.user.email',
|
||||
'label': 'E-Mail',
|
||||
'permission_scope': 'access:membership:create',
|
||||
'provenance': 'Mandantenmitgliedschaft oder '
|
||||
'Kontosuche.',
|
||||
'user_description': 'Die Adresse, die '
|
||||
'verwendet wird, um die '
|
||||
'Person zu '
|
||||
'identifizieren, wenn '
|
||||
'sie sich anmelden.',
|
||||
'validation': 'Muss eine gültige '
|
||||
'E-Mail-Adresse sein.'},
|
||||
{'admin_description': 'Wird, sofern verfügbar, '
|
||||
'in Benutzer- und '
|
||||
'Kontoantworten als '
|
||||
'display_name ausgegeben.',
|
||||
'api_field': 'display_name',
|
||||
'api_path': '/api/v1/admin/users',
|
||||
'field_id': 'access.user.display_name',
|
||||
'label': 'Anzeigename',
|
||||
'permission_scope': 'access:membership:update',
|
||||
'provenance': 'Profil der '
|
||||
'Mandantenmitgliedschaft.',
|
||||
'user_description': 'Der lesbare Name, der '
|
||||
'in Benutzerlisten und '
|
||||
'Bewertungsbildschirmen '
|
||||
'angezeigt wird.',
|
||||
'validation': 'Menschenlesbarer Text; Halten '
|
||||
'Sie ihn für Administratoren '
|
||||
'erkennbar.'},
|
||||
{'admin_description': 'Wird beim Aktualisieren '
|
||||
'einer Benutzer- oder '
|
||||
'Gruppenmitgliedschaft '
|
||||
'als group_ids übertragen.',
|
||||
'api_field': 'group_ids',
|
||||
'api_path': '/api/v1/admin/users/{user_id}',
|
||||
'field_id': 'access.user.groups',
|
||||
'label': 'Gruppen',
|
||||
'permission_scope': 'access:group:manage_members',
|
||||
'provenance': 'Benutzergruppenmitgliedschaftszeilen.',
|
||||
'user_description': 'Gemeinsame Zugriffsbündel, '
|
||||
'die Rollen für viele '
|
||||
'Personen gleichzeitig '
|
||||
'hinzufügen können.',
|
||||
'validation': 'Gruppen müssen zum gleichen '
|
||||
'Mandant gehören.'},
|
||||
{'admin_description': 'Wird bei '
|
||||
'Aktualisierungsanforderungen '
|
||||
'für Benutzer- und '
|
||||
'Gruppenrollen als role_ids '
|
||||
'übertragen.',
|
||||
'api_field': 'role_ids',
|
||||
'api_path': '/api/v1/admin/users/{user_id}',
|
||||
'field_id': 'access.user.roles',
|
||||
'label': 'Rollen',
|
||||
'permission_scope': 'access:role:assign',
|
||||
'provenance': 'Direkte Benutzerrollen plus '
|
||||
'Gruppenrollenvererbung.',
|
||||
'user_description': 'Direktzugangszuschüsse, '
|
||||
'die einer Person '
|
||||
'zugewiesen oder von '
|
||||
'Gruppen geerbt wurden.',
|
||||
'validation': 'Rollen müssen zuordenbar sein '
|
||||
'und dürfen das '
|
||||
'Delegationslimit der '
|
||||
'handelnden Person nicht '
|
||||
'überschreiten.'},
|
||||
{'admin_description': 'Bildet Scopes beim '
|
||||
'Erstellen eines '
|
||||
'API-Schlüssel zu und '
|
||||
'wird mit den aktuellen '
|
||||
'Berechtigungen des '
|
||||
'Besitzers geschnitten.',
|
||||
'api_field': 'scopes',
|
||||
'api_path': '/api/v1/admin/api-keys',
|
||||
'field_id': 'access.api_key.scopes',
|
||||
'label': 'Anwendungsbereiche',
|
||||
'permission_scope': 'access:api_key:create',
|
||||
'provenance': 'API-Schlüssel Grant plus '
|
||||
'Eigentümerdelegation.',
|
||||
'user_description': 'Die Aktionen, die ein '
|
||||
'API-Schlüssel ausführen '
|
||||
'kann.',
|
||||
'validation': 'Verwenden Sie möglichst enge '
|
||||
'Berechtigungsbereiche.'}]},
|
||||
'access.reference.personal-navigation': {'outcome': 'Die Seitenschiene des Benutzers spiegelt die '
|
||||
'persönlichen Präferenzen wider, während '
|
||||
'verschlossene und unzugängliche Einträge '
|
||||
'durch übergeordnete Richtlinien geregelt '
|
||||
'bleiben.'},
|
||||
'access.workflow.configuration-packages': {'limitations': ['Die Paketübernahme installiert keine '
|
||||
'fehlenden Module.',
|
||||
'Die anbieterübergreifende Übernahme ist keine atomar '
|
||||
'verteilte Transaktion.',
|
||||
'Generisches Rollback hängt von einem '
|
||||
'beibehaltenen '
|
||||
'vor der Übernahme erstellten Datenbank-Snapshot ab.'],
|
||||
'operational_consequences': ['Ein abgestandener oder '
|
||||
'blockierter Preflight '
|
||||
'muss vor der Anwendung '
|
||||
'erneut durchgeführt '
|
||||
'werden.',
|
||||
'Eine teilweise Anwendung '
|
||||
'erfordert eine '
|
||||
'Wiederherstellung, bevor '
|
||||
'das Paket erneut '
|
||||
'getestet wird.',
|
||||
'Geheimwerte bleiben '
|
||||
'außerhalb tragbarer '
|
||||
'Fragmente und '
|
||||
'Herkunft.']},
|
||||
'access.workflow.data-subject-request': {'limitations': ['Module ohne DSAR-Anbieter werden als '
|
||||
'Deckungslücken gemeldet.',
|
||||
'Globale Konten und Identitäten werden '
|
||||
'nicht automatisch gelöscht.']},
|
||||
'access.workflow.grant-user-access': {'outcome': 'Eine Person kann sich beim Mandant anmelden und '
|
||||
'erhält den beabsichtigten Zugang durch Gruppen '
|
||||
'und Rollen.',
|
||||
'prerequisites': ['Sie können Admin öffnen.',
|
||||
'Sie können Benutzer, Gruppen und Rollen '
|
||||
'lesen.',
|
||||
'Schreib- oder Zuweisungsaktionen '
|
||||
'erfordern übereinstimmende '
|
||||
'Verwaltungsberechtigungen.'],
|
||||
'result': 'Die Mitgliedschaft hat die beabsichtigten '
|
||||
'effektiven Berechtigungen und keine breiteren '
|
||||
'Rollen als nötig.',
|
||||
'steps': ['Öffnen Sie Admin und gehen Sie zu Benutzern.',
|
||||
'Finden Sie die bestehende Person oder erstellen '
|
||||
'Sie eine Mitgliedschaft mit ihrer E-Mail-Adresse '
|
||||
'und dem Anzeigenamen.',
|
||||
'Überprüfen Sie aktuelle Gruppen und direkte '
|
||||
'Rollen, bevor Sie etwas ändern.',
|
||||
'Fügen Sie die Person der kleinsten Gruppe hinzu, '
|
||||
'die den erforderlichen gemeinsamen Zugriff '
|
||||
'gewährt.',
|
||||
'Weisen Sie direkte Rollen nur zu, wenn eine '
|
||||
'Gruppe nicht mit dem Fall übereinstimmt.',
|
||||
'Speichern und überprüfen Sie eine '
|
||||
'Blockernachricht, bevor Sie einen System- oder '
|
||||
'Mandantbesitzer um Hilfe bitten.'],
|
||||
'verification': 'Öffnen Sie den Benutzer erneut und '
|
||||
'vergleichen Sie Gruppen, direkte Rollen '
|
||||
'und effektive Berechtigungen mit der '
|
||||
'Anforderung.'},
|
||||
'access.workflow.manage-api-keys': {'consequences': ['Der Widerruf lehnt nachfolgende Anfragen, '
|
||||
'die mit dem Schlüssel gestellt wurden, '
|
||||
'sofort ab.',
|
||||
'Durch das Entfernen von Berechtigungen vom '
|
||||
'Besitzer wird der effektive '
|
||||
'Schlüsselzugriff sofort eingeschränkt.'],
|
||||
'limitations': ['Ein einmaliges Geheimnis kann nach dem '
|
||||
'Schließen des Erstellungsdialogs nicht '
|
||||
'angezeigt oder wiederhergestellt werden.',
|
||||
'Ändern des Besitzers, Ablauf oder Scopes '
|
||||
'erfordert einen Ersatzschlüssel.',
|
||||
'Der Widerruf aktualisiert keine externen '
|
||||
'Clients; die Betreiber müssen bei Bedarf '
|
||||
'einen Ersatz installieren.'],
|
||||
'outcome': 'Der Automatisierungsclient verfügt über einen '
|
||||
'zeitlich begrenzten Berechtigungsnachweis, dessen '
|
||||
'effektiver Zugriff weder seine gespeicherten '
|
||||
'Berechtigungsbereiche noch die aktuellen '
|
||||
'Berechtigungen seines Besitzers überschreiten '
|
||||
'kann.',
|
||||
'prerequisites': ['Der Mandant erlaubt '
|
||||
'API-Anmeldeinformationen.',
|
||||
'Die handelnde Person kann API-Schlüssel '
|
||||
'erstellen oder widerrufen und jeden '
|
||||
'ausgewählten Bereich delegieren.',
|
||||
'Ein zugelassener externer Geheimmanager '
|
||||
'und rechenschaftspflichtiger Eigentümer '
|
||||
'sind bekannt.'],
|
||||
'steps': ['Wählen Sie den verantwortlichen Eigentümer und die '
|
||||
'engsten erforderlichen Berechtigungsbereiche.',
|
||||
'Legen Sie den kürzesten praktischen Ablauf fest, '
|
||||
'bevor Sie den Schlüssel erstellen.',
|
||||
'Übertragen Sie das einmalige Geheimnis direkt in '
|
||||
'den genehmigten Geheimmanager.',
|
||||
'Widerrufen Sie den Schlüssel, wenn sein Client, '
|
||||
'Eigentümer oder Zweck nicht mehr gültig ist.'],
|
||||
'verification': 'Laden Sie das Schlüsselverzeichnis neu, '
|
||||
'überprüfen Sie Eigentümer, Präfix, '
|
||||
'Berechtigungsumfang, Ablauf und Status und '
|
||||
'testen Sie dann den beabsichtigten Client, '
|
||||
'ohne geheimes Material in Nachweise zu '
|
||||
'kopieren.'},
|
||||
'access.workflow.manage-reusable-credentials': {'limitations': ['GovOPlaN kann ein konfiguriertes '
|
||||
'Geheimnis nicht anzeigen oder '
|
||||
'wiederherstellen.',
|
||||
'Eine leere Modul- oder '
|
||||
'Serverbeschränkung bedeutet '
|
||||
'jeden Wert, der nach '
|
||||
'Berechtigungsumfang zulässig '
|
||||
'ist.',
|
||||
'Das Löschen oder Leeren eines '
|
||||
'Geheimnisses schreibt keine '
|
||||
'abhängigen Verbindungsreferenzen '
|
||||
'neu.'],
|
||||
'outcome': 'Die Zugangsdaten bleiben '
|
||||
'schreibgeschützt und sind nur '
|
||||
'innerhalb seines aktiven '
|
||||
'Berechtigungsumfangs, Moduls, Servers '
|
||||
'und Autorisierungsgrenzen verwendbar.',
|
||||
'prerequisites': ['Der beabsichtigte '
|
||||
'Berechtigungsinhaber wird '
|
||||
'ausgewählt.',
|
||||
'Die handelnde Person kann '
|
||||
'Anmeldeinformationen lesen und '
|
||||
'hat Schreibautorität für '
|
||||
'Mutationen.',
|
||||
'Der externe '
|
||||
'Secret-Manager-Eigentümer und '
|
||||
'abhängige Verbindungen sind '
|
||||
'bekannt.'],
|
||||
'steps': ['Wählen Sie den engsten Besitzumfang '
|
||||
'und Anmeldetyp.',
|
||||
'Beschränken Sie Module und Server '
|
||||
'explizit, wenn eine breite Nutzung '
|
||||
'nicht beabsichtigt ist.',
|
||||
'Speichern Sie ein neues oder '
|
||||
'Ersatzgeheimnis, ohne zu erwarten, '
|
||||
'dass es erneut angezeigt wird.',
|
||||
'Überprüfen Sie abhängige Verbindungen '
|
||||
'vor der Deaktivierung, geheimen '
|
||||
'Löschung oder Löschung.'],
|
||||
'verification': 'Laden Sie die Liste der '
|
||||
'Zugangsdaten neu, bestätigen Sie '
|
||||
'deren Berechtigungsumfang und '
|
||||
'Verfügbarkeit und testen Sie '
|
||||
'dann jede beabsichtigte '
|
||||
'abhängige Verbindung, ohne das '
|
||||
'Geheimnis zu enthüllen.'},
|
||||
'access.workflow.manage-service-account-credentials': {'consequences': ['Rotation widerruft den '
|
||||
'vorherigen Nachweis in '
|
||||
'der gleichen '
|
||||
'Transaktion, die seinen '
|
||||
'Ersatz schafft.',
|
||||
'Der Widerruf, die '
|
||||
'Deaktivierung des Kontos '
|
||||
'und der Ruhestand lehnen '
|
||||
'betroffene '
|
||||
'Kundenanfragen sofort '
|
||||
'ab.',
|
||||
'Eine veraltete Revision '
|
||||
'wird abgelehnt, so dass '
|
||||
'ein gleichzeitiger '
|
||||
'Verwaltungswechsel nicht '
|
||||
'überschrieben wird.'],
|
||||
'limitations': ['Einmalige '
|
||||
'Anmeldegeheimnisse können '
|
||||
'nach dem Schließen des '
|
||||
'Erstellungsdialogs nicht '
|
||||
'angezeigt oder '
|
||||
'wiederhergestellt werden.',
|
||||
'Deaktivierung und eine '
|
||||
'reduzierte '
|
||||
'Berechtigungsumfangsobergrenze '
|
||||
'betreffen Clients sofort, '
|
||||
'schreiben ihre externe '
|
||||
'Konfiguration jedoch '
|
||||
'nicht neu.',
|
||||
'Der Ruhestand widerruft '
|
||||
'alle aktiven '
|
||||
'Anmeldeinformationen und '
|
||||
'erfordert ein neues '
|
||||
'Servicekonto für die '
|
||||
'spätere '
|
||||
'Wiederverwendung.'],
|
||||
'outcome': 'Der Automatisierungsprinzipal '
|
||||
'bleibt nicht interaktiv und '
|
||||
'kann sich nur durch einen '
|
||||
'aktiven Berechtigungsnachweis '
|
||||
'authentifizieren, dessen '
|
||||
'Gewährung innerhalb der '
|
||||
'aktuellen '
|
||||
'Berechtigungsumfangsobergrenze '
|
||||
'des Kontos liegt.',
|
||||
'prerequisites': ['Der Mandant erlaubt '
|
||||
'API-Anmeldeinformationen.',
|
||||
'Sie haben eine '
|
||||
'Service-Account-Schreibberechtigung '
|
||||
'und können jeden '
|
||||
'ausgewählten Bereich '
|
||||
'delegieren.'],
|
||||
'steps': ['Erstellen Sie ein Servicekonto '
|
||||
'und definieren Sie die engste '
|
||||
'Nutzumfangsobergrenze.',
|
||||
'Öffnen Sie das Konto und '
|
||||
'erstellen Sie einen '
|
||||
'Berechtigungsnachweis mit einem '
|
||||
'gleichen oder engeren '
|
||||
'Berechtigungsumfang.',
|
||||
'Notieren Sie das einmalige '
|
||||
'Geheimnis in einem externen '
|
||||
'Geheimmanager.',
|
||||
'Anmeldeinformationen vor Ablauf '
|
||||
'drehen und Anmeldeinformationen '
|
||||
'widerrufen, die nicht mehr '
|
||||
'verwendet werden.'],
|
||||
'verification': 'Die Verwaltungstabelle '
|
||||
'zeigt die erwartete '
|
||||
'Anzahl der aktiven '
|
||||
'Anmeldeinformationen, den '
|
||||
'Zeitstempel für die '
|
||||
'letzte Verwendung, die '
|
||||
'Revision und die '
|
||||
'Audit-Ereignisse, ohne '
|
||||
'geheimes Material '
|
||||
'preiszugeben.'}}
|
||||
@@ -1,26 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Group, GroupRoleAssignment, Role, UserGroupMembership, UserRoleAssignment
|
||||
from govoplan_access.backend.db.models import (
|
||||
Group,
|
||||
GroupRoleAssignment,
|
||||
Role,
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
new_uuid,
|
||||
)
|
||||
from govoplan_core.admin.common import AdminConflictError
|
||||
from govoplan_core.core.access import AccessGovernanceMaterializer, GovernanceTemplateMaterialization
|
||||
from govoplan_core.core.access import (
|
||||
AccessGovernanceMaterializer,
|
||||
AccessGovernanceProjectionV1,
|
||||
GovernanceProjectionBatch,
|
||||
GovernanceProjectionCommand,
|
||||
GovernanceProjectionOutcome,
|
||||
GovernanceProjectionResult,
|
||||
GovernanceTemplateMaterialization,
|
||||
)
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
|
||||
|
||||
class SqlAccessGovernanceMaterializer(AccessGovernanceMaterializer):
|
||||
def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
||||
class SqlAccessGovernanceMaterializer(
|
||||
AccessGovernanceMaterializer,
|
||||
AccessGovernanceProjectionV1,
|
||||
):
|
||||
"""Reconcile governance projections with a constant number of bulk reads."""
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
session: object,
|
||||
batch: GovernanceProjectionBatch,
|
||||
) -> GovernanceProjectionResult:
|
||||
db = _session(session)
|
||||
if template.kind == "group":
|
||||
group = (
|
||||
db.query(Group)
|
||||
.filter(Group.tenant_id == template.tenant_id, Group.system_template_id == template.template_id)
|
||||
.first()
|
||||
commands = tuple(batch.commands)
|
||||
group_commands = tuple(item for item in commands if item.template.kind == "group")
|
||||
role_commands = tuple(item for item in commands if item.template.kind == "role")
|
||||
|
||||
groups, duplicate_group_keys = _managed_groups(db, group_commands)
|
||||
roles, duplicate_role_keys = _managed_roles(db, role_commands)
|
||||
used_group_slugs = _used_slugs(db, Group, group_commands)
|
||||
used_role_slugs = _used_slugs(db, Role, role_commands)
|
||||
|
||||
group_ids = {item.id for item in groups.values()}
|
||||
role_ids = {item.id for item in roles.values()}
|
||||
group_memberships = _assignment_counts(db, UserGroupMembership, UserGroupMembership.group_id, group_ids)
|
||||
group_role_links = _assignment_counts(db, GroupRoleAssignment, GroupRoleAssignment.group_id, group_ids)
|
||||
role_user_links = _assignment_counts(db, UserRoleAssignment, UserRoleAssignment.role_id, role_ids)
|
||||
role_group_links = _assignment_counts(db, GroupRoleAssignment, GroupRoleAssignment.role_id, role_ids)
|
||||
|
||||
outcomes: list[GovernanceProjectionOutcome] = []
|
||||
for command in commands:
|
||||
key = (command.template.tenant_id, command.template.template_id)
|
||||
if command.template.kind == "group":
|
||||
outcome = self._reconcile_group(
|
||||
db,
|
||||
command,
|
||||
groups,
|
||||
duplicate_group_keys,
|
||||
used_group_slugs,
|
||||
group_memberships,
|
||||
group_role_links,
|
||||
dry_run=batch.dry_run,
|
||||
)
|
||||
else:
|
||||
outcome = self._reconcile_role(
|
||||
db,
|
||||
command,
|
||||
roles,
|
||||
duplicate_role_keys,
|
||||
used_role_slugs,
|
||||
role_user_links,
|
||||
role_group_links,
|
||||
dry_run=batch.dry_run,
|
||||
)
|
||||
outcomes.append(outcome)
|
||||
if outcome.status in {"removed", "absent"}:
|
||||
groups.pop(key, None)
|
||||
roles.pop(key, None)
|
||||
|
||||
if not batch.dry_run:
|
||||
db.flush()
|
||||
return GovernanceProjectionResult(
|
||||
operation_id=batch.operation_id,
|
||||
outcomes=tuple(outcomes),
|
||||
dry_run=batch.dry_run,
|
||||
)
|
||||
|
||||
def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
||||
self._legacy_reconcile(session, template, operation="upsert")
|
||||
|
||||
def remove_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
||||
self._legacy_reconcile(session, template, operation="remove")
|
||||
|
||||
def _legacy_reconcile(
|
||||
self,
|
||||
session: object,
|
||||
template: GovernanceTemplateMaterialization,
|
||||
*,
|
||||
operation: str,
|
||||
) -> None:
|
||||
command = GovernanceProjectionCommand(
|
||||
assignment_id=f"legacy:{template.kind}:{template.template_id}:{template.tenant_id}",
|
||||
operation=operation, # type: ignore[arg-type]
|
||||
template=template,
|
||||
provenance={"contract": "access.governanceMaterializer"},
|
||||
)
|
||||
result = self.reconcile(
|
||||
session,
|
||||
GovernanceProjectionBatch(
|
||||
operation_id=command.assignment_id,
|
||||
commands=(command,),
|
||||
),
|
||||
)
|
||||
if result.blocked:
|
||||
raise AdminConflictError(result.blocked[0].message or "Governance projection was blocked.")
|
||||
|
||||
def _reconcile_group(
|
||||
self,
|
||||
db: Session,
|
||||
command: GovernanceProjectionCommand,
|
||||
existing: dict[tuple[str, str], Group],
|
||||
duplicate_keys: set[tuple[str, str]],
|
||||
used_slugs: dict[str, set[str]],
|
||||
membership_counts: dict[str, int],
|
||||
role_counts: dict[str, int],
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> GovernanceProjectionOutcome:
|
||||
template = command.template
|
||||
key = (template.tenant_id, template.template_id)
|
||||
group = existing.get(key)
|
||||
if key in duplicate_keys:
|
||||
return _outcome(
|
||||
command,
|
||||
status="failed",
|
||||
blocker_codes=("duplicate_managed_projection",),
|
||||
message="Multiple managed groups exist for this template and tenant.",
|
||||
)
|
||||
if command.operation == "remove":
|
||||
if group is None:
|
||||
return _outcome(command, status="absent")
|
||||
blockers: list[str] = []
|
||||
if membership_counts.get(group.id, 0):
|
||||
blockers.append("group_has_members")
|
||||
if role_counts.get(group.id, 0):
|
||||
blockers.append("group_has_roles")
|
||||
if blockers:
|
||||
return _outcome(
|
||||
command,
|
||||
status="blocked",
|
||||
resource_id=group.id,
|
||||
blocker_codes=tuple(blockers),
|
||||
message=f"Cannot remove {template.name!r} while its managed group has members or roles.",
|
||||
)
|
||||
if not dry_run:
|
||||
try:
|
||||
_run_delete_vetoes(db, "group", template.tenant_id, group.id)
|
||||
except AdminConflictError as exc:
|
||||
return _outcome(
|
||||
command,
|
||||
status="blocked",
|
||||
resource_id=group.id,
|
||||
blocker_codes=("module_delete_veto",),
|
||||
message=str(exc),
|
||||
)
|
||||
db.delete(group)
|
||||
return _outcome(command, status="removed", resource_id=group.id)
|
||||
|
||||
if group is None:
|
||||
resource_id = new_uuid()
|
||||
slug = _available_slug(used_slugs[template.tenant_id], template.slug)
|
||||
if not dry_run:
|
||||
group = Group(
|
||||
id=resource_id,
|
||||
tenant_id=template.tenant_id,
|
||||
slug=_available_slug(db, Group, template.tenant_id, template.slug),
|
||||
slug=slug,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
is_active=template.is_active,
|
||||
@@ -28,78 +188,199 @@ class SqlAccessGovernanceMaterializer(AccessGovernanceMaterializer):
|
||||
system_required=template.required,
|
||||
)
|
||||
db.add(group)
|
||||
else:
|
||||
group.name = template.name
|
||||
group.description = template.description
|
||||
group.system_required = template.required
|
||||
if template.required:
|
||||
group.is_active = template.is_active
|
||||
db.flush()
|
||||
return
|
||||
existing[key] = group
|
||||
return _outcome(command, status="created", resource_id=resource_id)
|
||||
|
||||
role = (
|
||||
db.query(Role)
|
||||
.filter(Role.tenant_id == template.tenant_id, Role.system_template_id == template.template_id)
|
||||
.first()
|
||||
)
|
||||
if role is None:
|
||||
role = Role(
|
||||
tenant_id=template.tenant_id,
|
||||
slug=_available_slug(db, Role, template.tenant_id, template.slug),
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
permissions=list(template.permissions),
|
||||
is_builtin=False,
|
||||
is_assignable=template.is_active,
|
||||
system_template_id=template.template_id,
|
||||
system_required=template.required,
|
||||
)
|
||||
db.add(role)
|
||||
else:
|
||||
role.name = template.name
|
||||
role.description = template.description
|
||||
role.permissions = list(template.permissions)
|
||||
role.system_required = template.required
|
||||
if template.required:
|
||||
role.is_assignable = template.is_active
|
||||
db.flush()
|
||||
changes = {
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
"system_required": template.required,
|
||||
}
|
||||
if template.required:
|
||||
changes["is_active"] = template.is_active
|
||||
changed = any(getattr(group, field) != value for field, value in changes.items())
|
||||
if changed and not dry_run:
|
||||
for field, value in changes.items():
|
||||
setattr(group, field, value)
|
||||
return _outcome(command, status="updated" if changed else "unchanged", resource_id=group.id)
|
||||
|
||||
def remove_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
||||
db = _session(session)
|
||||
if template.kind == "group":
|
||||
group = (
|
||||
db.query(Group)
|
||||
.filter(Group.tenant_id == template.tenant_id, Group.system_template_id == template.template_id)
|
||||
.first()
|
||||
def _reconcile_role(
|
||||
self,
|
||||
db: Session,
|
||||
command: GovernanceProjectionCommand,
|
||||
existing: dict[tuple[str, str], Role],
|
||||
duplicate_keys: set[tuple[str, str]],
|
||||
used_slugs: dict[str, set[str]],
|
||||
user_counts: dict[str, int],
|
||||
group_counts: dict[str, int],
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> GovernanceProjectionOutcome:
|
||||
template = command.template
|
||||
key = (template.tenant_id, template.template_id)
|
||||
role = existing.get(key)
|
||||
if key in duplicate_keys:
|
||||
return _outcome(
|
||||
command,
|
||||
status="failed",
|
||||
blocker_codes=("duplicate_managed_projection",),
|
||||
message="Multiple managed roles exist for this template and tenant.",
|
||||
)
|
||||
if group is None:
|
||||
return
|
||||
membership_count = db.query(UserGroupMembership).filter(UserGroupMembership.group_id == group.id).count()
|
||||
role_count = db.query(GroupRoleAssignment).filter(GroupRoleAssignment.group_id == group.id).count()
|
||||
if membership_count or role_count:
|
||||
raise AdminConflictError(
|
||||
f"Cannot remove {template.name!r} from the tenant while its managed group has members or roles."
|
||||
if command.operation == "remove":
|
||||
if role is None:
|
||||
return _outcome(command, status="absent")
|
||||
blockers: list[str] = []
|
||||
if user_counts.get(role.id, 0):
|
||||
blockers.append("role_has_users")
|
||||
if group_counts.get(role.id, 0):
|
||||
blockers.append("role_has_groups")
|
||||
if blockers:
|
||||
return _outcome(
|
||||
command,
|
||||
status="blocked",
|
||||
resource_id=role.id,
|
||||
blocker_codes=tuple(blockers),
|
||||
message=f"Cannot remove {template.name!r} while its managed role is assigned to users or groups.",
|
||||
)
|
||||
_run_delete_vetoes(db, "group", template.tenant_id, group.id)
|
||||
db.delete(group)
|
||||
db.flush()
|
||||
return
|
||||
if not dry_run:
|
||||
db.delete(role)
|
||||
return _outcome(command, status="removed", resource_id=role.id)
|
||||
|
||||
role = (
|
||||
db.query(Role)
|
||||
.filter(Role.tenant_id == template.tenant_id, Role.system_template_id == template.template_id)
|
||||
.first()
|
||||
)
|
||||
if role is None:
|
||||
return
|
||||
user_count = db.query(UserRoleAssignment).filter(UserRoleAssignment.role_id == role.id).count()
|
||||
group_count = db.query(GroupRoleAssignment).filter(GroupRoleAssignment.role_id == role.id).count()
|
||||
if user_count or group_count:
|
||||
raise AdminConflictError(
|
||||
f"Cannot remove {template.name!r} from the tenant while its managed role is assigned to users or groups."
|
||||
)
|
||||
db.delete(role)
|
||||
db.flush()
|
||||
resource_id = new_uuid()
|
||||
slug = _available_slug(used_slugs[template.tenant_id], template.slug)
|
||||
if not dry_run:
|
||||
role = Role(
|
||||
id=resource_id,
|
||||
tenant_id=template.tenant_id,
|
||||
slug=slug,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
permissions=list(template.permissions),
|
||||
is_builtin=False,
|
||||
is_assignable=template.is_active,
|
||||
system_template_id=template.template_id,
|
||||
system_required=template.required,
|
||||
)
|
||||
db.add(role)
|
||||
existing[key] = role
|
||||
return _outcome(command, status="created", resource_id=resource_id)
|
||||
|
||||
changes: dict[str, object] = {
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
"permissions": list(template.permissions),
|
||||
"system_required": template.required,
|
||||
}
|
||||
if template.required:
|
||||
changes["is_assignable"] = template.is_active
|
||||
changed = any(getattr(role, field) != value for field, value in changes.items())
|
||||
if changed and not dry_run:
|
||||
for field, value in changes.items():
|
||||
setattr(role, field, value)
|
||||
return _outcome(command, status="updated" if changed else "unchanged", resource_id=role.id)
|
||||
|
||||
|
||||
def _managed_groups(
|
||||
session: Session,
|
||||
commands: tuple[GovernanceProjectionCommand, ...],
|
||||
) -> tuple[dict[tuple[str, str], Group], set[tuple[str, str]]]:
|
||||
if not commands:
|
||||
return {}, set()
|
||||
tenants = {item.template.tenant_id for item in commands}
|
||||
templates = {item.template.template_id for item in commands}
|
||||
rows = session.query(Group).filter(
|
||||
Group.tenant_id.in_(tenants),
|
||||
Group.system_template_id.in_(templates),
|
||||
).all()
|
||||
return _indexed_managed(rows)
|
||||
|
||||
|
||||
def _managed_roles(
|
||||
session: Session,
|
||||
commands: tuple[GovernanceProjectionCommand, ...],
|
||||
) -> tuple[dict[tuple[str, str], Role], set[tuple[str, str]]]:
|
||||
if not commands:
|
||||
return {}, set()
|
||||
tenants = {item.template.tenant_id for item in commands}
|
||||
templates = {item.template.template_id for item in commands}
|
||||
rows = session.query(Role).filter(
|
||||
Role.tenant_id.in_(tenants),
|
||||
Role.system_template_id.in_(templates),
|
||||
).all()
|
||||
return _indexed_managed(rows)
|
||||
|
||||
|
||||
def _indexed_managed(rows):
|
||||
indexed = {}
|
||||
duplicates = set()
|
||||
for row in rows:
|
||||
key = (row.tenant_id, row.system_template_id)
|
||||
if key in indexed:
|
||||
duplicates.add(key)
|
||||
else:
|
||||
indexed[key] = row
|
||||
return indexed, duplicates
|
||||
|
||||
|
||||
def _used_slugs(
|
||||
session: Session,
|
||||
model: type[Group] | type[Role],
|
||||
commands: tuple[GovernanceProjectionCommand, ...],
|
||||
) -> dict[str, set[str]]:
|
||||
used: dict[str, set[str]] = defaultdict(set)
|
||||
tenants = {item.template.tenant_id for item in commands}
|
||||
if tenants:
|
||||
for tenant_id, slug in session.query(model.tenant_id, model.slug).filter(model.tenant_id.in_(tenants)).all():
|
||||
used[str(tenant_id)].add(slug)
|
||||
for tenant_id in tenants:
|
||||
used[tenant_id]
|
||||
return used
|
||||
|
||||
|
||||
def _assignment_counts(session: Session, model, column, resource_ids: set[str]) -> dict[str, int]:
|
||||
if not resource_ids:
|
||||
return {}
|
||||
return {
|
||||
resource_id: count
|
||||
for resource_id, count in session.query(column, func.count(model.id))
|
||||
.filter(column.in_(resource_ids))
|
||||
.group_by(column)
|
||||
.all()
|
||||
}
|
||||
|
||||
|
||||
def _available_slug(used: set[str], base: str) -> str:
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while candidate in used:
|
||||
candidate = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
used.add(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
def _outcome(
|
||||
command: GovernanceProjectionCommand,
|
||||
*,
|
||||
status: str,
|
||||
resource_id: str | None = None,
|
||||
blocker_codes: tuple[str, ...] = (),
|
||||
message: str | None = None,
|
||||
) -> GovernanceProjectionOutcome:
|
||||
template = command.template
|
||||
return GovernanceProjectionOutcome(
|
||||
assignment_id=command.assignment_id,
|
||||
template_id=template.template_id,
|
||||
tenant_id=template.tenant_id,
|
||||
kind=template.kind,
|
||||
operation=command.operation,
|
||||
status=status, # type: ignore[arg-type]
|
||||
resource_id=resource_id,
|
||||
blocker_codes=blocker_codes,
|
||||
message=message,
|
||||
provenance=dict(command.provenance),
|
||||
)
|
||||
|
||||
|
||||
def _session(session: object) -> Session:
|
||||
@@ -119,12 +400,3 @@ def _run_delete_vetoes(session: Session, resource_type: str, tenant_id: str, res
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise AdminConflictError(str(exc)) from exc
|
||||
|
||||
|
||||
def _available_slug(session: Session, model: type[Group] | type[Role], tenant_id: str, base: str) -> str:
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while session.query(model).filter(model.tenant_id == tenant_id, model.slug == candidate).first():
|
||||
candidate = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
|
||||
+443
@@ -0,0 +1,443 @@
|
||||
"""access semantic directory
|
||||
|
||||
Revision ID: 4a5b6c7d8e9f
|
||||
Revises: 3f4a5b6c7d8e
|
||||
Create Date: 2026-07-10 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "4a5b6c7d8e9f"
|
||||
down_revision = "3f4a5b6c7d8e"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _tables() -> set[str]:
|
||||
return set(sa.inspect(op.get_bind()).get_table_names())
|
||||
|
||||
|
||||
def _scope_fk_target() -> str:
|
||||
tables = _tables()
|
||||
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
|
||||
|
||||
|
||||
def _indexes(table_name: str) -> set[str]:
|
||||
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()
|
||||
scope_fk_target = _scope_fk_target()
|
||||
|
||||
if "access_identities" not in tables:
|
||||
op.create_table(
|
||||
"access_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_access_identities")),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_identities_external_subject"), "access_identities", ["external_subject"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_identity_account_links" not in tables:
|
||||
op.create_table(
|
||||
"access_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(
|
||||
["account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f("fk_access_identity_account_links_account_id_access_accounts"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["identity_id"],
|
||||
["access_identities.id"],
|
||||
name=op.f("fk_access_identity_account_links_identity_id_access_identities"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_identity_account_links")),
|
||||
sa.UniqueConstraint("identity_id", "account_id", name="uq_identity_account_links_identity_account"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_identity_account_links_account_id"), "access_identity_account_links", ["account_id"])
|
||||
_create_index_if_missing(op.f("ix_access_identity_account_links_identity_id"), "access_identity_account_links", ["identity_id"])
|
||||
_create_index_if_missing(
|
||||
"uq_identity_account_links_primary_account",
|
||||
"access_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_account_links_primary_identity",
|
||||
"access_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_organization_units" not in tables:
|
||||
op.create_table(
|
||||
"access_organization_units",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("parent_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("slug", sa.String(length=100), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["parent_id"],
|
||||
["access_organization_units.id"],
|
||||
name=op.f("fk_access_organization_units_parent_id_access_organization_units"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
[scope_fk_target],
|
||||
name=op.f("fk_access_organization_units_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_organization_units")),
|
||||
sa.UniqueConstraint("tenant_id", "slug", name="uq_organization_units_tenant_slug"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_organization_units_parent_id"), "access_organization_units", ["parent_id"])
|
||||
_create_index_if_missing(op.f("ix_access_organization_units_tenant_id"), "access_organization_units", ["tenant_id"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_functions" not in tables:
|
||||
op.create_table(
|
||||
"access_functions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("organization_unit_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("slug", sa.String(length=100), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("delegable", sa.Boolean(), nullable=False),
|
||||
sa.Column("act_in_place_allowed", sa.Boolean(), 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.ForeignKeyConstraint(
|
||||
["organization_unit_id"],
|
||||
["access_organization_units.id"],
|
||||
name=op.f("fk_access_functions_organization_unit_id_access_organization_units"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
[scope_fk_target],
|
||||
name=op.f("fk_access_functions_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_functions")),
|
||||
sa.UniqueConstraint("tenant_id", "organization_unit_id", "slug", name="uq_functions_tenant_ou_slug"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_functions_organization_unit_id"), "access_functions", ["organization_unit_id"])
|
||||
_create_index_if_missing(op.f("ix_access_functions_tenant_id"), "access_functions", ["tenant_id"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_function_role_assignments" not in tables:
|
||||
op.create_table(
|
||||
"access_function_role_assignments",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("role_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["function_id"],
|
||||
["access_functions.id"],
|
||||
name=op.f("fk_access_function_role_assignments_function_id_access_functions"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["role_id"],
|
||||
["access_roles.id"],
|
||||
name=op.f("fk_access_function_role_assignments_role_id_access_roles"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
[scope_fk_target],
|
||||
name=op.f("fk_access_function_role_assignments_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_function_role_assignments")),
|
||||
sa.UniqueConstraint("tenant_id", "function_id", "role_id", name="uq_function_role_assignments"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_function_role_assignments_function_id"), "access_function_role_assignments", ["function_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_role_assignments_role_id"), "access_function_role_assignments", ["role_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_role_assignments_tenant_id"), "access_function_role_assignments", ["tenant_id"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_external_function_role_assignments" not in tables:
|
||||
op.create_table(
|
||||
"access_external_function_role_assignments",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_module", sa.String(length=50), nullable=False),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("role_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["role_id"],
|
||||
["access_roles.id"],
|
||||
name=op.f("fk_access_external_function_role_assignments_role_id_access_roles"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
[scope_fk_target],
|
||||
name=op.f("fk_access_external_function_role_assignments_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_external_function_role_assignments")),
|
||||
sa.UniqueConstraint("tenant_id", "source_module", "function_id", "role_id", name="uq_external_function_role_assignments"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_external_function_role_assignments_function_id"), "access_external_function_role_assignments", ["function_id"])
|
||||
_create_index_if_missing(op.f("ix_access_external_function_role_assignments_role_id"), "access_external_function_role_assignments", ["role_id"])
|
||||
_create_index_if_missing(op.f("ix_access_external_function_role_assignments_source_module"), "access_external_function_role_assignments", ["source_module"])
|
||||
_create_index_if_missing(op.f("ix_access_external_function_role_assignments_tenant_id"), "access_external_function_role_assignments", ["tenant_id"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_function_assignments" not in tables:
|
||||
op.create_table(
|
||||
"access_function_assignments",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("identity_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("organization_unit_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("applies_to_subunits", sa.Boolean(), nullable=False),
|
||||
sa.Column("source", sa.String(length=50), nullable=False),
|
||||
sa.Column("delegated_from_assignment_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("acting_for_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f("fk_access_function_assignments_account_id_access_accounts"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["acting_for_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f("fk_access_function_assignments_acting_for_account_id_access_accounts"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["delegated_from_assignment_id"],
|
||||
["access_function_assignments.id"],
|
||||
name=op.f("fk_access_function_assignments_delegated_from_assignment_id_access_function_assignments"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["function_id"],
|
||||
["access_functions.id"],
|
||||
name=op.f("fk_access_function_assignments_function_id_access_functions"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["identity_id"],
|
||||
["access_identities.id"],
|
||||
name=op.f("fk_access_function_assignments_identity_id_access_identities"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["organization_unit_id"],
|
||||
["access_organization_units.id"],
|
||||
name=op.f("fk_access_function_assignments_organization_unit_id_access_organization_units"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
[scope_fk_target],
|
||||
name=op.f("fk_access_function_assignments_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_function_assignments")),
|
||||
sa.UniqueConstraint("tenant_id", "account_id", "function_id", "organization_unit_id", name="uq_function_assignments_account_scope"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_account_id"), "access_function_assignments", ["account_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_acting_for_account_id"), "access_function_assignments", ["acting_for_account_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_delegated_from_assignment_id"), "access_function_assignments", ["delegated_from_assignment_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_function_id"), "access_function_assignments", ["function_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_identity_id"), "access_function_assignments", ["identity_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_organization_unit_id"), "access_function_assignments", ["organization_unit_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_tenant_id"), "access_function_assignments", ["tenant_id"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_function_delegations" not in tables:
|
||||
op.create_table(
|
||||
"access_function_delegations",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("function_assignment_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("delegator_account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("delegate_account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("mode", sa.String(length=30), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["delegate_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f("fk_access_function_delegations_delegate_account_id_access_accounts"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["delegator_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f("fk_access_function_delegations_delegator_account_id_access_accounts"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["function_assignment_id"],
|
||||
["access_function_assignments.id"],
|
||||
name=op.f("fk_access_function_delegations_function_assignment_id_access_function_assignments"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
[scope_fk_target],
|
||||
name=op.f("fk_access_function_delegations_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_function_delegations")),
|
||||
sa.UniqueConstraint("tenant_id", "function_assignment_id", "delegate_account_id", "mode", name="uq_function_delegations_assignment_delegate_mode"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_function_delegations_delegate_account_id"), "access_function_delegations", ["delegate_account_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_delegations_delegator_account_id"), "access_function_delegations", ["delegator_account_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_delegations_function_assignment_id"), "access_function_delegations", ["function_assignment_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_delegations_revoked_at"), "access_function_delegations", ["revoked_at"])
|
||||
_create_index_if_missing(op.f("ix_access_function_delegations_tenant_id"), "access_function_delegations", ["tenant_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table_name, indexes in (
|
||||
(
|
||||
"access_function_delegations",
|
||||
(
|
||||
op.f("ix_access_function_delegations_tenant_id"),
|
||||
op.f("ix_access_function_delegations_revoked_at"),
|
||||
op.f("ix_access_function_delegations_function_assignment_id"),
|
||||
op.f("ix_access_function_delegations_delegator_account_id"),
|
||||
op.f("ix_access_function_delegations_delegate_account_id"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"access_function_assignments",
|
||||
(
|
||||
op.f("ix_access_function_assignments_tenant_id"),
|
||||
op.f("ix_access_function_assignments_organization_unit_id"),
|
||||
op.f("ix_access_function_assignments_identity_id"),
|
||||
op.f("ix_access_function_assignments_function_id"),
|
||||
op.f("ix_access_function_assignments_delegated_from_assignment_id"),
|
||||
op.f("ix_access_function_assignments_acting_for_account_id"),
|
||||
op.f("ix_access_function_assignments_account_id"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"access_external_function_role_assignments",
|
||||
(
|
||||
op.f("ix_access_external_function_role_assignments_tenant_id"),
|
||||
op.f("ix_access_external_function_role_assignments_source_module"),
|
||||
op.f("ix_access_external_function_role_assignments_role_id"),
|
||||
op.f("ix_access_external_function_role_assignments_function_id"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"access_function_role_assignments",
|
||||
(
|
||||
op.f("ix_access_function_role_assignments_tenant_id"),
|
||||
op.f("ix_access_function_role_assignments_role_id"),
|
||||
op.f("ix_access_function_role_assignments_function_id"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"access_functions",
|
||||
(
|
||||
op.f("ix_access_functions_tenant_id"),
|
||||
op.f("ix_access_functions_organization_unit_id"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"access_organization_units",
|
||||
(
|
||||
op.f("ix_access_organization_units_tenant_id"),
|
||||
op.f("ix_access_organization_units_parent_id"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"access_identity_account_links",
|
||||
(
|
||||
"uq_identity_account_links_primary_identity",
|
||||
"uq_identity_account_links_primary_account",
|
||||
op.f("ix_access_identity_account_links_identity_id"),
|
||||
op.f("ix_access_identity_account_links_account_id"),
|
||||
),
|
||||
),
|
||||
("access_identities", (op.f("ix_access_identities_external_subject"),)),
|
||||
):
|
||||
for index_name in indexes:
|
||||
_drop_index_if_exists(index_name, table_name)
|
||||
|
||||
for table_name in (
|
||||
"access_function_delegations",
|
||||
"access_function_assignments",
|
||||
"access_function_role_assignments",
|
||||
"access_functions",
|
||||
"access_organization_units",
|
||||
"access_identity_account_links",
|
||||
"access_identities",
|
||||
):
|
||||
if table_name in _tables():
|
||||
op.drop_table(table_name)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""managed automation service accounts
|
||||
|
||||
Revision ID: b6d9f2a5c8e1
|
||||
Revises: 4a5b6c7d8e9f
|
||||
Create Date: 2026-07-29 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "b6d9f2a5c8e1"
|
||||
down_revision = "4a5b6c7d8e9f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if (
|
||||
"access_service_accounts"
|
||||
in sa.inspect(op.get_bind()).get_table_names()
|
||||
):
|
||||
return
|
||||
op.create_table(
|
||||
"access_service_accounts",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("membership_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("normalized_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("scope_ceiling", sa.JSON(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"created_by_account_id",
|
||||
sa.String(length=36),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_by_account_id",
|
||||
sa.String(length=36),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_account_id_access_accounts"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["created_by_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_created_by_account_id_"
|
||||
"access_accounts"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["membership_id"],
|
||||
["access_users.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_membership_id_access_users"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_tenant_id_core_scopes"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["updated_by_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_updated_by_account_id_"
|
||||
"access_accounts"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_access_service_accounts"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"account_id",
|
||||
name="uq_access_service_accounts_account",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"membership_id",
|
||||
name="uq_access_service_accounts_membership",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"normalized_name",
|
||||
name="uq_access_service_accounts_tenant_name",
|
||||
),
|
||||
)
|
||||
for name, columns in (
|
||||
(
|
||||
"ix_access_service_accounts_account_id",
|
||||
["account_id"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_is_active",
|
||||
["is_active"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_membership_id",
|
||||
["membership_id"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_retired_at",
|
||||
["retired_at"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_tenant_id",
|
||||
["tenant_id"],
|
||||
),
|
||||
):
|
||||
op.create_index(name, "access_service_accounts", columns)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if (
|
||||
"access_service_accounts"
|
||||
in sa.inspect(op.get_bind()).get_table_names()
|
||||
):
|
||||
op.drop_table("access_service_accounts")
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
"""Repair missing external function role mappings without replaying the baseline.
|
||||
|
||||
Revision ID: d8f1b4e7a0c3
|
||||
Revises: b6d9f2a5c8e1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d8f1b4e7a0c3"
|
||||
down_revision = "b6d9f2a5c8e1"
|
||||
branch_labels = None
|
||||
depends_on = "4f2a9c8e7b6d"
|
||||
|
||||
TABLE_NAME = "access_external_function_role_assignments"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Some older installations record the Access baseline without this table.
|
||||
# Never recreate an existing mapping table or derive permission grants.
|
||||
if sa.inspect(op.get_bind()).has_table(TABLE_NAME):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
TABLE_NAME,
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_module", sa.String(length=50), nullable=False),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("role_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["role_id"],
|
||||
["access_roles.id"],
|
||||
name=op.f("fk_access_external_function_role_assignments_role_id_access_roles"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f("fk_access_external_function_role_assignments_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_external_function_role_assignments")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "source_module", "function_id", "role_id",
|
||||
name="uq_external_function_role_assignments",
|
||||
),
|
||||
)
|
||||
for column in ("function_id", "role_id", "source_module", "tenant_id"):
|
||||
op.create_index(op.f(f"ix_{TABLE_NAME}_{column}"), TABLE_NAME, [column], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The table belongs to the baseline, not this repair. Keep mappings created
|
||||
# before or after repair; dropping it would silently remove permission policy.
|
||||
pass
|
||||
@@ -0,0 +1,192 @@
|
||||
"""v0.1.7 access baseline
|
||||
|
||||
Revision ID: 4a5b6c7d8e9f
|
||||
Revises: None
|
||||
Create Date: 2026-07-11 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '4a5b6c7d8e9f'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = '4f2a9c8e7b6d'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('access_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_access_identities'))
|
||||
)
|
||||
op.create_index(op.f('ix_access_identities_external_subject'), 'access_identities', ['external_subject'], unique=False)
|
||||
op.create_table('access_organization_units',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('parent_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('slug', sa.String(length=100), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['parent_id'], ['access_organization_units.id'], name=op.f('fk_access_organization_units_parent_id_access_organization_units'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_organization_units_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_organization_units')),
|
||||
sa.UniqueConstraint('tenant_id', 'slug', name='uq_organization_units_tenant_slug')
|
||||
)
|
||||
op.create_index(op.f('ix_access_organization_units_parent_id'), 'access_organization_units', ['parent_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_organization_units_tenant_id'), 'access_organization_units', ['tenant_id'], unique=False)
|
||||
op.create_table('access_external_function_role_assignments',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('source_module', sa.String(length=50), nullable=False),
|
||||
sa.Column('function_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('role_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['access_roles.id'], name=op.f('fk_access_external_function_role_assignments_role_id_access_roles'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_external_function_role_assignments_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_external_function_role_assignments')),
|
||||
sa.UniqueConstraint('tenant_id', 'source_module', 'function_id', 'role_id', name='uq_external_function_role_assignments')
|
||||
)
|
||||
op.create_index(op.f('ix_access_external_function_role_assignments_function_id'), 'access_external_function_role_assignments', ['function_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_external_function_role_assignments_role_id'), 'access_external_function_role_assignments', ['role_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_external_function_role_assignments_source_module'), 'access_external_function_role_assignments', ['source_module'], unique=False)
|
||||
op.create_index(op.f('ix_access_external_function_role_assignments_tenant_id'), 'access_external_function_role_assignments', ['tenant_id'], unique=False)
|
||||
op.create_table('access_functions',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('organization_unit_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('slug', sa.String(length=100), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('delegable', sa.Boolean(), nullable=False),
|
||||
sa.Column('act_in_place_allowed', sa.Boolean(), 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.ForeignKeyConstraint(['organization_unit_id'], ['access_organization_units.id'], name=op.f('fk_access_functions_organization_unit_id_access_organization_units'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_functions_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_functions')),
|
||||
sa.UniqueConstraint('tenant_id', 'organization_unit_id', 'slug', name='uq_functions_tenant_ou_slug')
|
||||
)
|
||||
op.create_index(op.f('ix_access_functions_organization_unit_id'), 'access_functions', ['organization_unit_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_functions_tenant_id'), 'access_functions', ['tenant_id'], unique=False)
|
||||
op.create_table('access_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(['account_id'], ['access_accounts.id'], name=op.f('fk_access_identity_account_links_account_id_access_accounts'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['identity_id'], ['access_identities.id'], name=op.f('fk_access_identity_account_links_identity_id_access_identities'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_identity_account_links')),
|
||||
sa.UniqueConstraint('identity_id', 'account_id', name='uq_identity_account_links_identity_account')
|
||||
)
|
||||
op.create_index(op.f('ix_access_identity_account_links_account_id'), 'access_identity_account_links', ['account_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_identity_account_links_identity_id'), 'access_identity_account_links', ['identity_id'], unique=False)
|
||||
op.create_index('uq_identity_account_links_primary_account', 'access_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_account_links_primary_identity', 'access_identity_account_links', ['identity_id'], unique=True, sqlite_where=sa.text('is_primary = 1'), postgresql_where=sa.text('is_primary IS TRUE'))
|
||||
op.create_table('access_function_assignments',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('account_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('identity_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('function_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('organization_unit_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('applies_to_subunits', sa.Boolean(), nullable=False),
|
||||
sa.Column('source', sa.String(length=50), nullable=False),
|
||||
sa.Column('delegated_from_assignment_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('acting_for_account_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['access_accounts.id'], name=op.f('fk_access_function_assignments_account_id_access_accounts'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['acting_for_account_id'], ['access_accounts.id'], name=op.f('fk_access_function_assignments_acting_for_account_id_access_accounts'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['delegated_from_assignment_id'], ['access_function_assignments.id'], name=op.f('fk_access_function_assignments_delegated_from_assignment_id_access_function_assignments'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['function_id'], ['access_functions.id'], name=op.f('fk_access_function_assignments_function_id_access_functions'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['identity_id'], ['access_identities.id'], name=op.f('fk_access_function_assignments_identity_id_access_identities'), ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['organization_unit_id'], ['access_organization_units.id'], name=op.f('fk_access_function_assignments_organization_unit_id_access_organization_units'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_function_assignments_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_function_assignments')),
|
||||
sa.UniqueConstraint('tenant_id', 'account_id', 'function_id', 'organization_unit_id', name='uq_function_assignments_account_scope')
|
||||
)
|
||||
op.create_index(op.f('ix_access_function_assignments_account_id'), 'access_function_assignments', ['account_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_acting_for_account_id'), 'access_function_assignments', ['acting_for_account_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_delegated_from_assignment_id'), 'access_function_assignments', ['delegated_from_assignment_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_function_id'), 'access_function_assignments', ['function_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_identity_id'), 'access_function_assignments', ['identity_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_organization_unit_id'), 'access_function_assignments', ['organization_unit_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_assignments_tenant_id'), 'access_function_assignments', ['tenant_id'], unique=False)
|
||||
op.create_table('access_function_role_assignments',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('function_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('role_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['function_id'], ['access_functions.id'], name=op.f('fk_access_function_role_assignments_function_id_access_functions'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['access_roles.id'], name=op.f('fk_access_function_role_assignments_role_id_access_roles'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_function_role_assignments_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_function_role_assignments')),
|
||||
sa.UniqueConstraint('tenant_id', 'function_id', 'role_id', name='uq_function_role_assignments')
|
||||
)
|
||||
op.create_index(op.f('ix_access_function_role_assignments_function_id'), 'access_function_role_assignments', ['function_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_role_assignments_role_id'), 'access_function_role_assignments', ['role_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_role_assignments_tenant_id'), 'access_function_role_assignments', ['tenant_id'], unique=False)
|
||||
op.create_table('access_function_delegations',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('tenant_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('function_assignment_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('delegator_account_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('delegate_account_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('mode', sa.String(length=30), nullable=False),
|
||||
sa.Column('reason', sa.Text(), nullable=True),
|
||||
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('settings', sa.JSON(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['delegate_account_id'], ['access_accounts.id'], name=op.f('fk_access_function_delegations_delegate_account_id_access_accounts'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['delegator_account_id'], ['access_accounts.id'], name=op.f('fk_access_function_delegations_delegator_account_id_access_accounts'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['function_assignment_id'], ['access_function_assignments.id'], name=op.f('fk_access_function_delegations_function_assignment_id_access_function_assignments'), ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_access_function_delegations_tenant_id_scopes'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_access_function_delegations')),
|
||||
sa.UniqueConstraint('tenant_id', 'function_assignment_id', 'delegate_account_id', 'mode', name='uq_function_delegations_assignment_delegate_mode')
|
||||
)
|
||||
op.create_index(op.f('ix_access_function_delegations_delegate_account_id'), 'access_function_delegations', ['delegate_account_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_delegations_delegator_account_id'), 'access_function_delegations', ['delegator_account_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_delegations_function_assignment_id'), 'access_function_delegations', ['function_assignment_id'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_delegations_revoked_at'), 'access_function_delegations', ['revoked_at'], unique=False)
|
||||
op.create_index(op.f('ix_access_function_delegations_tenant_id'), 'access_function_delegations', ['tenant_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('access_function_delegations')
|
||||
op.drop_table('access_function_role_assignments')
|
||||
op.drop_table('access_function_assignments')
|
||||
op.drop_table('access_identity_account_links')
|
||||
op.drop_table('access_functions')
|
||||
op.drop_table('access_external_function_role_assignments')
|
||||
op.drop_table('access_organization_units')
|
||||
op.drop_table('access_identities')
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
"""managed automation service accounts
|
||||
|
||||
Revision ID: b6d9f2a5c8e1
|
||||
Revises: 4a5b6c7d8e9f
|
||||
Create Date: 2026-07-29 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "b6d9f2a5c8e1"
|
||||
down_revision = "4a5b6c7d8e9f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if (
|
||||
"access_service_accounts"
|
||||
in sa.inspect(op.get_bind()).get_table_names()
|
||||
):
|
||||
return
|
||||
op.create_table(
|
||||
"access_service_accounts",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("membership_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("normalized_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("scope_ceiling", sa.JSON(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"created_by_account_id",
|
||||
sa.String(length=36),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_by_account_id",
|
||||
sa.String(length=36),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_account_id_access_accounts"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["created_by_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_created_by_account_id_"
|
||||
"access_accounts"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["membership_id"],
|
||||
["access_users.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_membership_id_access_users"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_tenant_id_core_scopes"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["updated_by_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_updated_by_account_id_"
|
||||
"access_accounts"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_access_service_accounts"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"account_id",
|
||||
name="uq_access_service_accounts_account",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"membership_id",
|
||||
name="uq_access_service_accounts_membership",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"normalized_name",
|
||||
name="uq_access_service_accounts_tenant_name",
|
||||
),
|
||||
)
|
||||
for name, columns in (
|
||||
(
|
||||
"ix_access_service_accounts_account_id",
|
||||
["account_id"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_is_active",
|
||||
["is_active"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_membership_id",
|
||||
["membership_id"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_retired_at",
|
||||
["retired_at"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_tenant_id",
|
||||
["tenant_id"],
|
||||
),
|
||||
):
|
||||
op.create_index(name, "access_service_accounts", columns)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if (
|
||||
"access_service_accounts"
|
||||
in sa.inspect(op.get_bind()).get_table_names()
|
||||
):
|
||||
op.drop_table("access_service_accounts")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Persist explicit interactive acting-in-place context.
|
||||
|
||||
Revision ID: c7e0a3d6f9b2
|
||||
Revises: b6d9f2a5c8e1
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c7e0a3d6f9b2"
|
||||
down_revision = "b6d9f2a5c8e1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"access_auth_sessions",
|
||||
sa.Column("acting_assignment_id", sa.String(length=36), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"access_auth_sessions",
|
||||
sa.Column("acting_for_account_id", sa.String(length=36), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_access_auth_sessions_acting_assignment_id"),
|
||||
"access_auth_sessions",
|
||||
["acting_assignment_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_access_auth_sessions_acting_assignment_id"),
|
||||
table_name="access_auth_sessions",
|
||||
)
|
||||
op.drop_column("access_auth_sessions", "acting_for_account_id")
|
||||
op.drop_column("access_auth_sessions", "acting_assignment_id")
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
"""Repair missing external function role mappings without replaying the baseline.
|
||||
|
||||
Revision ID: d8f1b4e7a0c3
|
||||
Revises: c7e0a3d6f9b2
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d8f1b4e7a0c3"
|
||||
down_revision = "c7e0a3d6f9b2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TABLE_NAME = "access_external_function_role_assignments"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Some older installations record the Access baseline without this table.
|
||||
# Never recreate an existing mapping table or derive permission grants.
|
||||
if sa.inspect(op.get_bind()).has_table(TABLE_NAME):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
TABLE_NAME,
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_module", sa.String(length=50), nullable=False),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("role_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["role_id"],
|
||||
["access_roles.id"],
|
||||
name=op.f("fk_access_external_function_role_assignments_role_id_access_roles"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f("fk_access_external_function_role_assignments_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_external_function_role_assignments")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "source_module", "function_id", "role_id",
|
||||
name="uq_external_function_role_assignments",
|
||||
),
|
||||
)
|
||||
for column in ("function_id", "role_id", "source_module", "tenant_id"):
|
||||
op.create_index(op.f(f"ix_{TABLE_NAME}_{column}"), TABLE_NAME, [column], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The table belongs to the baseline, not this repair. Keep mappings created
|
||||
# before or after repair; dropping it would silently remove permission policy.
|
||||
pass
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, User
|
||||
from govoplan_core.core.people import (
|
||||
PeopleSearchError,
|
||||
PeopleSearchGroup,
|
||||
PersonSearchCandidate,
|
||||
person_selection_key,
|
||||
)
|
||||
|
||||
|
||||
def _principal_tenant_id(principal: object) -> str:
|
||||
try:
|
||||
tenant_id = getattr(principal, "tenant_id")
|
||||
except (AttributeError, RuntimeError) as exc:
|
||||
raise PeopleSearchError("People search requires an active tenant context.") from exc
|
||||
normalized = str(tenant_id or "").strip()
|
||||
if not normalized:
|
||||
raise PeopleSearchError("People search requires an active tenant context.")
|
||||
return normalized
|
||||
|
||||
|
||||
def _like_pattern(query: str) -> str:
|
||||
escaped = query.casefold().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
return f"%{escaped}%"
|
||||
|
||||
|
||||
class AccessPeopleSearchProvider:
|
||||
"""Search active accounts through their active tenant membership.
|
||||
|
||||
The active principal's tenant is the only accepted visibility boundary;
|
||||
global accounts and memberships of other tenants are never candidates.
|
||||
Feature routers authorize the surrounding task before invoking this
|
||||
capability.
|
||||
"""
|
||||
|
||||
def search_people(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str,
|
||||
limit: int = 25,
|
||||
) -> tuple[PeopleSearchGroup, ...]:
|
||||
if not isinstance(session, Session):
|
||||
raise PeopleSearchError("People search requires a database session.")
|
||||
tenant_id = _principal_tenant_id(principal)
|
||||
normalized_limit = max(1, min(int(limit), 100))
|
||||
account_query = (
|
||||
session.query(User, Account)
|
||||
.join(Account, Account.id == User.account_id)
|
||||
.filter(
|
||||
User.tenant_id == tenant_id,
|
||||
User.is_active.is_(True),
|
||||
Account.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
normalized_query = str(query or "").strip()
|
||||
if normalized_query:
|
||||
pattern = _like_pattern(normalized_query)
|
||||
account_query = account_query.filter(
|
||||
or_(
|
||||
func.lower(User.display_name).like(pattern, escape="\\"),
|
||||
func.lower(User.email).like(pattern, escape="\\"),
|
||||
func.lower(Account.display_name).like(pattern, escape="\\"),
|
||||
func.lower(Account.email).like(pattern, escape="\\"),
|
||||
)
|
||||
)
|
||||
|
||||
rows = (
|
||||
account_query
|
||||
.order_by(
|
||||
func.coalesce(User.display_name, Account.display_name, User.email, Account.email).asc(),
|
||||
Account.id.asc(),
|
||||
)
|
||||
.limit(normalized_limit)
|
||||
.all()
|
||||
)
|
||||
candidates = tuple(
|
||||
PersonSearchCandidate(
|
||||
selection_key=person_selection_key("account", account.id),
|
||||
kind="account",
|
||||
reference_id=account.id,
|
||||
display_name=user.display_name or account.display_name or user.email or account.email,
|
||||
email=user.email or account.email,
|
||||
source_module="access",
|
||||
source_label="Accounts",
|
||||
source_ref=f"access:account:{account.id}",
|
||||
)
|
||||
for user, account in rows
|
||||
)
|
||||
return (PeopleSearchGroup(key="accounts", label="Accounts", candidates=candidates),)
|
||||
|
||||
|
||||
def people_search_capability(_context: object) -> AccessPeopleSearchProvider:
|
||||
return AccessPeopleSearchProvider()
|
||||
|
||||
|
||||
__all__ = ["AccessPeopleSearchProvider", "people_search_capability"]
|
||||
@@ -0,0 +1,250 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
from govoplan_access.backend.permissions.evaluator import scope_grants as _scope_grants
|
||||
from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate
|
||||
from govoplan_core.security.module_permissions import compatible_required_scopes
|
||||
from govoplan_core.security.permissions import ALL_PERMISSIONS as CORE_LEGACY_PERMISSION_DEFINITIONS
|
||||
from govoplan_core.security.permissions import PermissionDefinition as CoreLegacyPermissionDefinition
|
||||
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
|
||||
|
||||
|
||||
def _legacy_permission(permission: CoreLegacyPermissionDefinition) -> PermissionDefinition:
|
||||
parts = permission.scope.split(":", 2)
|
||||
if len(parts) == 2:
|
||||
module_id, action = parts
|
||||
resource = module_id
|
||||
else:
|
||||
module_id, resource, action = parts
|
||||
return PermissionDefinition(
|
||||
scope=permission.scope,
|
||||
label=permission.label,
|
||||
description=permission.description,
|
||||
category=permission.category,
|
||||
level=permission.level, # type: ignore[arg-type]
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
action=action,
|
||||
deprecated=True,
|
||||
)
|
||||
|
||||
|
||||
LEGACY_PERMISSION_DEFINITIONS: tuple[PermissionDefinition, ...] = tuple(
|
||||
_legacy_permission(permission)
|
||||
for permission in CORE_LEGACY_PERMISSION_DEFINITIONS
|
||||
)
|
||||
|
||||
|
||||
def normalize_email(value: str) -> str:
|
||||
return value.strip().casefold()
|
||||
|
||||
|
||||
def permission_catalog(*, include_legacy: bool = True) -> tuple[PermissionDefinition, ...]:
|
||||
catalog: dict[str, PermissionDefinition] = {}
|
||||
for permission in _active_permission_definitions():
|
||||
catalog[permission.scope] = permission
|
||||
if include_legacy:
|
||||
for permission in LEGACY_PERMISSION_DEFINITIONS:
|
||||
catalog.setdefault(permission.scope, permission)
|
||||
return tuple(catalog.values())
|
||||
|
||||
|
||||
def permission_map(*, include_legacy: bool = True) -> dict[str, PermissionDefinition]:
|
||||
return {permission.scope: permission for permission in permission_catalog(include_legacy=include_legacy)}
|
||||
|
||||
|
||||
def role_templates() -> tuple[RoleTemplate, ...]:
|
||||
registry = _registry()
|
||||
if registry is not None and hasattr(registry, "role_templates"):
|
||||
return tuple(registry.role_templates())
|
||||
from govoplan_access.backend.manifest import ACCESS_ROLE_TEMPLATES
|
||||
|
||||
return ACCESS_ROLE_TEMPLATES
|
||||
|
||||
|
||||
def role_templates_for_level(level: PermissionLevel) -> tuple[RoleTemplate, ...]:
|
||||
return tuple(template for template in role_templates() if template.level == level)
|
||||
|
||||
|
||||
def scope_grants(granted: str, required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
|
||||
catalog = catalog if catalog is not None else permission_map(include_legacy=True)
|
||||
if _scope_grants(granted, required, catalog=catalog):
|
||||
return True
|
||||
for alias in LEGACY_SCOPE_ALIASES.get(granted, frozenset()):
|
||||
if scope_grants(alias, required, catalog=catalog):
|
||||
return True
|
||||
return any(
|
||||
_scope_grants(granted, alias, catalog=catalog)
|
||||
for alias in compatible_required_scopes(required)
|
||||
if alias != required
|
||||
)
|
||||
|
||||
|
||||
def scopes_grant(scopes: Iterable[str], required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
|
||||
catalog = catalog if catalog is not None else permission_map(include_legacy=True)
|
||||
return any(scope_grants(scope, required, catalog=catalog) for scope in scopes)
|
||||
|
||||
|
||||
def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> list[str]:
|
||||
catalog = permission_map(include_legacy=True)
|
||||
raw = {str(scope) for scope in scopes if scope}
|
||||
expanded: set[str] = set()
|
||||
for scope in raw:
|
||||
if scope in {"*", "tenant:*", "system:*"} or scope.endswith(":*"):
|
||||
expanded.add(scope)
|
||||
for alias in LEGACY_SCOPE_ALIASES.get(scope, frozenset()):
|
||||
expanded.add(alias)
|
||||
matched = {candidate for candidate in catalog if scope_grants(scope, candidate, catalog=catalog)}
|
||||
expanded.update(matched)
|
||||
for alias in compatible_required_scopes(scope):
|
||||
if alias != scope:
|
||||
expanded.add(alias)
|
||||
# Preserve explicitly granted scopes in the presentation set. A
|
||||
# canonical module scope can still match its legacy compatibility
|
||||
# alias when the providing module is not loaded; treating that match
|
||||
# as proof that the original scope is known would otherwise replace
|
||||
# the canonical grant with only the deprecated alias.
|
||||
if include_unknown or scope in catalog:
|
||||
expanded.add(scope)
|
||||
return sorted(expanded)
|
||||
|
||||
|
||||
def effective_permission_scopes(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> set[str]:
|
||||
candidates = _effective_permission_candidates(level=level)
|
||||
granted = list(scopes)
|
||||
catalog = permission_map(include_legacy=True)
|
||||
return {scope for scope in candidates if scopes_grant(granted, scope, catalog=catalog)}
|
||||
|
||||
|
||||
def effective_permission_count(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> int:
|
||||
return len(effective_permission_scopes(scopes, level=level))
|
||||
|
||||
|
||||
def _effective_permission_candidates(*, level: PermissionLevel | None = None) -> set[str]:
|
||||
active_catalog = permission_map(include_legacy=False)
|
||||
full_catalog = permission_map(include_legacy=True)
|
||||
active_scopes = {
|
||||
scope
|
||||
for scope, definition in active_catalog.items()
|
||||
if level is None or definition.level == level
|
||||
}
|
||||
candidates = set(active_scopes)
|
||||
for scope, definition in full_catalog.items():
|
||||
if scope in active_catalog:
|
||||
continue
|
||||
if level is not None and definition.level != level:
|
||||
continue
|
||||
if any(
|
||||
scope_grants(active_scope, scope, catalog=full_catalog)
|
||||
or scope_grants(scope, active_scope, catalog=full_catalog)
|
||||
for active_scope in active_scopes
|
||||
):
|
||||
continue
|
||||
candidates.add(scope)
|
||||
return candidates
|
||||
|
||||
|
||||
def validate_permissions(scopes: Iterable[str], *, level: PermissionLevel) -> list[str]:
|
||||
normalized = {str(scope) for scope in scopes if scope}
|
||||
wildcard = "system:*" if level == "system" else "tenant:*"
|
||||
if "*" in normalized or wildcard in normalized:
|
||||
return [wildcard]
|
||||
|
||||
catalog = permission_map(include_legacy=True)
|
||||
expanded: set[str] = set()
|
||||
invalid: list[str] = []
|
||||
for scope in sorted(normalized):
|
||||
aliases = LEGACY_SCOPE_ALIASES.get(scope, frozenset())
|
||||
if aliases:
|
||||
for alias in aliases:
|
||||
definition = catalog.get(alias)
|
||||
if definition is not None and definition.level == level:
|
||||
expanded.add(alias)
|
||||
continue
|
||||
if scope.endswith(":*"):
|
||||
matching = {candidate for candidate, definition in catalog.items() if definition.level == level and scope_grants(scope, candidate, catalog=catalog)}
|
||||
if matching:
|
||||
expanded.add(scope)
|
||||
continue
|
||||
definition = catalog.get(scope)
|
||||
if definition is not None and definition.level == level:
|
||||
expanded.add(scope)
|
||||
continue
|
||||
invalid.append(scope)
|
||||
if invalid:
|
||||
raise ValueError(f"Unsupported {level} permissions: {', '.join(invalid)}")
|
||||
return sorted(expanded)
|
||||
|
||||
|
||||
def validate_tenant_permissions(scopes: Iterable[str]) -> list[str]:
|
||||
return validate_permissions(scopes, level="tenant")
|
||||
|
||||
|
||||
def validate_system_permissions(scopes: Iterable[str]) -> list[str]:
|
||||
return validate_permissions(scopes, level="system")
|
||||
|
||||
|
||||
def delegateable_scopes(scopes: Iterable[str], *, level: PermissionLevel) -> set[str]:
|
||||
expanded = set(expand_scopes(scopes, include_unknown=False))
|
||||
wildcard = "system:*" if level == "system" else "tenant:*"
|
||||
if "*" in expanded or wildcard in expanded:
|
||||
return {scope for scope, definition in permission_map(include_legacy=True).items() if definition.level == level}
|
||||
return effective_permission_scopes(expanded, level=level)
|
||||
|
||||
|
||||
def delegateable_tenant_scopes(scopes: Iterable[str]) -> set[str]:
|
||||
return delegateable_scopes(scopes, level="tenant")
|
||||
|
||||
|
||||
def delegateable_system_scopes(scopes: Iterable[str]) -> set[str]:
|
||||
return delegateable_scopes(scopes, level="system")
|
||||
|
||||
|
||||
def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[str]) -> list[str]:
|
||||
user = list(user_scopes)
|
||||
key = list(key_scopes)
|
||||
catalog = permission_map(include_legacy=True)
|
||||
tenant_scopes = {scope for scope, definition in catalog.items() if definition.level == "tenant"}
|
||||
allowed = {scope for scope in tenant_scopes if scopes_grant(user, scope, catalog=catalog) and scopes_grant(key, scope, catalog=catalog)}
|
||||
user_raw = set(expand_scopes(user))
|
||||
key_raw = set(expand_scopes(key))
|
||||
allowed.update(
|
||||
scope
|
||||
for scope in user_raw.intersection(key_raw)
|
||||
if _is_concrete_tenant_credential_scope(scope, catalog)
|
||||
)
|
||||
return sorted(allowed)
|
||||
|
||||
|
||||
def _is_concrete_tenant_credential_scope(
|
||||
scope: str,
|
||||
catalog: Mapping[str, PermissionDefinition],
|
||||
) -> bool:
|
||||
# Wildcards are expanded against the tenant catalogue above. Returning the
|
||||
# wildcard itself could grant system permissions sharing the module prefix,
|
||||
# or permissions outside the currently known tenant catalogue.
|
||||
if scope == "*" or scope.endswith(":*"):
|
||||
return False
|
||||
# System permissions can use module-native names (e.g. access:tenant:create),
|
||||
# so excluding only the historical system: prefix is not sufficient.
|
||||
return all(
|
||||
not alias.startswith("system:")
|
||||
and (alias not in catalog or catalog[alias].level == "tenant")
|
||||
for alias in compatible_required_scopes(scope)
|
||||
)
|
||||
|
||||
|
||||
def _active_permission_definitions() -> tuple[PermissionDefinition, ...]:
|
||||
registry = _registry()
|
||||
if registry is not None and hasattr(registry, "permissions"):
|
||||
return tuple(registry.permissions())
|
||||
from govoplan_access.backend.manifest import ACCESS_PERMISSIONS
|
||||
|
||||
return ACCESS_PERMISSIONS
|
||||
|
||||
|
||||
def _registry() -> object | None:
|
||||
from govoplan_access.backend.runtime import get_registry
|
||||
|
||||
return get_registry()
|
||||
@@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_core.core.references import (
|
||||
ReferenceOption,
|
||||
ReferenceSearchPage,
|
||||
ReferenceSearchRequest,
|
||||
)
|
||||
|
||||
|
||||
class SqlAccessReferenceOptionProvider:
|
||||
"""Principal-aware, bounded Access directory search."""
|
||||
|
||||
def search_reference_options(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ReferenceSearchRequest,
|
||||
) -> ReferenceSearchPage:
|
||||
db = _session(session)
|
||||
tenant_id = str(request.tenant_id or "").strip()
|
||||
if not tenant_id:
|
||||
return ReferenceSearchPage()
|
||||
limit = max(1, min(int(request.limit), 200))
|
||||
offset = _cursor_offset(request.cursor)
|
||||
selected = tuple(
|
||||
dict.fromkeys(
|
||||
str(value).strip()
|
||||
for value in request.selected_values
|
||||
if str(value).strip()
|
||||
)
|
||||
)[:200]
|
||||
administrative = request.context.get("administrative") is True
|
||||
query = str(request.query or "").strip().casefold()
|
||||
if request.kind in {"user", "membership"}:
|
||||
return _search_users(
|
||||
db,
|
||||
principal,
|
||||
tenant_id=tenant_id,
|
||||
kind=request.kind,
|
||||
query=query,
|
||||
selected=selected,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
administrative=administrative,
|
||||
)
|
||||
if request.kind == "group":
|
||||
return _search_groups(
|
||||
db,
|
||||
principal,
|
||||
tenant_id=tenant_id,
|
||||
query=query,
|
||||
selected=selected,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
administrative=administrative,
|
||||
)
|
||||
raise ValueError(f"Unsupported Access reference kind: {request.kind}")
|
||||
|
||||
|
||||
def _search_users(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
kind: str,
|
||||
query: str,
|
||||
selected: Sequence[str],
|
||||
limit: int,
|
||||
offset: int,
|
||||
administrative: bool,
|
||||
) -> ReferenceSearchPage:
|
||||
value_column = User.id if kind == "membership" else User.account_id
|
||||
base = (
|
||||
session.query(User, Account)
|
||||
.join(Account, Account.id == User.account_id)
|
||||
.filter(User.tenant_id == tenant_id)
|
||||
)
|
||||
if not administrative:
|
||||
account_id = str(getattr(principal, "account_id", "") or "")
|
||||
if not account_id:
|
||||
return ReferenceSearchPage()
|
||||
base = base.filter(User.account_id == account_id)
|
||||
|
||||
selected_rows = (
|
||||
base.filter(value_column.in_(selected)).all()
|
||||
if selected
|
||||
else []
|
||||
)
|
||||
search_query = base
|
||||
if selected:
|
||||
search_query = search_query.filter(value_column.notin_(selected))
|
||||
if query:
|
||||
search_query = search_query.filter(
|
||||
or_(
|
||||
func.lower(func.coalesce(User.display_name, "")).contains(
|
||||
query,
|
||||
autoescape=True,
|
||||
),
|
||||
func.lower(User.email).contains(query, autoescape=True),
|
||||
func.lower(Account.email).contains(query, autoescape=True),
|
||||
func.lower(value_column).contains(query, autoescape=True),
|
||||
)
|
||||
)
|
||||
rows = (
|
||||
search_query.order_by(
|
||||
func.lower(func.coalesce(User.display_name, User.email)).asc(),
|
||||
value_column.asc(),
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
options = [
|
||||
_user_option(user, account, kind=kind)
|
||||
for user, account in rows[:limit]
|
||||
]
|
||||
selected_by_value = {
|
||||
_user_value(user, kind=kind): _user_option(user, account, kind=kind)
|
||||
for user, account in selected_rows
|
||||
}
|
||||
options.extend(
|
||||
selected_by_value[value]
|
||||
for value in selected
|
||||
if value in selected_by_value
|
||||
)
|
||||
return ReferenceSearchPage(
|
||||
options=tuple(options),
|
||||
next_cursor=f"offset:{offset + limit}" if has_more else None,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
def _search_groups(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
query: str,
|
||||
selected: Sequence[str],
|
||||
limit: int,
|
||||
offset: int,
|
||||
administrative: bool,
|
||||
) -> ReferenceSearchPage:
|
||||
base = session.query(Group).filter(Group.tenant_id == tenant_id)
|
||||
if not administrative:
|
||||
permitted = tuple(
|
||||
dict.fromkeys(
|
||||
str(group_id)
|
||||
for group_id in getattr(principal, "group_ids", ())
|
||||
if str(group_id)
|
||||
)
|
||||
)
|
||||
if not permitted:
|
||||
return ReferenceSearchPage()
|
||||
base = base.filter(Group.id.in_(permitted))
|
||||
|
||||
selected_rows = base.filter(Group.id.in_(selected)).all() if selected else []
|
||||
search_query = base
|
||||
if selected:
|
||||
search_query = search_query.filter(Group.id.notin_(selected))
|
||||
if query:
|
||||
search_query = search_query.filter(
|
||||
or_(
|
||||
func.lower(Group.name).contains(query, autoescape=True),
|
||||
func.lower(Group.slug).contains(query, autoescape=True),
|
||||
func.lower(Group.id).contains(query, autoescape=True),
|
||||
)
|
||||
)
|
||||
rows = (
|
||||
search_query.order_by(func.lower(Group.name).asc(), Group.id.asc())
|
||||
.offset(offset)
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
options = [_group_option(group) for group in rows[:limit]]
|
||||
selected_by_value = {group.id: _group_option(group) for group in selected_rows}
|
||||
options.extend(
|
||||
selected_by_value[value]
|
||||
for value in selected
|
||||
if value in selected_by_value
|
||||
)
|
||||
return ReferenceSearchPage(
|
||||
options=tuple(options),
|
||||
next_cursor=f"offset:{offset + limit}" if has_more else None,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
def _user_value(user: User, *, kind: str) -> str:
|
||||
return user.id if kind == "membership" else user.account_id
|
||||
|
||||
|
||||
def _user_option(user: User, account: Account, *, kind: str) -> ReferenceOption:
|
||||
inactive = not user.is_active or not account.is_active
|
||||
value = _user_value(user, kind=kind)
|
||||
description_parts = [
|
||||
user.email,
|
||||
"Inactive" if inactive else None,
|
||||
]
|
||||
return ReferenceOption(
|
||||
value=value,
|
||||
label=user.display_name or user.email or value,
|
||||
description=" · ".join(
|
||||
part for part in description_parts if part
|
||||
) or None,
|
||||
kind=kind,
|
||||
availability="inactive" if inactive else "available",
|
||||
disabled=inactive,
|
||||
source_module="access",
|
||||
provenance={
|
||||
"tenant_id": user.tenant_id,
|
||||
"membership_id": user.id,
|
||||
"account_id": user.account_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _group_option(group: Group) -> ReferenceOption:
|
||||
inactive = not group.is_active
|
||||
return ReferenceOption(
|
||||
value=group.id,
|
||||
label=group.name or group.id,
|
||||
description="Inactive" if inactive else None,
|
||||
kind="group",
|
||||
availability="inactive" if inactive else "available",
|
||||
disabled=inactive,
|
||||
source_module="access",
|
||||
provenance={"tenant_id": group.tenant_id, "group_id": group.id},
|
||||
)
|
||||
|
||||
|
||||
def _cursor_offset(cursor: str | None) -> int:
|
||||
if cursor is None:
|
||||
return 0
|
||||
prefix = "offset:"
|
||||
if not cursor.startswith(prefix):
|
||||
raise ValueError("Invalid reference search cursor.")
|
||||
try:
|
||||
offset = int(cursor[len(prefix):])
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid reference search cursor.") from exc
|
||||
if offset < 0:
|
||||
raise ValueError("Invalid reference search cursor.")
|
||||
return offset
|
||||
|
||||
|
||||
def _session(session: object) -> Session:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("Access reference search requires a SQLAlchemy Session")
|
||||
return session
|
||||
|
||||
|
||||
__all__ = ["SqlAccessReferenceOptionProvider"]
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
|
||||
from govoplan_access.backend.db.models import ApiKey, User
|
||||
@@ -11,6 +11,7 @@ from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
|
||||
API_KEY_PREFIX_LENGTH = 12
|
||||
API_KEY_RANDOM_BYTES = 32
|
||||
API_KEY_SECRET_PREFIX = "gpn_"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -28,7 +29,7 @@ def verify_api_key(secret: str, expected_hash: str) -> bool:
|
||||
|
||||
|
||||
def generate_api_key_secret() -> str:
|
||||
return generate_secret("mm_", random_bytes=API_KEY_RANDOM_BYTES)
|
||||
return generate_secret(API_KEY_SECRET_PREFIX, random_bytes=API_KEY_RANDOM_BYTES)
|
||||
|
||||
|
||||
def api_key_prefix(secret: str) -> str:
|
||||
@@ -60,17 +61,36 @@ def create_api_key(
|
||||
return CreatedApiKey(model=model, secret=secret)
|
||||
|
||||
|
||||
def authenticate_api_key(session: Session, secret: str) -> ApiKey | None:
|
||||
def authenticate_api_key(
|
||||
session: Session,
|
||||
secret: str,
|
||||
*,
|
||||
touch_interval_seconds: int = 5 * 60,
|
||||
) -> ApiKey | None:
|
||||
prefix = api_key_prefix(secret)
|
||||
candidates = session.query(ApiKey).filter(ApiKey.prefix == prefix, ApiKey.revoked_at.is_(None)).all()
|
||||
candidates = (
|
||||
session.query(ApiKey)
|
||||
.options(joinedload(ApiKey.user).joinedload(User.account))
|
||||
.filter(
|
||||
ApiKey.prefix == prefix,
|
||||
ApiKey.revoked_at.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
now = utc_now()
|
||||
for candidate in candidates:
|
||||
expires_at = ensure_aware_utc(candidate.expires_at)
|
||||
if expires_at and expires_at < now:
|
||||
continue
|
||||
if verify_api_key(secret, candidate.key_hash):
|
||||
candidate.last_used_at = now
|
||||
session.add(candidate)
|
||||
last_used_at = ensure_aware_utc(candidate.last_used_at)
|
||||
if (
|
||||
touch_interval_seconds <= 0
|
||||
or last_used_at is None
|
||||
or now - last_used_at >= timedelta(seconds=touch_interval_seconds)
|
||||
):
|
||||
candidate.last_used_at = now
|
||||
session.add(candidate)
|
||||
return candidate
|
||||
return None
|
||||
|
||||
@@ -78,4 +98,3 @@ def authenticate_api_key(session: Session, secret: str) -> ApiKey | None:
|
||||
def has_scope(api_key: ApiKey, required_scope: str) -> bool:
|
||||
scopes = set(api_key.scopes or [])
|
||||
return "*" in scopes or required_scope in scopes
|
||||
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from redis import Redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_REDIS_INCREMENT_SCRIPT = """
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
local ttl = redis.call('TTL', KEYS[1])
|
||||
return {count, ttl}
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AttemptBucket:
|
||||
count: int = 0
|
||||
retry_after_seconds: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LoginThrottleDecision:
|
||||
allowed: bool
|
||||
retry_after_seconds: int = 0
|
||||
|
||||
|
||||
class LoginAttemptStore(Protocol):
|
||||
def read(self, key: str) -> AttemptBucket: ...
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket: ...
|
||||
|
||||
def delete(self, key: str) -> None: ...
|
||||
|
||||
|
||||
class InMemoryLoginAttemptStore:
|
||||
"""Bounded process-local fallback for development and Redis outages."""
|
||||
|
||||
def __init__(self, *, max_entries: int = 10_000) -> None:
|
||||
self._entries: dict[str, tuple[int, float]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._max_entries = max(2, max_entries)
|
||||
|
||||
def read(self, key: str) -> AttemptBucket:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
entry = self._active_entry(key, now=now)
|
||||
if entry is None:
|
||||
return AttemptBucket()
|
||||
count, expires_at = entry
|
||||
return AttemptBucket(
|
||||
count=count,
|
||||
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||
)
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
entry = self._active_entry(key, now=now)
|
||||
if entry is None:
|
||||
self._make_room(now=now, incoming_key=key)
|
||||
count = 1
|
||||
expires_at = now + window_seconds
|
||||
else:
|
||||
count = entry[0] + 1
|
||||
expires_at = entry[1]
|
||||
self._entries[key] = (count, expires_at)
|
||||
return AttemptBucket(
|
||||
count=count,
|
||||
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||
)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
with self._lock:
|
||||
self._entries.pop(key, None)
|
||||
|
||||
def _active_entry(self, key: str, *, now: float) -> tuple[int, float] | None:
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if entry[1] <= now:
|
||||
self._entries.pop(key, None)
|
||||
return None
|
||||
return entry
|
||||
|
||||
def _make_room(self, *, now: float, incoming_key: str) -> None:
|
||||
if incoming_key in self._entries or len(self._entries) < self._max_entries:
|
||||
return
|
||||
expired = [key for key, (_, expires_at) in self._entries.items() if expires_at <= now]
|
||||
for key in expired:
|
||||
self._entries.pop(key, None)
|
||||
while len(self._entries) >= self._max_entries:
|
||||
self._entries.pop(next(iter(self._entries)))
|
||||
|
||||
|
||||
class RedisLoginAttemptStore:
|
||||
"""Redis-backed fixed-window counters shared by all API workers."""
|
||||
|
||||
def __init__(self, redis_url: str) -> None:
|
||||
self._client = Redis.from_url(
|
||||
redis_url,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=0.25,
|
||||
socket_timeout=0.25,
|
||||
health_check_interval=30,
|
||||
)
|
||||
|
||||
def read(self, key: str) -> AttemptBucket:
|
||||
pipeline = self._client.pipeline(transaction=False)
|
||||
pipeline.get(key)
|
||||
pipeline.ttl(key)
|
||||
raw_count, raw_ttl = pipeline.execute()
|
||||
count = int(raw_count or 0)
|
||||
ttl = int(raw_ttl or 0)
|
||||
return AttemptBucket(count=count, retry_after_seconds=max(0, ttl))
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket:
|
||||
result = self._client.eval(_REDIS_INCREMENT_SCRIPT, 1, key, window_seconds)
|
||||
if not isinstance(result, (list, tuple)) or len(result) != 2:
|
||||
raise RedisError("Unexpected login throttle response from Redis")
|
||||
return AttemptBucket(
|
||||
count=int(result[0]),
|
||||
retry_after_seconds=max(1, int(result[1])),
|
||||
)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self._client.delete(key)
|
||||
|
||||
|
||||
class ResilientLoginAttemptStore:
|
||||
"""Prefer the distributed store and fail safely to a local bounded store."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
primary: LoginAttemptStore | None,
|
||||
fallback: LoginAttemptStore,
|
||||
*,
|
||||
retry_seconds: int = 30,
|
||||
) -> None:
|
||||
self._primary = primary
|
||||
self._fallback = fallback
|
||||
self._retry_seconds = max(1, retry_seconds)
|
||||
self._primary_unavailable_until = 0.0
|
||||
self._state_lock = threading.Lock()
|
||||
|
||||
def read(self, key: str) -> AttemptBucket:
|
||||
fallback_result = self._fallback.read(key)
|
||||
primary = self._available_primary()
|
||||
if primary is None:
|
||||
return fallback_result
|
||||
try:
|
||||
return _stricter_bucket(primary.read(key), fallback_result)
|
||||
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||
self._mark_primary_unavailable(exc)
|
||||
return fallback_result
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket:
|
||||
primary = self._available_primary()
|
||||
if primary is not None:
|
||||
fallback_result = self._fallback.increment(key, window_seconds=window_seconds)
|
||||
try:
|
||||
# Mirror the active process's failures so a later Redis outage
|
||||
# or recovery cannot restart its protection window from zero.
|
||||
primary_result = primary.increment(key, window_seconds=window_seconds)
|
||||
return _stricter_bucket(primary_result, fallback_result)
|
||||
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||
self._mark_primary_unavailable(exc)
|
||||
return fallback_result
|
||||
return self._fallback.increment(key, window_seconds=window_seconds)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self._fallback.delete(key)
|
||||
primary = self._available_primary()
|
||||
if primary is None:
|
||||
return
|
||||
try:
|
||||
primary.delete(key)
|
||||
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||
self._mark_primary_unavailable(exc)
|
||||
|
||||
def _available_primary(self) -> LoginAttemptStore | None:
|
||||
if self._primary is None:
|
||||
return None
|
||||
with self._state_lock:
|
||||
if time.monotonic() < self._primary_unavailable_until:
|
||||
return None
|
||||
return self._primary
|
||||
|
||||
def _mark_primary_unavailable(self, exc: Exception) -> None:
|
||||
should_log = False
|
||||
with self._state_lock:
|
||||
now = time.monotonic()
|
||||
if now >= self._primary_unavailable_until:
|
||||
should_log = True
|
||||
self._primary_unavailable_until = now + self._retry_seconds
|
||||
if should_log:
|
||||
logger.warning(
|
||||
"Redis login throttling is unavailable; using the process-local fallback (%s)",
|
||||
type(exc).__name__,
|
||||
)
|
||||
|
||||
|
||||
def _stricter_bucket(first: AttemptBucket, second: AttemptBucket) -> AttemptBucket:
|
||||
return AttemptBucket(
|
||||
count=max(first.count, second.count),
|
||||
retry_after_seconds=max(first.retry_after_seconds, second.retry_after_seconds),
|
||||
)
|
||||
|
||||
|
||||
class LoginThrottle:
|
||||
def __init__(
|
||||
self,
|
||||
store: LoginAttemptStore,
|
||||
*,
|
||||
identity_limit: int,
|
||||
client_limit: int,
|
||||
window_seconds: int,
|
||||
key_prefix: str = "govoplan:access:login:v1",
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._identity_limit = max(1, identity_limit)
|
||||
self._client_limit = max(1, client_limit)
|
||||
self._window_seconds = max(1, window_seconds)
|
||||
self._key_prefix = key_prefix.rstrip(":")
|
||||
|
||||
def check(
|
||||
self,
|
||||
*,
|
||||
normalized_email: str,
|
||||
tenant_slug: str | None,
|
||||
client_address: str | None,
|
||||
) -> LoginThrottleDecision:
|
||||
return self._decision(
|
||||
self._buckets(
|
||||
normalized_email=normalized_email,
|
||||
tenant_slug=tenant_slug,
|
||||
client_address=client_address,
|
||||
),
|
||||
increment=False,
|
||||
)
|
||||
|
||||
def record_failure(
|
||||
self,
|
||||
*,
|
||||
normalized_email: str,
|
||||
tenant_slug: str | None,
|
||||
client_address: str | None,
|
||||
) -> LoginThrottleDecision:
|
||||
return self._decision(
|
||||
self._buckets(
|
||||
normalized_email=normalized_email,
|
||||
tenant_slug=tenant_slug,
|
||||
client_address=client_address,
|
||||
),
|
||||
increment=True,
|
||||
)
|
||||
|
||||
def record_success(
|
||||
self,
|
||||
*,
|
||||
normalized_email: str,
|
||||
tenant_slug: str | None,
|
||||
) -> None:
|
||||
del tenant_slug
|
||||
identity_key, _ = self._keys(
|
||||
normalized_email=normalized_email,
|
||||
client_address=None,
|
||||
)
|
||||
self._store.delete(identity_key)
|
||||
|
||||
def _decision(
|
||||
self,
|
||||
buckets: tuple[tuple[str, int], ...],
|
||||
*,
|
||||
increment: bool,
|
||||
) -> LoginThrottleDecision:
|
||||
blocked_retry_after = 0
|
||||
for key, limit in buckets:
|
||||
state = (
|
||||
self._store.increment(key, window_seconds=self._window_seconds)
|
||||
if increment
|
||||
else self._store.read(key)
|
||||
)
|
||||
if state.count >= limit:
|
||||
blocked_retry_after = max(
|
||||
blocked_retry_after,
|
||||
max(1, state.retry_after_seconds),
|
||||
)
|
||||
return LoginThrottleDecision(
|
||||
allowed=blocked_retry_after == 0,
|
||||
retry_after_seconds=blocked_retry_after,
|
||||
)
|
||||
|
||||
def _buckets(
|
||||
self,
|
||||
*,
|
||||
normalized_email: str,
|
||||
tenant_slug: str | None,
|
||||
client_address: str | None,
|
||||
) -> tuple[tuple[str, int], ...]:
|
||||
# Accounts and their passwords are global login identities. Do not put
|
||||
# the caller-supplied tenant slug into the identity key: rotating fake
|
||||
# slugs must not bypass the account-level limit.
|
||||
del tenant_slug
|
||||
identity_key, client_key = self._keys(
|
||||
normalized_email=normalized_email,
|
||||
client_address=client_address,
|
||||
)
|
||||
buckets = [(identity_key, self._identity_limit)]
|
||||
if client_key is not None:
|
||||
buckets.append((client_key, self._client_limit))
|
||||
return tuple(buckets)
|
||||
|
||||
def _keys(
|
||||
self,
|
||||
*,
|
||||
normalized_email: str,
|
||||
client_address: str | None,
|
||||
) -> tuple[str, str | None]:
|
||||
identity = normalized_email.strip().casefold()
|
||||
identity_digest = hashlib.sha256(identity.encode()).hexdigest()
|
||||
identity_key = f"{self._key_prefix}:identity:{identity_digest}"
|
||||
if not client_address:
|
||||
return identity_key, None
|
||||
client_digest = hashlib.sha256(client_address.strip().casefold().encode()).hexdigest()
|
||||
return identity_key, f"{self._key_prefix}:client:{client_digest}"
|
||||
|
||||
|
||||
def build_login_throttle(
|
||||
*,
|
||||
redis_url: str | None,
|
||||
identity_limit: int,
|
||||
client_limit: int,
|
||||
window_seconds: int,
|
||||
redis_retry_seconds: int,
|
||||
) -> LoginThrottle:
|
||||
redis_store = RedisLoginAttemptStore(redis_url) if redis_url and redis_url.strip() else None
|
||||
resilient_store = ResilientLoginAttemptStore(
|
||||
redis_store,
|
||||
InMemoryLoginAttemptStore(),
|
||||
retry_seconds=redis_retry_seconds,
|
||||
)
|
||||
return LoginThrottle(
|
||||
resilient_store,
|
||||
identity_limit=identity_limit,
|
||||
client_limit=client_limit,
|
||||
window_seconds=window_seconds,
|
||||
)
|
||||
@@ -9,6 +9,14 @@ _ALGORITHM = "pbkdf2_sha256"
|
||||
_DEFAULT_ITERATIONS = 260_000
|
||||
_SALT_BYTES = 16
|
||||
|
||||
# A valid, fixed-cost hash used when a login identity has no local password hash.
|
||||
# Its plaintext value is intentionally irrelevant; the hash only keeps failed
|
||||
# login attempts on the same verification path as existing local accounts.
|
||||
DUMMY_PASSWORD_HASH = (
|
||||
"pbkdf2_sha256$260000$Z292b3BsYW4tZHVtbXktdjE=" # noqa: S105 # nosec B105 - non-account timing equalizer.
|
||||
"$uWgE7ht8wO6cotOqNKK2yNomPt57gstVss5ben5gTbw="
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password: str, *, iterations: int = _DEFAULT_ITERATIONS) -> str:
|
||||
salt = os.urandom(_SALT_BYTES)
|
||||
@@ -37,4 +45,3 @@ def verify_password(password: str, encoded: str | None) -> bool:
|
||||
return False
|
||||
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
||||
return hmac.compare_digest(actual, expected)
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
|
||||
from govoplan_access.backend.db.models import (
|
||||
@@ -18,7 +19,8 @@ from govoplan_access.backend.db.models import (
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_core.security.permissions import expand_scopes
|
||||
from govoplan_access.backend.semantic import collect_function_authorization_context, collect_function_roles
|
||||
from govoplan_access.backend.permissions.catalog import expand_scopes, role_templates_for_level
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
|
||||
SESSION_RANDOM_BYTES = 32
|
||||
@@ -32,6 +34,16 @@ class CreatedSession:
|
||||
csrf_token: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserAuthorizationContext:
|
||||
tenant_roles: list[Role]
|
||||
system_roles: list[Role]
|
||||
groups: list[Group]
|
||||
function_assignment_ids: tuple[str, ...]
|
||||
function_delegation_ids: tuple[str, ...]
|
||||
scopes: list[str]
|
||||
|
||||
|
||||
def generate_session_token() -> str:
|
||||
return generate_secret("ms_", random_bytes=SESSION_RANDOM_BYTES)
|
||||
|
||||
@@ -92,17 +104,39 @@ def create_auth_session(
|
||||
return CreatedSession(model=model, token=token, csrf_token=csrf_token)
|
||||
|
||||
|
||||
def authenticate_session_token(session: Session, token: str) -> AuthSession | None:
|
||||
def authenticate_session_token(
|
||||
session: Session,
|
||||
token: str,
|
||||
*,
|
||||
touch_interval_seconds: int = 5 * 60,
|
||||
) -> AuthSession | None:
|
||||
token_hash = hash_session_token(token)
|
||||
model = session.query(AuthSession).filter(AuthSession.token_hash == token_hash, AuthSession.revoked_at.is_(None)).one_or_none()
|
||||
model = (
|
||||
session.query(AuthSession)
|
||||
.options(
|
||||
joinedload(AuthSession.user).joinedload(User.account),
|
||||
joinedload(AuthSession.account),
|
||||
)
|
||||
.filter(
|
||||
AuthSession.token_hash == token_hash,
|
||||
AuthSession.revoked_at.is_(None),
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if not model:
|
||||
return None
|
||||
now = utc_now()
|
||||
expires_at = ensure_aware_utc(model.expires_at)
|
||||
if expires_at is None or expires_at < now:
|
||||
return None
|
||||
model.last_seen_at = now
|
||||
session.add(model)
|
||||
last_seen_at = ensure_aware_utc(model.last_seen_at)
|
||||
if (
|
||||
touch_interval_seconds <= 0
|
||||
or last_seen_at is None
|
||||
or now - last_seen_at >= timedelta(seconds=touch_interval_seconds)
|
||||
):
|
||||
model.last_seen_at = now
|
||||
session.add(model)
|
||||
return model
|
||||
|
||||
|
||||
@@ -131,6 +165,8 @@ def switch_auth_session_tenant(session: Session, auth_session: AuthSession, tena
|
||||
raise LookupError("The account does not have an active membership in this tenant.")
|
||||
auth_session.tenant_id = membership.tenant_id
|
||||
auth_session.user_id = membership.id
|
||||
auth_session.acting_assignment_id = None
|
||||
auth_session.acting_for_account_id = None
|
||||
auth_session.last_seen_at = utc_now()
|
||||
session.add(auth_session)
|
||||
session.flush()
|
||||
@@ -169,6 +205,10 @@ def collect_user_roles(session: Session, user: User) -> list[Role]:
|
||||
)
|
||||
for role in group_roles:
|
||||
roles_by_id[role.id] = role
|
||||
for role in collect_function_roles(session, user):
|
||||
roles_by_id[role.id] = role
|
||||
for role in _materialized_default_authenticated_roles(session, user):
|
||||
roles_by_id[role.id] = role
|
||||
return list(roles_by_id.values())
|
||||
|
||||
|
||||
@@ -196,16 +236,115 @@ def collect_user_groups(session: Session, user: User) -> list[Group]:
|
||||
)
|
||||
|
||||
|
||||
def collect_user_authorization_context(
|
||||
session: Session,
|
||||
user: User,
|
||||
*,
|
||||
account: Account | None = None,
|
||||
include_system: bool = True,
|
||||
extra_roles: Iterable[Role] = (),
|
||||
) -> UserAuthorizationContext:
|
||||
account = account or user.account
|
||||
roles_by_id: dict[str, Role] = {role.id: role for role in collect_direct_user_roles(session, user)}
|
||||
groups = collect_user_groups(session, user)
|
||||
group_ids = [group.id for group in groups]
|
||||
if group_ids:
|
||||
group_roles = (
|
||||
session.query(Role)
|
||||
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
GroupRoleAssignment.tenant_id == user.tenant_id,
|
||||
GroupRoleAssignment.group_id.in_(sorted(group_ids)),
|
||||
Role.tenant_id == user.tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
for role in group_roles:
|
||||
roles_by_id[role.id] = role
|
||||
|
||||
function_context = collect_function_authorization_context(session, user)
|
||||
for role in function_context.roles:
|
||||
roles_by_id[role.id] = role
|
||||
for role in extra_roles:
|
||||
roles_by_id[role.id] = role
|
||||
for role in _materialized_default_authenticated_roles(session, user):
|
||||
roles_by_id[role.id] = role
|
||||
|
||||
tenant_roles = list(roles_by_id.values())
|
||||
system_roles = collect_system_roles(session, account) if include_system and account is not None else []
|
||||
default_slugs = _default_authenticated_slugs()
|
||||
scopes = {
|
||||
scope
|
||||
for role in tenant_roles
|
||||
if role.slug not in default_slugs
|
||||
for scope in (role.permissions or [])
|
||||
}
|
||||
scopes.update(
|
||||
scope
|
||||
for role in system_roles
|
||||
for scope in (role.permissions or [])
|
||||
)
|
||||
scopes.update(_default_authenticated_scopes())
|
||||
return UserAuthorizationContext(
|
||||
tenant_roles=tenant_roles,
|
||||
system_roles=system_roles,
|
||||
groups=groups,
|
||||
function_assignment_ids=function_context.assignment_ids,
|
||||
function_delegation_ids=function_context.delegation_ids,
|
||||
scopes=expand_scopes(scopes),
|
||||
)
|
||||
|
||||
|
||||
def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]:
|
||||
scopes: set[str] = set()
|
||||
scopes = _default_authenticated_scopes()
|
||||
default_slugs = _default_authenticated_slugs()
|
||||
for role in collect_user_roles(session, user):
|
||||
scopes.update(role.permissions or [])
|
||||
if role.slug not in default_slugs:
|
||||
scopes.update(role.permissions or [])
|
||||
if include_system and user.account:
|
||||
for role in collect_system_roles(session, user.account):
|
||||
scopes.update(role.permissions or [])
|
||||
return expand_scopes(scopes)
|
||||
|
||||
|
||||
def _default_authenticated_slugs() -> set[str]:
|
||||
return {
|
||||
template.slug
|
||||
for template in role_templates_for_level("tenant")
|
||||
if template.default_authenticated
|
||||
}
|
||||
|
||||
|
||||
def _default_authenticated_scopes() -> set[str]:
|
||||
return {
|
||||
scope
|
||||
for template in role_templates_for_level("tenant")
|
||||
if template.default_authenticated
|
||||
for scope in template.permissions
|
||||
}
|
||||
|
||||
|
||||
def _materialized_default_authenticated_roles(
|
||||
session: Session,
|
||||
user: User,
|
||||
) -> list[Role]:
|
||||
"""Return the optional database projection without mutating auth reads."""
|
||||
|
||||
default_slugs = _default_authenticated_slugs()
|
||||
if not default_slugs:
|
||||
return []
|
||||
return (
|
||||
session.query(Role)
|
||||
.filter(
|
||||
Role.tenant_id == user.tenant_id,
|
||||
Role.slug.in_(default_slugs),
|
||||
)
|
||||
.order_by(Role.name.asc(), Role.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def collect_tenant_memberships(session: Session, account: Account) -> list[tuple[User, Tenant]]:
|
||||
return (
|
||||
session.query(User, Tenant)
|
||||
@@ -218,4 +357,3 @@ def collect_tenant_memberships(session: Session, account: Account) -> list[tuple
|
||||
.order_by(Tenant.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ExternalFunctionRoleAssignment,
|
||||
Function,
|
||||
FunctionAssignment,
|
||||
FunctionDelegation,
|
||||
FunctionRoleAssignment,
|
||||
Identity,
|
||||
IdentityAccountLink,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from govoplan_core.core.identity import IdentityDirectory
|
||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import ORGANIZATIONS_MODULE_ID, OrganizationDirectory
|
||||
from govoplan_core.security.time import utc_now
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FunctionAuthorizationContext:
|
||||
assignment_ids: tuple[str, ...]
|
||||
delegation_ids: tuple[str, ...]
|
||||
roles: tuple[Role, ...]
|
||||
|
||||
|
||||
def primary_identity_for_account(session: Session, account_id: str) -> Identity | None:
|
||||
return (
|
||||
session.query(Identity)
|
||||
.join(IdentityAccountLink, IdentityAccountLink.identity_id == Identity.id)
|
||||
.filter(
|
||||
IdentityAccountLink.account_id == account_id,
|
||||
IdentityAccountLink.is_primary.is_(True),
|
||||
Identity.is_active.is_(True),
|
||||
)
|
||||
.order_by(IdentityAccountLink.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def identity_id_for_account(
|
||||
session: Session,
|
||||
account_id: str,
|
||||
*,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
) -> str | None:
|
||||
if identity_directory is not None:
|
||||
identity = identity_directory.identity_for_account(account_id)
|
||||
if identity is not None and identity.status == "active":
|
||||
return identity.id
|
||||
identity = primary_identity_for_account(session, account_id)
|
||||
return identity.id if identity is not None else None
|
||||
|
||||
|
||||
def ensure_identity_for_account(session: Session, account: Account, *, source: str = "local") -> Identity:
|
||||
identity = primary_identity_for_account(session, account.id)
|
||||
if identity is not None:
|
||||
return identity
|
||||
identity = Identity(display_name=account.display_name or account.email, source=source)
|
||||
session.add(identity)
|
||||
session.flush()
|
||||
session.add(IdentityAccountLink(identity_id=identity.id, account_id=account.id, is_primary=True, source=source))
|
||||
session.flush()
|
||||
return identity
|
||||
|
||||
|
||||
def active_function_assignments_for_account(
|
||||
session: Session,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> list[FunctionAssignment]:
|
||||
now = utc_now()
|
||||
query = (
|
||||
session.query(FunctionAssignment)
|
||||
.join(Function, Function.id == FunctionAssignment.function_id)
|
||||
.filter(
|
||||
FunctionAssignment.account_id == account_id,
|
||||
FunctionAssignment.is_active.is_(True),
|
||||
Function.is_active.is_(True),
|
||||
or_(FunctionAssignment.valid_from.is_(None), FunctionAssignment.valid_from <= now),
|
||||
or_(FunctionAssignment.valid_until.is_(None), FunctionAssignment.valid_until > now),
|
||||
)
|
||||
.order_by(FunctionAssignment.created_at.asc())
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(FunctionAssignment.tenant_id == tenant_id, Function.tenant_id == tenant_id)
|
||||
return query.all()
|
||||
|
||||
|
||||
def active_function_delegations_for_account(
|
||||
session: Session,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
modes: Iterable[str] = ("delegate",),
|
||||
) -> list[FunctionDelegation]:
|
||||
now = utc_now()
|
||||
mode_set = sorted({str(mode) for mode in modes})
|
||||
if not mode_set:
|
||||
return []
|
||||
query = (
|
||||
session.query(FunctionDelegation)
|
||||
.join(FunctionAssignment, FunctionAssignment.id == FunctionDelegation.function_assignment_id)
|
||||
.join(Function, Function.id == FunctionAssignment.function_id)
|
||||
.filter(
|
||||
FunctionDelegation.delegate_account_id == account_id,
|
||||
FunctionDelegation.mode.in_(mode_set),
|
||||
FunctionDelegation.is_active.is_(True),
|
||||
FunctionDelegation.revoked_at.is_(None),
|
||||
FunctionAssignment.is_active.is_(True),
|
||||
Function.is_active.is_(True),
|
||||
Function.delegable.is_(True),
|
||||
or_(FunctionDelegation.valid_from.is_(None), FunctionDelegation.valid_from <= now),
|
||||
or_(FunctionDelegation.valid_until.is_(None), FunctionDelegation.valid_until > now),
|
||||
or_(FunctionAssignment.valid_from.is_(None), FunctionAssignment.valid_from <= now),
|
||||
or_(FunctionAssignment.valid_until.is_(None), FunctionAssignment.valid_until > now),
|
||||
)
|
||||
.order_by(FunctionDelegation.created_at.asc())
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(FunctionDelegation.tenant_id == tenant_id, FunctionAssignment.tenant_id == tenant_id)
|
||||
return query.all()
|
||||
|
||||
|
||||
def collect_function_assignment_ids(session: Session, user: User) -> list[str]:
|
||||
ids = [item.id for item in active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)]
|
||||
for delegation in active_function_delegations_for_account(session, user.account_id, tenant_id=user.tenant_id):
|
||||
ids.append(delegation.function_assignment_id)
|
||||
return sorted(dict.fromkeys(ids))
|
||||
|
||||
|
||||
def collect_function_delegation_ids(session: Session, user: User) -> list[str]:
|
||||
return [
|
||||
item.id
|
||||
for item in active_function_delegations_for_account(
|
||||
session,
|
||||
user.account_id,
|
||||
tenant_id=user.tenant_id,
|
||||
modes=("delegate", "act_in_place"),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def collect_function_roles(session: Session, user: User) -> list[Role]:
|
||||
assignments = active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)
|
||||
delegated = active_function_delegations_for_account(session, user.account_id, tenant_id=user.tenant_id, modes=("delegate",))
|
||||
assignment_ids = {assignment.id for assignment in assignments}
|
||||
assignment_ids.update(delegation.function_assignment_id for delegation in delegated)
|
||||
if not assignment_ids:
|
||||
return []
|
||||
function_ids = [
|
||||
row[0]
|
||||
for row in session.query(FunctionAssignment.function_id)
|
||||
.filter(FunctionAssignment.tenant_id == user.tenant_id, FunctionAssignment.id.in_(assignment_ids))
|
||||
.all()
|
||||
]
|
||||
if not function_ids:
|
||||
return []
|
||||
return (
|
||||
session.query(Role)
|
||||
.join(FunctionRoleAssignment, FunctionRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
FunctionRoleAssignment.tenant_id == user.tenant_id,
|
||||
FunctionRoleAssignment.function_id.in_(function_ids),
|
||||
Role.tenant_id == user.tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def collect_function_authorization_context(session: Session, user: User) -> FunctionAuthorizationContext:
|
||||
assignments = active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)
|
||||
delegations = active_function_delegations_for_account(
|
||||
session,
|
||||
user.account_id,
|
||||
tenant_id=user.tenant_id,
|
||||
modes=("delegate", "act_in_place"),
|
||||
)
|
||||
direct_assignment_ids = {assignment.id for assignment in assignments}
|
||||
delegated_role_assignment_ids = {
|
||||
delegation.function_assignment_id
|
||||
for delegation in delegations
|
||||
if delegation.mode == "delegate"
|
||||
}
|
||||
role_assignment_ids = direct_assignment_ids | delegated_role_assignment_ids
|
||||
function_ids = {assignment.function_id for assignment in assignments}
|
||||
|
||||
missing_role_assignment_ids = delegated_role_assignment_ids - direct_assignment_ids
|
||||
if missing_role_assignment_ids:
|
||||
function_ids.update(
|
||||
row[0]
|
||||
for row in session.query(FunctionAssignment.function_id)
|
||||
.filter(
|
||||
FunctionAssignment.tenant_id == user.tenant_id,
|
||||
FunctionAssignment.id.in_(sorted(missing_role_assignment_ids)),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
roles: tuple[Role, ...] = ()
|
||||
if function_ids:
|
||||
roles = tuple(
|
||||
session.query(Role)
|
||||
.join(FunctionRoleAssignment, FunctionRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
FunctionRoleAssignment.tenant_id == user.tenant_id,
|
||||
FunctionRoleAssignment.function_id.in_(sorted(function_ids)),
|
||||
Role.tenant_id == user.tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return FunctionAuthorizationContext(
|
||||
assignment_ids=tuple(sorted(role_assignment_ids)),
|
||||
delegation_ids=tuple(sorted(delegation.id for delegation in delegations)),
|
||||
roles=roles,
|
||||
)
|
||||
|
||||
|
||||
def collect_external_function_roles(
|
||||
session: Session,
|
||||
user: User,
|
||||
assignments: Iterable[OrganizationFunctionAssignmentRef],
|
||||
*,
|
||||
source_module: str = ORGANIZATIONS_MODULE_ID,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> list[Role]:
|
||||
function_ids = sorted({
|
||||
assignment.function_id
|
||||
for assignment in assignments
|
||||
if assignment.tenant_id == user.tenant_id and assignment.status == "active"
|
||||
})
|
||||
if organization_directory is not None and source_module == ORGANIZATIONS_MODULE_ID:
|
||||
function_ids = [
|
||||
function_id
|
||||
for function_id in function_ids
|
||||
if _organization_function_active(organization_directory, function_id, tenant_id=user.tenant_id)
|
||||
]
|
||||
if not function_ids:
|
||||
return []
|
||||
return (
|
||||
session.query(Role)
|
||||
.join(ExternalFunctionRoleAssignment, ExternalFunctionRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
ExternalFunctionRoleAssignment.tenant_id == user.tenant_id,
|
||||
ExternalFunctionRoleAssignment.source_module == source_module,
|
||||
ExternalFunctionRoleAssignment.function_id.in_(function_ids),
|
||||
Role.tenant_id == user.tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _organization_function_active(
|
||||
organization_directory: OrganizationDirectory,
|
||||
function_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> bool:
|
||||
function = organization_directory.get_function(function_id)
|
||||
return function is not None and function.tenant_id == tenant_id and function.status == "active"
|
||||
@@ -0,0 +1,642 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.base import utcnow
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
ServiceAccount,
|
||||
Tenant,
|
||||
User,
|
||||
new_uuid,
|
||||
)
|
||||
from govoplan_access.backend.permissions.catalog import scopes_grant
|
||||
from govoplan_access.backend.security.api_keys import (
|
||||
CreatedApiKey,
|
||||
create_api_key,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
|
||||
|
||||
class ServiceAccountError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceAccountNotFoundError(ServiceAccountError):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceAccountConflictError(ServiceAccountError):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceAccountCredentialNotFoundError(ServiceAccountError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ServiceAccountCredentialSummary:
|
||||
credential_count: int = 0
|
||||
active_credential_count: int = 0
|
||||
last_credential_used_at: datetime | None = None
|
||||
|
||||
|
||||
def list_service_accounts(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> list[ServiceAccount]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(ServiceAccount)
|
||||
.where(ServiceAccount.tenant_id == tenant_id)
|
||||
.order_by(
|
||||
ServiceAccount.normalized_name,
|
||||
ServiceAccount.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def service_account_credential_summaries(
|
||||
session: Session,
|
||||
*,
|
||||
service_accounts: Iterable[ServiceAccount],
|
||||
) -> dict[str, ServiceAccountCredentialSummary]:
|
||||
items = tuple(service_accounts)
|
||||
by_membership = {item.membership_id: item.id for item in items}
|
||||
usable_accounts = {
|
||||
item.id
|
||||
for item in items
|
||||
if item.is_active and item.retired_at is None
|
||||
}
|
||||
summaries = {
|
||||
item.id: ServiceAccountCredentialSummary()
|
||||
for item in items
|
||||
}
|
||||
if not by_membership:
|
||||
return summaries
|
||||
now = utc_now()
|
||||
totals: dict[str, int] = {}
|
||||
active: dict[str, int] = {}
|
||||
last_used: dict[str, datetime | None] = {}
|
||||
credentials = session.scalars(
|
||||
select(ApiKey).where(ApiKey.user_id.in_(by_membership))
|
||||
)
|
||||
for credential in credentials:
|
||||
service_account_id = by_membership[credential.user_id]
|
||||
totals[service_account_id] = totals.get(service_account_id, 0) + 1
|
||||
expires_at = ensure_aware_utc(credential.expires_at)
|
||||
if (
|
||||
service_account_id in usable_accounts
|
||||
and
|
||||
credential.revoked_at is None
|
||||
and (expires_at is None or expires_at > now)
|
||||
):
|
||||
active[service_account_id] = (
|
||||
active.get(service_account_id, 0) + 1
|
||||
)
|
||||
used_at = ensure_aware_utc(credential.last_used_at)
|
||||
if used_at is not None and (
|
||||
last_used.get(service_account_id) is None
|
||||
or used_at > last_used[service_account_id]
|
||||
):
|
||||
last_used[service_account_id] = used_at
|
||||
return {
|
||||
item.id: ServiceAccountCredentialSummary(
|
||||
credential_count=totals.get(item.id, 0),
|
||||
active_credential_count=active.get(item.id, 0),
|
||||
last_credential_used_at=last_used.get(item.id),
|
||||
)
|
||||
for item in items
|
||||
}
|
||||
|
||||
|
||||
def list_service_account_credentials(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
include_revoked: bool = True,
|
||||
) -> tuple[ServiceAccount, list[ApiKey]]:
|
||||
item = get_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
)
|
||||
query = select(ApiKey).where(
|
||||
ApiKey.tenant_id == tenant_id,
|
||||
ApiKey.user_id == item.membership_id,
|
||||
)
|
||||
if not include_revoked:
|
||||
query = query.where(ApiKey.revoked_at.is_(None))
|
||||
credentials = list(
|
||||
session.scalars(
|
||||
query.order_by(ApiKey.created_at.desc(), ApiKey.id)
|
||||
)
|
||||
)
|
||||
return item, credentials
|
||||
|
||||
|
||||
def create_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
name: str,
|
||||
scopes: Iterable[str],
|
||||
expires_at: datetime | None,
|
||||
) -> tuple[ServiceAccount, CreatedApiKey]:
|
||||
item = _locked_service_account_for_credential_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
user = _active_service_account_membership(session, item)
|
||||
credential_scopes = _service_account_credential_scopes(
|
||||
principal,
|
||||
item,
|
||||
scopes,
|
||||
)
|
||||
created = create_api_key(
|
||||
session,
|
||||
user=user,
|
||||
name=_credential_name(name),
|
||||
scopes=list(credential_scopes),
|
||||
expires_at=_future_expiry(expires_at),
|
||||
)
|
||||
_touch_service_account(item, principal)
|
||||
session.flush()
|
||||
return item, created
|
||||
|
||||
|
||||
def rotate_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
name: str | None,
|
||||
scopes: Iterable[str] | None,
|
||||
expires_at: datetime | None,
|
||||
) -> tuple[ServiceAccount, ApiKey, CreatedApiKey]:
|
||||
item = _locked_service_account_for_credential_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
user = _active_service_account_membership(session, item)
|
||||
previous = _locked_service_account_credential(
|
||||
session,
|
||||
item=item,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
if previous.revoked_at is not None:
|
||||
raise ServiceAccountConflictError(
|
||||
"The credential is already revoked; reload before rotating"
|
||||
)
|
||||
requested_scopes = previous.scopes if scopes is None else scopes
|
||||
credential_scopes = _service_account_credential_scopes(
|
||||
principal,
|
||||
item,
|
||||
requested_scopes,
|
||||
)
|
||||
created = create_api_key(
|
||||
session,
|
||||
user=user,
|
||||
name=_credential_name(name or previous.name),
|
||||
scopes=list(credential_scopes),
|
||||
expires_at=_future_expiry(expires_at),
|
||||
)
|
||||
previous.revoked_at = utc_now()
|
||||
_touch_service_account(item, principal)
|
||||
session.flush()
|
||||
return item, previous, created
|
||||
|
||||
|
||||
def revoke_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
) -> tuple[ServiceAccount, ApiKey]:
|
||||
item = _locked_service_account_for_credential_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
credential = _locked_service_account_credential(
|
||||
session,
|
||||
item=item,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
if credential.revoked_at is None:
|
||||
credential.revoked_at = utc_now()
|
||||
_touch_service_account(item, principal)
|
||||
session.flush()
|
||||
return item, credential
|
||||
|
||||
|
||||
def get_service_account(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
lock: bool = False,
|
||||
) -> ServiceAccount:
|
||||
query = select(ServiceAccount).where(
|
||||
ServiceAccount.id == service_account_id,
|
||||
ServiceAccount.tenant_id == tenant_id,
|
||||
)
|
||||
if lock:
|
||||
query = query.with_for_update()
|
||||
item = session.scalar(query)
|
||||
if item is None:
|
||||
raise ServiceAccountNotFoundError(
|
||||
"Service account was not found"
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
def create_service_account(
|
||||
session: Session,
|
||||
*,
|
||||
tenant: Tenant,
|
||||
principal: ApiPrincipal,
|
||||
name: str,
|
||||
description: str | None,
|
||||
scope_ceiling: Iterable[str],
|
||||
) -> ServiceAccount:
|
||||
clean_name = _service_account_name(name)
|
||||
scopes = _service_account_scopes(
|
||||
principal,
|
||||
scope_ceiling,
|
||||
)
|
||||
service_account_id = new_uuid()
|
||||
internal_email = (
|
||||
f"service-account-{service_account_id}@govoplan.invalid"
|
||||
)
|
||||
account = Account(
|
||||
id=new_uuid(),
|
||||
email=internal_email,
|
||||
normalized_email=internal_email,
|
||||
display_name=clean_name,
|
||||
is_active=True,
|
||||
auth_provider="service_account",
|
||||
password_hash=None,
|
||||
password_reset_required=False,
|
||||
)
|
||||
membership = User(
|
||||
id=new_uuid(),
|
||||
tenant_id=tenant.id,
|
||||
account=account,
|
||||
email=internal_email,
|
||||
display_name=clean_name,
|
||||
is_active=True,
|
||||
is_tenant_admin=False,
|
||||
auth_provider="service_account",
|
||||
password_hash=None,
|
||||
settings={"managed_service_account": service_account_id},
|
||||
)
|
||||
item = ServiceAccount(
|
||||
id=service_account_id,
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
membership_id=membership.id,
|
||||
name=clean_name,
|
||||
normalized_name=_normalized_name(clean_name),
|
||||
description=_optional_text(description),
|
||||
scope_ceiling=list(scopes),
|
||||
is_active=True,
|
||||
revision=1,
|
||||
created_by_account_id=principal.account_id,
|
||||
updated_by_account_id=principal.account_id,
|
||||
settings={},
|
||||
)
|
||||
session.add_all((account, membership, item))
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError as exc:
|
||||
raise ServiceAccountConflictError(
|
||||
"A service account with this name already exists"
|
||||
) from exc
|
||||
return item
|
||||
|
||||
|
||||
def update_service_account(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
changes: Mapping[str, object],
|
||||
) -> ServiceAccount:
|
||||
item = get_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
lock=True,
|
||||
)
|
||||
if item.revision != expected_revision:
|
||||
raise ServiceAccountConflictError(
|
||||
"Service account changed on the server; reload before saving"
|
||||
)
|
||||
account = session.get(Account, item.account_id)
|
||||
membership = session.get(User, item.membership_id)
|
||||
if account is None or membership is None:
|
||||
raise ServiceAccountConflictError(
|
||||
"Service account backing identity is missing"
|
||||
)
|
||||
if "name" in changes:
|
||||
clean_name = _service_account_name(str(changes["name"]))
|
||||
item.name = clean_name
|
||||
item.normalized_name = _normalized_name(clean_name)
|
||||
account.display_name = clean_name
|
||||
membership.display_name = clean_name
|
||||
if "description" in changes:
|
||||
value = changes["description"]
|
||||
item.description = _optional_text(
|
||||
str(value) if value is not None else None
|
||||
)
|
||||
if "scope_ceiling" in changes:
|
||||
raw_scopes = changes["scope_ceiling"]
|
||||
if not isinstance(raw_scopes, Iterable) or isinstance(
|
||||
raw_scopes,
|
||||
(str, bytes),
|
||||
):
|
||||
raise ServiceAccountError(
|
||||
"Service account scope ceiling is invalid"
|
||||
)
|
||||
item.scope_ceiling = list(
|
||||
_service_account_scopes(
|
||||
principal,
|
||||
(str(scope) for scope in raw_scopes),
|
||||
)
|
||||
)
|
||||
if "is_active" in changes:
|
||||
active = bool(changes["is_active"])
|
||||
item.is_active = active
|
||||
account.is_active = active
|
||||
membership.is_active = active
|
||||
item.retired_at = None if active else utcnow()
|
||||
item.revision += 1
|
||||
item.updated_by_account_id = principal.account_id
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError as exc:
|
||||
raise ServiceAccountConflictError(
|
||||
"A service account with this name already exists"
|
||||
) from exc
|
||||
return item
|
||||
|
||||
|
||||
def retire_service_account(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
) -> ServiceAccount:
|
||||
item = update_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
principal=principal,
|
||||
expected_revision=expected_revision,
|
||||
changes={"is_active": False},
|
||||
)
|
||||
now = utc_now()
|
||||
credentials = session.scalars(
|
||||
select(ApiKey).where(
|
||||
ApiKey.tenant_id == tenant_id,
|
||||
ApiKey.user_id == item.membership_id,
|
||||
ApiKey.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
for credential in credentials:
|
||||
credential.revoked_at = now
|
||||
session.flush()
|
||||
return item
|
||||
|
||||
|
||||
def _locked_service_account_for_credential_change(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
expected_revision: int,
|
||||
) -> ServiceAccount:
|
||||
item = get_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
lock=True,
|
||||
)
|
||||
if item.revision != expected_revision:
|
||||
raise ServiceAccountConflictError(
|
||||
"Service account changed on the server; reload before changing credentials"
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
def _locked_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
item: ServiceAccount,
|
||||
credential_id: str,
|
||||
) -> ApiKey:
|
||||
credential = session.scalar(
|
||||
select(ApiKey)
|
||||
.where(
|
||||
ApiKey.id == credential_id,
|
||||
ApiKey.tenant_id == item.tenant_id,
|
||||
ApiKey.user_id == item.membership_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if credential is None:
|
||||
raise ServiceAccountCredentialNotFoundError(
|
||||
"Service-account credential was not found"
|
||||
)
|
||||
return credential
|
||||
|
||||
|
||||
def _active_service_account_membership(
|
||||
session: Session,
|
||||
item: ServiceAccount,
|
||||
) -> User:
|
||||
user = session.get(User, item.membership_id)
|
||||
account = session.get(Account, item.account_id)
|
||||
if (
|
||||
not item.is_active
|
||||
or item.retired_at is not None
|
||||
or user is None
|
||||
or account is None
|
||||
or not user.is_active
|
||||
or not account.is_active
|
||||
):
|
||||
raise ServiceAccountConflictError(
|
||||
"Activate the service account before creating or rotating credentials"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def _service_account_credential_scopes(
|
||||
principal: ApiPrincipal,
|
||||
item: ServiceAccount,
|
||||
values: Iterable[str],
|
||||
) -> tuple[str, ...]:
|
||||
scopes = tuple(
|
||||
sorted(
|
||||
{
|
||||
str(value).strip()
|
||||
for value in values
|
||||
if str(value).strip()
|
||||
}
|
||||
)
|
||||
)
|
||||
if not scopes:
|
||||
raise ServiceAccountError(
|
||||
"A service-account credential requires at least one scope"
|
||||
)
|
||||
if len(scopes) > 200:
|
||||
raise ServiceAccountError(
|
||||
"Service-account credentials support at most 200 scopes"
|
||||
)
|
||||
denied_by_ceiling = tuple(
|
||||
scope
|
||||
for scope in scopes
|
||||
if not scopes_grant(item.scope_ceiling, scope)
|
||||
)
|
||||
if denied_by_ceiling:
|
||||
raise PermissionError(
|
||||
"Credential scopes exceed the service-account scope ceiling: "
|
||||
+ ", ".join(denied_by_ceiling)
|
||||
)
|
||||
denied_by_actor = tuple(
|
||||
scope for scope in scopes if not principal.has(scope)
|
||||
)
|
||||
if denied_by_actor:
|
||||
raise PermissionError(
|
||||
"Credential scopes exceed the current administrator authority: "
|
||||
+ ", ".join(denied_by_actor)
|
||||
)
|
||||
return scopes
|
||||
|
||||
|
||||
def _credential_name(value: str) -> str:
|
||||
clean = " ".join(value.split())
|
||||
if not 1 <= len(clean) <= 255:
|
||||
raise ServiceAccountError(
|
||||
"Credential name must contain between 1 and 255 characters"
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
def _future_expiry(value: datetime | None) -> datetime | None:
|
||||
expires_at = ensure_aware_utc(value)
|
||||
if expires_at is not None and expires_at <= utc_now():
|
||||
raise ServiceAccountError(
|
||||
"Credential expiry must be in the future"
|
||||
)
|
||||
return expires_at
|
||||
|
||||
|
||||
def _touch_service_account(
|
||||
item: ServiceAccount,
|
||||
principal: ApiPrincipal,
|
||||
) -> None:
|
||||
item.revision += 1
|
||||
item.updated_by_account_id = principal.account_id
|
||||
|
||||
|
||||
def _service_account_name(value: str) -> str:
|
||||
clean = " ".join(value.split())
|
||||
if not 1 <= len(clean) <= 255:
|
||||
raise ServiceAccountError(
|
||||
"Service account name must contain between 1 and 255 characters"
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
def _normalized_name(value: str) -> str:
|
||||
return value.casefold()
|
||||
|
||||
|
||||
def _optional_text(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
clean = value.strip()
|
||||
if len(clean) > 4000:
|
||||
raise ServiceAccountError(
|
||||
"Service account description is too long"
|
||||
)
|
||||
return clean or None
|
||||
|
||||
|
||||
def _service_account_scopes(
|
||||
principal: ApiPrincipal,
|
||||
values: Iterable[str],
|
||||
) -> tuple[str, ...]:
|
||||
scopes = tuple(
|
||||
sorted(
|
||||
{
|
||||
str(value).strip()
|
||||
for value in values
|
||||
if str(value).strip()
|
||||
}
|
||||
)
|
||||
)
|
||||
if len(scopes) > 200:
|
||||
raise ServiceAccountError(
|
||||
"Service accounts support at most 200 scope grants"
|
||||
)
|
||||
denied = tuple(
|
||||
scope for scope in scopes
|
||||
if not principal.has(scope)
|
||||
)
|
||||
if denied:
|
||||
raise PermissionError(
|
||||
"Cannot grant service-account scopes outside the current "
|
||||
f"administrator authority: {', '.join(denied)}"
|
||||
)
|
||||
return scopes
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ServiceAccountConflictError",
|
||||
"ServiceAccountCredentialNotFoundError",
|
||||
"ServiceAccountCredentialSummary",
|
||||
"ServiceAccountError",
|
||||
"ServiceAccountNotFoundError",
|
||||
"create_service_account",
|
||||
"create_service_account_credential",
|
||||
"get_service_account",
|
||||
"list_service_accounts",
|
||||
"list_service_account_credentials",
|
||||
"revoke_service_account_credential",
|
||||
"retire_service_account",
|
||||
"rotate_service_account_credential",
|
||||
"service_account_credential_summaries",
|
||||
"update_service_account",
|
||||
]
|
||||
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import AuthSession
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
|
||||
|
||||
MAX_SESSION_LIST_ITEMS = 100
|
||||
MAX_CLIENT_LABEL_LENGTH = 160
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionSummary:
|
||||
id: str
|
||||
tenant_id: str
|
||||
current: bool
|
||||
status: str
|
||||
created_at: datetime
|
||||
last_seen_at: datetime | None
|
||||
expires_at: datetime
|
||||
revoked_at: datetime | None
|
||||
client: str | None
|
||||
|
||||
|
||||
def session_summary(
|
||||
item: AuthSession,
|
||||
*,
|
||||
current_session_id: str | None,
|
||||
now: datetime | None = None,
|
||||
) -> SessionSummary:
|
||||
effective_at = now or utc_now()
|
||||
expires_at = ensure_aware_utc(item.expires_at)
|
||||
revoked_at = ensure_aware_utc(item.revoked_at)
|
||||
if revoked_at is not None:
|
||||
status = "revoked"
|
||||
elif expires_at is None or expires_at <= effective_at:
|
||||
status = "expired"
|
||||
else:
|
||||
status = "active"
|
||||
return SessionSummary(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
current=item.id == current_session_id,
|
||||
status=status,
|
||||
created_at=item.created_at,
|
||||
last_seen_at=item.last_seen_at,
|
||||
expires_at=item.expires_at,
|
||||
revoked_at=item.revoked_at,
|
||||
client=_bounded_client(item.user_agent),
|
||||
)
|
||||
|
||||
|
||||
def list_account_sessions(
|
||||
session: Session,
|
||||
*,
|
||||
account_id: str,
|
||||
current_session_id: str | None,
|
||||
tenant_id: str | None = None,
|
||||
include_inactive: bool = False,
|
||||
limit: int = MAX_SESSION_LIST_ITEMS,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[SessionSummary, ...]:
|
||||
effective_at = now or utc_now()
|
||||
query = session.query(AuthSession).filter(AuthSession.account_id == account_id)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(AuthSession.tenant_id == tenant_id)
|
||||
if not include_inactive:
|
||||
query = query.filter(
|
||||
AuthSession.revoked_at.is_(None),
|
||||
AuthSession.expires_at > effective_at,
|
||||
)
|
||||
rows = (
|
||||
query.order_by(AuthSession.created_at.desc(), AuthSession.id.asc())
|
||||
.limit(max(1, min(limit, MAX_SESSION_LIST_ITEMS)))
|
||||
.all()
|
||||
)
|
||||
return tuple(
|
||||
session_summary(
|
||||
item,
|
||||
current_session_id=current_session_id,
|
||||
now=effective_at,
|
||||
)
|
||||
for item in rows
|
||||
)
|
||||
|
||||
|
||||
def revoke_account_session(
|
||||
session: Session,
|
||||
*,
|
||||
account_id: str,
|
||||
session_id: str,
|
||||
tenant_id: str | None = None,
|
||||
protected_session_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[AuthSession | None, bool]:
|
||||
query = session.query(AuthSession).filter(
|
||||
AuthSession.id == session_id,
|
||||
AuthSession.account_id == account_id,
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(AuthSession.tenant_id == tenant_id)
|
||||
item = query.one_or_none()
|
||||
if item is None:
|
||||
return None, False
|
||||
if protected_session_id is not None and item.id == protected_session_id:
|
||||
raise ValueError("The current session cannot be revoked through session management.")
|
||||
if item.revoked_at is not None:
|
||||
return item, False
|
||||
item.revoked_at = now or utc_now()
|
||||
session.add(item)
|
||||
return item, True
|
||||
|
||||
|
||||
def revoke_other_account_sessions(
|
||||
session: Session,
|
||||
*,
|
||||
account_id: str,
|
||||
current_session_id: str,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
effective_at = now or utc_now()
|
||||
rows = (
|
||||
session.query(AuthSession)
|
||||
.filter(
|
||||
AuthSession.account_id == account_id,
|
||||
AuthSession.id != current_session_id,
|
||||
AuthSession.revoked_at.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
revoked: list[str] = []
|
||||
for item in rows:
|
||||
expires_at = ensure_aware_utc(item.expires_at)
|
||||
if expires_at is None or expires_at <= effective_at:
|
||||
continue
|
||||
item.revoked_at = effective_at
|
||||
session.add(item)
|
||||
revoked.append(item.id)
|
||||
return tuple(sorted(revoked))
|
||||
|
||||
|
||||
def _bounded_client(value: str | None) -> str | None:
|
||||
normalized = " ".join(str(value or "").split())
|
||||
if not normalized:
|
||||
return None
|
||||
return normalized[:MAX_CLIENT_LABEL_LENGTH]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_CLIENT_LABEL_LENGTH",
|
||||
"MAX_SESSION_LIST_ITEMS",
|
||||
"SessionSummary",
|
||||
"list_account_sessions",
|
||||
"revoke_account_session",
|
||||
"revoke_other_account_sessions",
|
||||
"session_summary",
|
||||
]
|
||||
@@ -1,13 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.admin.service import ensure_default_roles
|
||||
from govoplan_access.backend.db.models import Account, User, UserRoleAssignment
|
||||
from govoplan_access.backend.admin.service import ensure_default_roles, get_or_create_account
|
||||
from govoplan_access.backend.db.models import Account, Role, SystemRoleAssignment, User, UserRoleAssignment
|
||||
from govoplan_access.backend.permissions.catalog import normalize_email, scopes_grant
|
||||
from govoplan_access.backend.security.api_keys import create_api_key
|
||||
from govoplan_access.backend.security.passwords import hash_password
|
||||
from govoplan_core.admin.common import AdminValidationError
|
||||
from govoplan_core.core.access import TenantAccessProvisioner, TenantOwnerCandidateRef
|
||||
from govoplan_core.core.access import (
|
||||
CreatedApiKeyRef,
|
||||
DevelopmentBootstrapRef,
|
||||
FirstAdminProvisioner,
|
||||
FirstAdminProvisioningError,
|
||||
FirstSystemAdministratorRef,
|
||||
TenantAccessProvisioner,
|
||||
TenantOwnerCandidateRef,
|
||||
UserRef,
|
||||
)
|
||||
|
||||
|
||||
class LegacyTenantAccessProvisioner(TenantAccessProvisioner):
|
||||
@@ -75,6 +87,199 @@ class LegacyTenantAccessProvisioner(TenantAccessProvisioner):
|
||||
db.flush()
|
||||
return membership.id
|
||||
|
||||
def ensure_development_admin(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant: object,
|
||||
user_email: str,
|
||||
user_password: str,
|
||||
api_key_secret: str | None,
|
||||
scopes: Sequence[str],
|
||||
) -> DevelopmentBootstrapRef:
|
||||
db = _session(session)
|
||||
tenant_id = getattr(tenant, "id", None)
|
||||
if not tenant_id:
|
||||
raise AdminValidationError("Development bootstrap requires a persisted tenant.")
|
||||
|
||||
tenant_roles = ensure_default_roles(db, tenant) # type: ignore[arg-type]
|
||||
system_roles = ensure_default_roles(db, None)
|
||||
account, _, _ = get_or_create_account(
|
||||
db,
|
||||
email=user_email,
|
||||
display_name="Development Admin",
|
||||
password=user_password,
|
||||
password_reset_required=False,
|
||||
)
|
||||
if not account.password_hash:
|
||||
account.password_hash = hash_password(user_password)
|
||||
account.is_active = True
|
||||
db.add(account)
|
||||
|
||||
user = db.query(User).filter(User.tenant_id == tenant_id, User.account_id == account.id).one_or_none()
|
||||
if user is None:
|
||||
user = User(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name="Development Admin",
|
||||
is_tenant_admin=True,
|
||||
auth_provider=account.auth_provider,
|
||||
password_hash=account.password_hash,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
else:
|
||||
user.email = account.email
|
||||
user.password_hash = account.password_hash
|
||||
user.is_active = True
|
||||
db.add(user)
|
||||
|
||||
owner_role = tenant_roles["owner"]
|
||||
existing_assignment = db.query(UserRoleAssignment).filter(
|
||||
UserRoleAssignment.tenant_id == tenant_id,
|
||||
UserRoleAssignment.user_id == user.id,
|
||||
UserRoleAssignment.role_id == owner_role.id,
|
||||
).one_or_none()
|
||||
if existing_assignment is None:
|
||||
db.add(UserRoleAssignment(tenant_id=tenant_id, user_id=user.id, role_id=owner_role.id))
|
||||
|
||||
system_owner = system_roles["system_owner"]
|
||||
existing_system_assignment = db.query(SystemRoleAssignment).filter(
|
||||
SystemRoleAssignment.account_id == account.id,
|
||||
SystemRoleAssignment.role_id == system_owner.id,
|
||||
).one_or_none()
|
||||
if existing_system_assignment is None:
|
||||
db.add(SystemRoleAssignment(account_id=account.id, role_id=system_owner.id))
|
||||
|
||||
created_api_key = None
|
||||
if api_key_secret:
|
||||
existing = [key for key in user.api_keys if key.name == "Development API key" and key.revoked_at is None]
|
||||
if not existing:
|
||||
api_key = create_api_key(
|
||||
db,
|
||||
user=user,
|
||||
name="Development API key",
|
||||
scopes=list(scopes),
|
||||
secret=api_key_secret,
|
||||
)
|
||||
created_api_key = CreatedApiKeyRef(id=api_key.model.id, secret=api_key.secret)
|
||||
|
||||
db.flush()
|
||||
return DevelopmentBootstrapRef(
|
||||
user=UserRef(
|
||||
id=user.id,
|
||||
account_id=account.id,
|
||||
tenant_id=tenant_id,
|
||||
email=user.email,
|
||||
display_name=user.display_name,
|
||||
status="active" if user.is_active else "inactive",
|
||||
),
|
||||
created_api_key=created_api_key,
|
||||
)
|
||||
|
||||
|
||||
class LegacyFirstAdminProvisioner(FirstAdminProvisioner):
|
||||
def has_durable_system_administrator(self, session: object) -> bool:
|
||||
db = _session(session)
|
||||
roles = (
|
||||
db.query(Role)
|
||||
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
|
||||
.join(Account, Account.id == SystemRoleAssignment.account_id)
|
||||
.filter(Role.tenant_id.is_(None), Account.is_active.is_(True))
|
||||
.all()
|
||||
)
|
||||
return any(
|
||||
role.slug in {"system_owner", "system_admin"}
|
||||
or scopes_grant(role.permissions or (), "access:system_setting:write")
|
||||
for role in roles
|
||||
)
|
||||
|
||||
def create_first_system_administrator(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant: object,
|
||||
email: str,
|
||||
display_name: str | None,
|
||||
password: str,
|
||||
) -> FirstSystemAdministratorRef:
|
||||
db = _session(session)
|
||||
tenant_id = getattr(tenant, "id", None)
|
||||
if not tenant_id:
|
||||
raise FirstAdminProvisioningError(
|
||||
"First-administrator enrollment requires a persisted initial tenant."
|
||||
)
|
||||
if len(password) < 12:
|
||||
raise FirstAdminProvisioningError(
|
||||
"The administrator password must contain at least 12 characters."
|
||||
)
|
||||
if self.has_durable_system_administrator(db):
|
||||
raise FirstAdminProvisioningError(
|
||||
"A durable system administrator already exists."
|
||||
)
|
||||
|
||||
normalized_email = normalize_email(email)
|
||||
if not normalized_email or "@" not in normalized_email:
|
||||
raise FirstAdminProvisioningError("Enter a valid administrator email address.")
|
||||
existing = (
|
||||
db.query(Account)
|
||||
.filter(Account.normalized_email == normalized_email)
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
raise FirstAdminProvisioningError(
|
||||
"The enrollment email already belongs to an account. Use a new address for the first system owner."
|
||||
)
|
||||
|
||||
tenant_roles = ensure_default_roles(db, tenant) # type: ignore[arg-type]
|
||||
system_roles = ensure_default_roles(db, None)
|
||||
account, created, _temporary_password = get_or_create_account(
|
||||
db,
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
password=password,
|
||||
password_reset_required=False,
|
||||
)
|
||||
if not created:
|
||||
raise FirstAdminProvisioningError(
|
||||
"The enrollment email already belongs to an account."
|
||||
)
|
||||
membership = User(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=display_name or account.display_name,
|
||||
is_active=True,
|
||||
is_tenant_admin=True,
|
||||
auth_provider=account.auth_provider,
|
||||
password_hash=account.password_hash,
|
||||
)
|
||||
db.add(membership)
|
||||
db.flush()
|
||||
db.add(
|
||||
UserRoleAssignment(
|
||||
tenant_id=tenant_id,
|
||||
user_id=membership.id,
|
||||
role_id=tenant_roles["owner"].id,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
SystemRoleAssignment(
|
||||
account_id=account.id,
|
||||
role_id=system_roles["system_owner"].id,
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
return FirstSystemAdministratorRef(
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
membership_id=membership.id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
|
||||
def _session(session: object) -> Session:
|
||||
if not isinstance(session, Session):
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import (
|
||||
ApiKey,
|
||||
AuthSession,
|
||||
ExternalFunctionRoleAssignment,
|
||||
Function,
|
||||
FunctionAssignment,
|
||||
FunctionDelegation,
|
||||
FunctionRoleAssignment,
|
||||
Group,
|
||||
GroupRoleAssignment,
|
||||
OrganizationUnit,
|
||||
Role,
|
||||
ServiceAccount,
|
||||
User,
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_core.core.tenant_erasure import (
|
||||
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX,
|
||||
TenantErasurePreview,
|
||||
TenantErasureResource,
|
||||
TenantErasureStep,
|
||||
TenantErasureStepResult,
|
||||
)
|
||||
|
||||
|
||||
ACCESS_TENANT_ERASURE_CAPABILITY = (
|
||||
f"{TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX}access"
|
||||
)
|
||||
|
||||
_CREDENTIAL_MODELS = (AuthSession, ApiKey)
|
||||
_TENANT_ACCESS_MODELS = (
|
||||
ServiceAccount,
|
||||
FunctionDelegation,
|
||||
ExternalFunctionRoleAssignment,
|
||||
FunctionRoleAssignment,
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
GroupRoleAssignment,
|
||||
FunctionAssignment,
|
||||
Function,
|
||||
OrganizationUnit,
|
||||
User,
|
||||
Group,
|
||||
Role,
|
||||
)
|
||||
_ALL_MODELS = _CREDENTIAL_MODELS + _TENANT_ACCESS_MODELS
|
||||
|
||||
|
||||
def _counts(session: Session, tenant_id: str) -> dict[str, int]:
|
||||
return {
|
||||
model.__tablename__: session.query(model)
|
||||
.filter(model.tenant_id == tenant_id)
|
||||
.count()
|
||||
for model in _ALL_MODELS
|
||||
}
|
||||
|
||||
|
||||
def _delete_models(
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
models: tuple[type, ...],
|
||||
) -> int:
|
||||
deleted = 0
|
||||
for model in models:
|
||||
deleted += (
|
||||
session.query(model)
|
||||
.filter(model.tenant_id == tenant_id)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
return deleted
|
||||
|
||||
|
||||
class AccessTenantErasureProvider:
|
||||
module_id = "access"
|
||||
|
||||
def preview_tenant_erasure(
|
||||
self,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
) -> TenantErasurePreview:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("Access tenant erasure requires a database session.")
|
||||
counts = _counts(session, tenant_id)
|
||||
credential_count = sum(
|
||||
counts[model.__tablename__] for model in _CREDENTIAL_MODELS
|
||||
)
|
||||
access_count = sum(
|
||||
counts[model.__tablename__] for model in _TENANT_ACCESS_MODELS
|
||||
)
|
||||
resources = tuple(
|
||||
TenantErasureResource(
|
||||
resource_type=table_name,
|
||||
count=count,
|
||||
disposition="erase",
|
||||
summary=f"{count} tenant-scoped Access records will be erased.",
|
||||
)
|
||||
for table_name, count in sorted(counts.items())
|
||||
)
|
||||
steps: list[TenantErasureStep] = []
|
||||
if credential_count:
|
||||
steps.append(
|
||||
TenantErasureStep(
|
||||
step_id="revoke-tenant-credentials",
|
||||
kind="erase",
|
||||
summary="Revoke tenant sessions and erase tenant API keys.",
|
||||
destructive=True,
|
||||
irreversible=True,
|
||||
)
|
||||
)
|
||||
if access_count:
|
||||
steps.append(
|
||||
TenantErasureStep(
|
||||
step_id="erase-tenant-access",
|
||||
kind="erase",
|
||||
summary=(
|
||||
"Erase tenant memberships, service accounts, groups, roles, "
|
||||
"organization units, functions, assignments, and delegations."
|
||||
),
|
||||
destructive=True,
|
||||
irreversible=True,
|
||||
depends_on=(
|
||||
("revoke-tenant-credentials",) if credential_count else ()
|
||||
),
|
||||
)
|
||||
)
|
||||
return TenantErasurePreview(
|
||||
module_id=self.module_id,
|
||||
complete=True,
|
||||
resources=resources,
|
||||
steps=tuple(steps),
|
||||
warnings=(
|
||||
"Global accounts and identity links are retained because they may belong to other tenants.",
|
||||
),
|
||||
provider_revision="access-tenant-erasure-v1",
|
||||
)
|
||||
|
||||
def execute_tenant_erasure_step(
|
||||
self,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
step_id: str,
|
||||
idempotency_key: str,
|
||||
) -> TenantErasureStepResult:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("Access tenant erasure requires a database session.")
|
||||
if not idempotency_key.strip():
|
||||
raise ValueError("Access tenant erasure requires an idempotency key.")
|
||||
if step_id == "revoke-tenant-credentials":
|
||||
deleted = _delete_models(session, tenant_id, _CREDENTIAL_MODELS)
|
||||
summary = "Tenant sessions and API keys were erased."
|
||||
elif step_id == "erase-tenant-access":
|
||||
deleted = _delete_models(session, tenant_id, _TENANT_ACCESS_MODELS)
|
||||
summary = "Tenant-scoped Access records were erased."
|
||||
else:
|
||||
return TenantErasureStepResult(
|
||||
state="blocked",
|
||||
summary="Access tenant erasure step is unknown.",
|
||||
)
|
||||
return TenantErasureStepResult(
|
||||
state="completed",
|
||||
summary=summary,
|
||||
receipt_ref=f"access:tenant-erasure:{tenant_id}:{step_id}",
|
||||
metrics={"deleted": deleted},
|
||||
)
|
||||
|
||||
def reconcile_tenant_erasure_step(
|
||||
self,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
step_id: str,
|
||||
idempotency_key: str,
|
||||
) -> TenantErasureStepResult:
|
||||
return self.execute_tenant_erasure_step(
|
||||
session,
|
||||
tenant_id,
|
||||
step_id,
|
||||
idempotency_key,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ACCESS_TENANT_ERASURE_CAPABILITY",
|
||||
"AccessTenantErasureProvider",
|
||||
]
|
||||
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.administration import SqlAccessAdministration
|
||||
from govoplan_access.backend.api.v1.admin_common import (
|
||||
_accounts_by_user_id,
|
||||
_group_member_ids_by_group_id,
|
||||
_groups_by_user_id,
|
||||
_roles_by_group_id,
|
||||
_roles_by_user_id,
|
||||
_tenant_role_assignment_counts,
|
||||
)
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class AdminBatchHelperTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_access_admin_batch_maps_related_rows(self) -> None:
|
||||
session = self.session
|
||||
account = Account(id="account-1", email="ada@example.test", normalized_email="ada@example.test")
|
||||
user = User(id="user-1", tenant_id="tenant-1", account_id=account.id, email=account.email)
|
||||
group = Group(id="group-1", tenant_id="tenant-1", slug="clerks", name="Clerks")
|
||||
role = Role(id="role-1", tenant_id="tenant-1", slug="reader", name="Reader", permissions=["admin:users:read"])
|
||||
session.add_all([account, user, group, role])
|
||||
session.flush()
|
||||
session.add(UserGroupMembership(tenant_id="tenant-1", user_id=user.id, group_id=group.id))
|
||||
session.add(UserRoleAssignment(tenant_id="tenant-1", user_id=user.id, role_id=role.id))
|
||||
session.add(GroupRoleAssignment(tenant_id="tenant-1", group_id=group.id, role_id=role.id))
|
||||
session.commit()
|
||||
|
||||
accounts_by_user = _accounts_by_user_id(session, [user.id])
|
||||
group_member_ids = _group_member_ids_by_group_id(session, tenant_id="tenant-1", group_ids=[group.id])
|
||||
groups_by_user = _groups_by_user_id(session, tenant_id="tenant-1", user_ids=[user.id])
|
||||
roles_by_group = _roles_by_group_id(session, tenant_id="tenant-1", group_ids=[group.id])
|
||||
roles_by_user = _roles_by_user_id(session, tenant_id="tenant-1", user_ids=[user.id])
|
||||
role_counts = _tenant_role_assignment_counts(session, [role.id])
|
||||
|
||||
self.assertEqual(accounts_by_user[user.id].id, account.id)
|
||||
self.assertEqual(group_member_ids, {group.id: [user.id]})
|
||||
self.assertEqual([item.id for item in groups_by_user[user.id]], [group.id])
|
||||
self.assertEqual([item.id for item in roles_by_group[group.id]], [role.id])
|
||||
self.assertEqual([item.id for item in roles_by_user[user.id]], [role.id])
|
||||
self.assertEqual(role_counts, {role.id: (1, 1)})
|
||||
|
||||
def test_tenant_counts_many_uses_three_grouped_queries(self) -> None:
|
||||
accounts = [
|
||||
Account(
|
||||
id=f"account-{index}",
|
||||
email=f"user-{index}@example.test",
|
||||
normalized_email=f"user-{index}@example.test",
|
||||
)
|
||||
for index in range(3)
|
||||
]
|
||||
users = [
|
||||
User(
|
||||
id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id=accounts[0].id,
|
||||
email=accounts[0].email,
|
||||
),
|
||||
User(
|
||||
id="user-2",
|
||||
tenant_id="tenant-1",
|
||||
account_id=accounts[1].id,
|
||||
email=accounts[1].email,
|
||||
is_active=False,
|
||||
),
|
||||
User(
|
||||
id="user-3",
|
||||
tenant_id="tenant-2",
|
||||
account_id=accounts[2].id,
|
||||
email=accounts[2].email,
|
||||
),
|
||||
]
|
||||
self.session.add_all(
|
||||
[
|
||||
*accounts,
|
||||
*users,
|
||||
Group(id="group-1", tenant_id="tenant-1", slug="one", name="One"),
|
||||
Group(id="group-2", tenant_id="tenant-2", slug="two", name="Two"),
|
||||
ApiKey(
|
||||
id="key-1",
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
name="Active",
|
||||
prefix="active",
|
||||
key_hash="hash-1",
|
||||
),
|
||||
ApiKey(
|
||||
id="key-2",
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-2",
|
||||
name="Revoked",
|
||||
prefix="revoked",
|
||||
key_hash="hash-2",
|
||||
revoked_at=datetime.now(UTC),
|
||||
),
|
||||
]
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
query_count = 0
|
||||
|
||||
def count_query(*_args: object) -> None:
|
||||
nonlocal query_count
|
||||
query_count += 1
|
||||
|
||||
event.listen(self.engine, "before_cursor_execute", count_query)
|
||||
try:
|
||||
counts = SqlAccessAdministration().tenant_counts_many(
|
||||
self.session,
|
||||
["tenant-1", "tenant-2", "tenant-empty"],
|
||||
)
|
||||
finally:
|
||||
event.remove(self.engine, "before_cursor_execute", count_query)
|
||||
|
||||
self.assertEqual(3, query_count)
|
||||
self.assertEqual(
|
||||
{
|
||||
"users": 2,
|
||||
"active_users": 1,
|
||||
"groups": 1,
|
||||
"api_keys": 2,
|
||||
"active_api_keys": 1,
|
||||
},
|
||||
counts["tenant-1"],
|
||||
)
|
||||
self.assertEqual(1, counts["tenant-2"]["users"])
|
||||
self.assertEqual(0, counts["tenant-empty"]["users"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_access.backend.api.v1.routes import (
|
||||
_decode_full_delta_cursor,
|
||||
_encode_full_delta_cursor,
|
||||
_full_delta_page,
|
||||
_page_query,
|
||||
)
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, rows: list[int]) -> None:
|
||||
self._rows = rows
|
||||
self._offset = 0
|
||||
self._limit: int | None = None
|
||||
|
||||
def order_by(self, *_args: object) -> FakeQuery:
|
||||
return self
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self._rows)
|
||||
|
||||
def offset(self, value: int) -> FakeQuery:
|
||||
clone = FakeQuery(self._rows)
|
||||
clone._offset = value
|
||||
clone._limit = self._limit
|
||||
return clone
|
||||
|
||||
def limit(self, value: int) -> FakeQuery:
|
||||
clone = FakeQuery(self._rows)
|
||||
clone._offset = self._offset
|
||||
clone._limit = value
|
||||
return clone
|
||||
|
||||
def all(self) -> list[int]:
|
||||
end = None if self._limit is None else self._offset + self._limit
|
||||
return self._rows[self._offset:end]
|
||||
|
||||
|
||||
class AdminPaginationTests(unittest.TestCase):
|
||||
def test_page_query_returns_page_and_metadata(self) -> None:
|
||||
rows, metadata = _page_query(FakeQuery(list(range(7))), page=2, page_size=3)
|
||||
|
||||
self.assertEqual(rows, [3, 4, 5])
|
||||
self.assertEqual(metadata, {"total": 7, "page": 2, "page_size": 3, "pages": 3})
|
||||
|
||||
def test_full_delta_page_returns_cursor_until_last_page(self) -> None:
|
||||
rows, metadata, watermark, has_more = _full_delta_page(
|
||||
FakeQuery(list(range(7))),
|
||||
page=2,
|
||||
page_size=3,
|
||||
scope="users",
|
||||
snapshot_sequence=42,
|
||||
)
|
||||
|
||||
self.assertEqual(rows, [3, 4, 5])
|
||||
self.assertEqual(metadata, {"total": 7, "page": 2, "page_size": 3, "pages": 3})
|
||||
self.assertEqual(watermark, "full:users:3:42")
|
||||
self.assertTrue(has_more)
|
||||
|
||||
def test_full_delta_page_returns_sequence_watermark_on_last_page(self) -> None:
|
||||
rows, metadata, watermark, has_more = _full_delta_page(
|
||||
FakeQuery(list(range(7))),
|
||||
page=3,
|
||||
page_size=3,
|
||||
scope="users",
|
||||
snapshot_sequence=42,
|
||||
)
|
||||
|
||||
self.assertEqual(rows, [6])
|
||||
self.assertEqual(metadata, {"total": 7, "page": 3, "page_size": 3, "pages": 3})
|
||||
self.assertEqual(watermark, "seq:42")
|
||||
self.assertFalse(has_more)
|
||||
|
||||
def test_full_delta_cursor_round_trips_and_rejects_wrong_scope(self) -> None:
|
||||
cursor = _encode_full_delta_cursor("users", page=3, snapshot_sequence=42)
|
||||
|
||||
self.assertEqual(_decode_full_delta_cursor(cursor, scope="users"), (3, 42))
|
||||
self.assertIsNone(_decode_full_delta_cursor("seq:42", scope="users"))
|
||||
with self.assertRaises(HTTPException):
|
||||
_decode_full_delta_cursor(cursor, scope="groups")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest import TestCase
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||
from govoplan_access.backend.security.sessions import authenticate_session_token
|
||||
|
||||
|
||||
class AuthenticationActivityTouchTests(TestCase):
|
||||
def test_session_activity_is_touched_only_after_the_interval(self) -> None:
|
||||
now = datetime(2026, 7, 29, 10, 0, tzinfo=timezone.utc)
|
||||
for age_seconds, should_touch in ((60, False), (301, True)):
|
||||
with self.subTest(age_seconds=age_seconds):
|
||||
model = SimpleNamespace(
|
||||
expires_at=now + timedelta(hours=1),
|
||||
last_seen_at=now - timedelta(seconds=age_seconds),
|
||||
)
|
||||
session = MagicMock()
|
||||
(
|
||||
session.query.return_value.options.return_value
|
||||
.filter.return_value.one_or_none
|
||||
).return_value = model
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_access.backend.security.sessions.hash_session_token",
|
||||
return_value="hashed",
|
||||
),
|
||||
patch(
|
||||
"govoplan_access.backend.security.sessions.utc_now",
|
||||
return_value=now,
|
||||
),
|
||||
):
|
||||
result = authenticate_session_token(
|
||||
session,
|
||||
"token",
|
||||
touch_interval_seconds=300,
|
||||
)
|
||||
|
||||
self.assertIs(model, result)
|
||||
if should_touch:
|
||||
self.assertEqual(now, model.last_seen_at)
|
||||
session.add.assert_called_once_with(model)
|
||||
else:
|
||||
self.assertEqual(
|
||||
now - timedelta(seconds=age_seconds),
|
||||
model.last_seen_at,
|
||||
)
|
||||
session.add.assert_not_called()
|
||||
|
||||
def test_api_key_activity_is_touched_only_after_the_interval(self) -> None:
|
||||
now = datetime(2026, 7, 29, 10, 0, tzinfo=timezone.utc)
|
||||
for age_seconds, should_touch in ((60, False), (301, True)):
|
||||
with self.subTest(age_seconds=age_seconds):
|
||||
model = SimpleNamespace(
|
||||
expires_at=None,
|
||||
last_used_at=now - timedelta(seconds=age_seconds),
|
||||
key_hash="hashed",
|
||||
)
|
||||
session = MagicMock()
|
||||
(
|
||||
session.query.return_value.options.return_value
|
||||
.filter.return_value.all
|
||||
).return_value = [model]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_access.backend.security.api_keys.verify_api_key",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"govoplan_access.backend.security.api_keys.utc_now",
|
||||
return_value=now,
|
||||
),
|
||||
):
|
||||
result = authenticate_api_key(
|
||||
session,
|
||||
"mm_test-token",
|
||||
touch_interval_seconds=300,
|
||||
)
|
||||
|
||||
self.assertIs(model, result)
|
||||
if should_touch:
|
||||
self.assertEqual(now, model.last_used_at)
|
||||
session.add.assert_called_once_with(model)
|
||||
else:
|
||||
self.assertEqual(
|
||||
now - timedelta(seconds=age_seconds),
|
||||
model.last_used_at,
|
||||
)
|
||||
session.add.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import unittest
|
||||
|
||||
unittest.main()
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from starlette.requests import Request
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import _resolve_legacy_principal_context
|
||||
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||
from govoplan_access.backend.auth.tokens import hash_secret
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import Account, AuthSession, Role, ServiceAccount, User, UserRoleAssignment
|
||||
from govoplan_access.backend.security.api_keys import create_api_key
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||
|
||||
|
||||
class AuthCacheSecurityTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
principal_summary_cache.clear()
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
AccessBase.metadata.create_all(self.engine)
|
||||
self.revision_tables = [ChangeSequenceEntry.__table__, ChangeSequenceRetentionFloor.__table__]
|
||||
Base.metadata.create_all(self.engine, tables=self.revision_tables)
|
||||
self.session = sessionmaker(bind=self.engine)()
|
||||
self.cache_setting = patch.object(settings, "auth_principal_cache_enabled", True)
|
||||
self.cache_setting.start()
|
||||
self.tenant = Tenant(id="cache-tenant", slug="cache-tenant", name="Cache tenant")
|
||||
self.session.add(self.tenant)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.cache_setting.stop()
|
||||
principal_summary_cache.clear()
|
||||
self.session.close()
|
||||
AccessBase.metadata.drop_all(self.engine)
|
||||
scope_registry.metadata.drop_all(self.engine)
|
||||
Base.metadata.drop_all(self.engine, tables=self.revision_tables)
|
||||
self.engine.dispose()
|
||||
|
||||
def identity(self, *, service: bool = False) -> tuple[Account, User]:
|
||||
account = Account(id="cache-account", email="cache@example.test", normalized_email="cache@example.test", auth_provider="service_account" if service else "local")
|
||||
user = User(id="cache-user", tenant_id=self.tenant.id, account_id=account.id, email=account.email, auth_provider=account.auth_provider)
|
||||
role = Role(id="cache-role", tenant_id=self.tenant.id, slug="reader", name="Reader", permissions=["files:file:read"])
|
||||
assignment = UserRoleAssignment(tenant_id=self.tenant.id, user_id=user.id, role_id=role.id)
|
||||
self.session.add_all([account, user, role, assignment])
|
||||
self.session.commit()
|
||||
return account, user
|
||||
|
||||
def resolve(self, token: str, *, cookie: bool = False, csrf: str | None = None):
|
||||
headers = []
|
||||
if cookie:
|
||||
cookies = f"{settings.auth_session_cookie_name}={token}"
|
||||
if csrf is not None:
|
||||
cookies += f"; {settings.auth_csrf_cookie_name}={csrf}"
|
||||
headers.append((b"x-csrf-token", csrf.encode()))
|
||||
headers.append((b"cookie", cookies.encode()))
|
||||
request = Request({"type": "http", "method": "POST", "path": "/protected", "headers": headers})
|
||||
return _resolve_legacy_principal_context(request, self.session, authorization=None if cookie else f"Bearer {token}", x_api_key=None)
|
||||
|
||||
def test_warmed_api_key_is_never_accepted_as_a_session_cookie(self) -> None:
|
||||
_, user = self.identity()
|
||||
key = create_api_key(self.session, user=user, name="Test", scopes=["files:file:read"])
|
||||
self.session.commit()
|
||||
with self.assertRaises(HTTPException) as cold:
|
||||
self.resolve(key.secret, cookie=True)
|
||||
self.assertEqual(401, cold.exception.status_code)
|
||||
self.assertEqual("api_key", self.resolve(key.secret).principal.auth_method)
|
||||
with self.assertRaises(HTTPException) as warm:
|
||||
self.resolve(key.secret, cookie=True)
|
||||
self.assertEqual(401, warm.exception.status_code)
|
||||
self.assertEqual("api_key", self.resolve(key.secret).principal.auth_method)
|
||||
|
||||
def test_service_account_keeps_current_ceiling_and_provenance_with_cache_enabled(self) -> None:
|
||||
account, user = self.identity(service=True)
|
||||
item = ServiceAccount(id="cache-service", tenant_id=self.tenant.id, account_id=account.id, membership_id=user.id, name="Cache worker", normalized_name="cache worker", scope_ceiling=["dataflow:pipeline:run"])
|
||||
# A credential issued before a ceiling reduction can retain wider stored
|
||||
# scopes. Ordinary membership roles must not override the current ceiling.
|
||||
key = create_api_key(self.session, user=user, name="Worker", scopes=["dataflow:pipeline:run", "files:file:read"])
|
||||
self.session.add(item)
|
||||
self.session.commit()
|
||||
for _ in range(2):
|
||||
context = self.resolve(key.secret)
|
||||
self.assertEqual(frozenset({"dataflow:pipeline:run"}), context.principal.scopes)
|
||||
self.assertEqual("service_account", context.principal.auth_method)
|
||||
self.assertEqual(item.id, context.principal.service_account_id)
|
||||
self.assertFalse(context.principal.role_ids)
|
||||
item.scope_ceiling = []
|
||||
self.session.commit()
|
||||
self.assertEqual(frozenset(), self.resolve(key.secret).principal.scopes)
|
||||
item.is_active = False
|
||||
self.session.commit()
|
||||
with self.assertRaises(HTTPException) as inactive:
|
||||
self.resolve(key.secret)
|
||||
self.assertEqual(401, inactive.exception.status_code)
|
||||
|
||||
def test_warmed_session_cookie_still_requires_matching_csrf(self) -> None:
|
||||
account, user = self.identity()
|
||||
token, csrf = "ms_cache-session", "cache-csrf"
|
||||
auth_session = AuthSession(id="cache-session", tenant_id=self.tenant.id, user_id=user.id, account_id=account.id, token_hash=hash_secret(token), csrf_token_hash=hash_secret(csrf), expires_at=utc_now() + timedelta(hours=1))
|
||||
self.session.add(auth_session)
|
||||
self.session.commit()
|
||||
self.resolve(token)
|
||||
for supplied in (None, "incorrect"):
|
||||
with self.subTest(csrf=supplied), self.assertRaises(HTTPException) as denied:
|
||||
self.resolve(token, cookie=True, csrf=supplied)
|
||||
self.assertEqual(403, denied.exception.status_code)
|
||||
self.assertEqual("session", self.resolve(token, cookie=True, csrf=csrf).principal.auth_method)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,265 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from typing import Iterable
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from starlette.requests import Request
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import (
|
||||
_extract_token,
|
||||
_principal_idm_context,
|
||||
_requires_csrf,
|
||||
_resolve_legacy_principal_context,
|
||||
_resolve_legacy_principal_ref,
|
||||
)
|
||||
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||
from govoplan_access.backend.auth.tokens import hash_secret
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import Account, AuthSession, Role, User, UserRoleAssignment
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||
|
||||
|
||||
def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ()) -> Request:
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": method,
|
||||
"path": "/",
|
||||
"headers": [(key.lower().encode("latin-1"), value.encode("latin-1")) for key, value in headers],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AuthDependencyTests(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
principal_summary_cache.clear()
|
||||
|
||||
def test_extract_token_prefers_explicit_api_key(self) -> None:
|
||||
request = request_for(headers=[("authorization", "Bearer session-token")])
|
||||
|
||||
self.assertEqual(_extract_token(request, "Bearer session-token", "api-key-token"), ("api-key-token", "api_key"))
|
||||
|
||||
def test_extract_token_supports_bearer_and_cookie_sources(self) -> None:
|
||||
self.assertEqual(_extract_token(request_for(), "Bearer session-token", None), ("session-token", "bearer"))
|
||||
|
||||
cookie_request = request_for(headers=[("cookie", f"{settings.auth_session_cookie_name}=cookie-token")])
|
||||
self.assertEqual(_extract_token(cookie_request, None, None), ("cookie-token", "cookie"))
|
||||
|
||||
def test_requires_csrf_only_for_mutating_methods(self) -> None:
|
||||
self.assertFalse(_requires_csrf(request_for(method="GET")))
|
||||
self.assertTrue(_requires_csrf(request_for(method="POST")))
|
||||
|
||||
def test_legacy_principal_resolver_rejects_missing_token_before_db_lookup(self) -> None:
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
_resolve_legacy_principal_ref(request_for(), None, authorization=None, x_api_key=None) # type: ignore[arg-type]
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 401)
|
||||
self.assertEqual(raised.exception.detail, "Missing API key or session token")
|
||||
|
||||
def test_permission_revision_invalidates_cached_principal(self) -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(engine)
|
||||
AccessBase.metadata.create_all(bind=engine)
|
||||
Base.metadata.create_all(
|
||||
bind=engine,
|
||||
tables=[
|
||||
ChangeSequenceEntry.__table__,
|
||||
ChangeSequenceRetentionFloor.__table__,
|
||||
],
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
try:
|
||||
with SessionLocal() as session:
|
||||
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||
account = Account(
|
||||
id="account-1",
|
||||
email="owner@example.test",
|
||||
normalized_email="owner@example.test",
|
||||
)
|
||||
user = User(
|
||||
id="user-1",
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
)
|
||||
role = Role(
|
||||
id="role-1",
|
||||
tenant_id=tenant.id,
|
||||
slug="reader",
|
||||
name="Reader",
|
||||
permissions=["files:file:read"],
|
||||
)
|
||||
assignment = UserRoleAssignment(
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
)
|
||||
token = "ms_test-session-token"
|
||||
auth_session = AuthSession(
|
||||
id="session-1",
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
token_hash=hash_secret(token),
|
||||
expires_at=utc_now() + timedelta(hours=1),
|
||||
)
|
||||
session.add_all(
|
||||
[tenant, account, user, role, assignment, auth_session]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
request = request_for()
|
||||
first = _resolve_legacy_principal_context(
|
||||
request,
|
||||
session,
|
||||
authorization=f"Bearer {token}",
|
||||
x_api_key=None,
|
||||
)
|
||||
self.assertIn("files:file:read", first.principal.scopes)
|
||||
|
||||
role.permissions = ["files:file:write"]
|
||||
session.add(role)
|
||||
invalidate_auth_principals(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
source_module="access",
|
||||
resource_type="role",
|
||||
resource_id=role.id,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
second = _resolve_legacy_principal_context(
|
||||
request,
|
||||
session,
|
||||
authorization=f"Bearer {token}",
|
||||
x_api_key=None,
|
||||
)
|
||||
self.assertNotIn("files:file:read", second.principal.scopes)
|
||||
self.assertIn("files:file:write", second.principal.scopes)
|
||||
finally:
|
||||
AccessBase.metadata.drop_all(bind=engine)
|
||||
scope_registry.metadata.drop_all(bind=engine)
|
||||
Base.metadata.drop_all(
|
||||
bind=engine,
|
||||
tables=[
|
||||
ChangeSequenceEntry.__table__,
|
||||
ChangeSequenceRetentionFloor.__table__,
|
||||
],
|
||||
)
|
||||
engine.dispose()
|
||||
|
||||
def test_acting_assignment_requires_exact_session_selection(self) -> None:
|
||||
class Directory:
|
||||
def organization_function_assignments_for_account(
|
||||
self,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at=None,
|
||||
):
|
||||
del account_id, effective_at
|
||||
return (
|
||||
OrganizationFunctionAssignmentRef(
|
||||
id="direct-1",
|
||||
tenant_id=str(tenant_id),
|
||||
identity_id="identity-1",
|
||||
account_id="account-1",
|
||||
function_id="function-direct",
|
||||
organization_unit_id="unit-1",
|
||||
),
|
||||
OrganizationFunctionAssignmentRef(
|
||||
id="acting-1",
|
||||
tenant_id=str(tenant_id),
|
||||
identity_id="identity-1",
|
||||
account_id="account-1",
|
||||
function_id="function-acting",
|
||||
organization_unit_id="unit-1",
|
||||
source="acting_for",
|
||||
acting_for_account_id="represented-1",
|
||||
),
|
||||
)
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(engine)
|
||||
AccessBase.metadata.create_all(bind=engine)
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
try:
|
||||
with SessionLocal() as session:
|
||||
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||
account = Account(
|
||||
id="account-1",
|
||||
email="actor@example.test",
|
||||
normalized_email="actor@example.test",
|
||||
)
|
||||
user = User(
|
||||
id="user-1",
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
)
|
||||
auth_session = AuthSession(
|
||||
id="session-1",
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
token_hash="token-hash",
|
||||
expires_at=utc_now() + timedelta(hours=1),
|
||||
)
|
||||
session.add_all((tenant, account, user, auth_session))
|
||||
session.flush()
|
||||
|
||||
ordinary, _ = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=tenant.id,
|
||||
idm_directory=Directory(), # type: ignore[arg-type]
|
||||
organization_directory=None,
|
||||
)
|
||||
self.assertEqual([item.id for item in ordinary], ["direct-1"])
|
||||
|
||||
auth_session.acting_assignment_id = "acting-1"
|
||||
auth_session.acting_for_account_id = "represented-1"
|
||||
selected, _ = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=tenant.id,
|
||||
idm_directory=Directory(), # type: ignore[arg-type]
|
||||
organization_directory=None,
|
||||
auth_session=auth_session,
|
||||
)
|
||||
self.assertEqual(
|
||||
[item.id for item in selected],
|
||||
["direct-1", "acting-1"],
|
||||
)
|
||||
|
||||
auth_session.acting_for_account_id = "wrong-account"
|
||||
mismatched, _ = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=tenant.id,
|
||||
idm_directory=Directory(), # type: ignore[arg-type]
|
||||
organization_directory=None,
|
||||
auth_session=auth_session,
|
||||
)
|
||||
self.assertEqual([item.id for item in mismatched], ["direct-1"])
|
||||
finally:
|
||||
AccessBase.metadata.drop_all(bind=engine)
|
||||
scope_registry.metadata.drop_all(bind=engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,295 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import (
|
||||
AccessAutomationPrincipalProvider,
|
||||
)
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ServiceAccount,
|
||||
User,
|
||||
)
|
||||
from govoplan_access.backend.manifest import manifest
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
UserAuthorizationContext,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
)
|
||||
from govoplan_core.core.automation import AutomationPrincipalRequest
|
||||
from govoplan_core.tenancy.scope import (
|
||||
Tenant,
|
||||
create_scope_tables,
|
||||
scope_registry,
|
||||
)
|
||||
|
||||
|
||||
class AutomationPrincipalTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
AccessBase.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.account = Account(
|
||||
id="account-1",
|
||||
email="owner@example.test",
|
||||
normalized_email="owner@example.test",
|
||||
)
|
||||
self.tenant = Tenant(
|
||||
id="tenant-1",
|
||||
slug="tenant-1",
|
||||
name="Tenant 1",
|
||||
)
|
||||
self.user = User(
|
||||
id="user-1",
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=self.account.id,
|
||||
email=self.account.email,
|
||||
)
|
||||
self.session.add_all([self.tenant, self.account, self.user])
|
||||
self.session.commit()
|
||||
self.provider = AccessAutomationPrincipalProvider()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
AccessBase.metadata.drop_all(bind=self.engine)
|
||||
scope_registry.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def _request(self) -> AutomationPrincipalRequest:
|
||||
return AutomationPrincipalRequest(
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=self.account.id,
|
||||
membership_id=self.user.id,
|
||||
authorization_ref="dataflow-trigger:1",
|
||||
grant_scopes=(
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
),
|
||||
context={
|
||||
"trigger_ref": "dataflow-trigger:1",
|
||||
"delivery_ref": "dataflow-delivery:1",
|
||||
"event_actor": {
|
||||
"type": "user",
|
||||
"id": "event-user-1",
|
||||
},
|
||||
"operator_override": {
|
||||
"type": "user",
|
||||
"id": "operator-1",
|
||||
"reason": "approved replay",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def test_resolution_intersects_trigger_grant_with_current_scopes(self) -> None:
|
||||
context = UserAuthorizationContext(
|
||||
tenant_roles=[],
|
||||
system_roles=[],
|
||||
groups=[],
|
||||
function_assignment_ids=(),
|
||||
function_delegation_ids=(),
|
||||
scopes=[
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
"system:settings:write",
|
||||
],
|
||||
)
|
||||
with patch(
|
||||
"govoplan_access.backend.auth.dependencies."
|
||||
"collect_user_authorization_context",
|
||||
return_value=context,
|
||||
):
|
||||
result = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=self._request(),
|
||||
)
|
||||
|
||||
self.assertTrue(result.allowed)
|
||||
self.assertIsInstance(result.principal, ApiPrincipal)
|
||||
self.assertEqual(
|
||||
frozenset(
|
||||
{
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
}
|
||||
),
|
||||
result.principal.scopes,
|
||||
)
|
||||
self.assertIsNone(
|
||||
result.principal.principal.service_account_id
|
||||
)
|
||||
self.assertEqual(
|
||||
self.account.id,
|
||||
result.principal.principal.acting_for_account_id,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"system:settings:write",
|
||||
result.principal.scopes,
|
||||
)
|
||||
self.assertEqual(
|
||||
"delegated_user",
|
||||
result.provenance["trigger_owner"]["kind"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"event-user-1",
|
||||
result.provenance["event_actor"]["id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"operator-1",
|
||||
result.provenance["operator_override"]["id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.account.id,
|
||||
result.provenance[
|
||||
"current_automation_principal"
|
||||
]["account_id"],
|
||||
)
|
||||
|
||||
def test_revoked_scope_and_suspended_owner_fail_closed(self) -> None:
|
||||
context = UserAuthorizationContext(
|
||||
tenant_roles=[],
|
||||
system_roles=[],
|
||||
groups=[],
|
||||
function_assignment_ids=(),
|
||||
function_delegation_ids=(),
|
||||
scopes=["dataflow:pipeline:run"],
|
||||
)
|
||||
with patch(
|
||||
"govoplan_access.backend.auth.dependencies."
|
||||
"collect_user_authorization_context",
|
||||
return_value=context,
|
||||
):
|
||||
result = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=self._request(),
|
||||
)
|
||||
self.assertFalse(result.allowed)
|
||||
self.assertEqual(
|
||||
("datasources:catalogue:read",),
|
||||
result.missing_scopes,
|
||||
)
|
||||
|
||||
self.account.is_active = False
|
||||
self.session.flush()
|
||||
suspended = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=self._request(),
|
||||
)
|
||||
self.assertFalse(suspended.allowed)
|
||||
self.assertEqual(
|
||||
"inactive_or_inconsistent",
|
||||
suspended.provenance["status"],
|
||||
)
|
||||
|
||||
def test_service_account_resolution_uses_current_scope_ceiling(self) -> None:
|
||||
account = Account(
|
||||
id="service-account-backing",
|
||||
email="service@example.invalid",
|
||||
normalized_email="service@example.invalid",
|
||||
display_name="Import worker",
|
||||
auth_provider="service_account",
|
||||
)
|
||||
membership = User(
|
||||
id="service-membership",
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
auth_provider="service_account",
|
||||
)
|
||||
service_account = ServiceAccount(
|
||||
id="service-1",
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=account.id,
|
||||
membership_id=membership.id,
|
||||
name="Import worker",
|
||||
normalized_name="import worker",
|
||||
scope_ceiling=[
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
"system:settings:write",
|
||||
],
|
||||
is_active=True,
|
||||
revision=1,
|
||||
settings={},
|
||||
)
|
||||
self.session.add_all(
|
||||
(account, membership, service_account)
|
||||
)
|
||||
self.session.flush()
|
||||
request = AutomationPrincipalRequest.service_account(
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=service_account.id,
|
||||
authorization_ref="dataflow-trigger:service",
|
||||
grant_scopes=(
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
),
|
||||
)
|
||||
|
||||
result = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=request,
|
||||
)
|
||||
|
||||
self.assertTrue(result.allowed)
|
||||
self.assertEqual(
|
||||
service_account.id,
|
||||
result.principal.principal.service_account_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
frozenset(request.grant_scopes),
|
||||
result.principal.scopes,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"system:settings:write",
|
||||
result.principal.scopes,
|
||||
)
|
||||
self.assertEqual(
|
||||
"service_account",
|
||||
result.provenance["trigger_owner"]["kind"],
|
||||
)
|
||||
|
||||
service_account.scope_ceiling = [
|
||||
"dataflow:pipeline:run"
|
||||
]
|
||||
self.session.flush()
|
||||
reduced = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=request,
|
||||
)
|
||||
self.assertFalse(reduced.allowed)
|
||||
self.assertEqual(
|
||||
("datasources:catalogue:read",),
|
||||
reduced.missing_scopes,
|
||||
)
|
||||
|
||||
service_account.is_active = False
|
||||
self.session.flush()
|
||||
inactive = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=request,
|
||||
)
|
||||
self.assertFalse(inactive.allowed)
|
||||
self.assertEqual(
|
||||
"inactive_or_inconsistent",
|
||||
inactive.provenance["status"],
|
||||
)
|
||||
|
||||
def test_manifest_registers_automation_resolution(self) -> None:
|
||||
self.assertIn(
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
manifest.capability_factories,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
ConfigurationPackageApplyResponse,
|
||||
ConfigurationPackageExportResponse,
|
||||
)
|
||||
from govoplan_access.backend.api.v1.routes import _configuration_context
|
||||
from govoplan_core.core.infrastructure_capabilities import (
|
||||
InfrastructureCapabilityReceiptError,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ExternalProviderRuntimeState,
|
||||
ExternalProviderStateProviderRegistration,
|
||||
)
|
||||
|
||||
|
||||
class ConfigurationPackageContextTests(unittest.TestCase):
|
||||
def test_api_responses_preserve_rollback_and_redacted_export_provenance(self) -> None:
|
||||
applied = ConfigurationPackageApplyResponse(
|
||||
rollback={
|
||||
"status": "database_restore_required",
|
||||
"summary": "Snapshot is the generic rollback boundary.",
|
||||
"recovery_action": "Retain the snapshot.",
|
||||
}
|
||||
)
|
||||
exported = ConfigurationPackageExportResponse(
|
||||
provenance={
|
||||
"exported_at": "2026-08-22T12:00:00+00:00",
|
||||
"source_core_version": "0.1.35",
|
||||
"module_versions": {"forms": "0.1.20"},
|
||||
"tenant_id": "tenant-1",
|
||||
"exporter_id": "user-1",
|
||||
"selection": {
|
||||
"scopes": ["tenant"],
|
||||
"module_ids": ["forms"],
|
||||
"object_refs": [],
|
||||
},
|
||||
"redacted_secret_keys": ["credential_ref"],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
"database_restore_required",
|
||||
applied.model_dump()["rollback"]["status"],
|
||||
)
|
||||
self.assertEqual(
|
||||
["credential_ref"],
|
||||
exported.model_dump()["provenance"]["redacted_secret_keys"],
|
||||
)
|
||||
|
||||
def test_context_carries_operator_scopes_and_validated_infrastructure_receipt(self) -> None:
|
||||
receipt = SimpleNamespace(installation_id="deployment-1")
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
scopes=frozenset({"system:settings:write"}),
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"govoplan_access.backend.api.v1.routes.load_infrastructure_capability_receipt",
|
||||
return_value=receipt,
|
||||
),
|
||||
):
|
||||
context = _configuration_context(principal)
|
||||
|
||||
self.assertIs(receipt, context.infrastructure_receipt)
|
||||
self.assertEqual(
|
||||
frozenset({"system:settings:write"}),
|
||||
context.operator_scopes,
|
||||
)
|
||||
|
||||
def test_context_preserves_invalid_receipt_as_fail_closed_provider_state(self) -> None:
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
scopes=frozenset(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"govoplan_access.backend.api.v1.routes.load_infrastructure_capability_receipt",
|
||||
side_effect=InfrastructureCapabilityReceiptError("invalid receipt"),
|
||||
),
|
||||
):
|
||||
context = _configuration_context(principal)
|
||||
|
||||
self.assertIsNone(context.infrastructure_receipt)
|
||||
self.assertEqual("invalid receipt", context.infrastructure_receipt_error)
|
||||
|
||||
def test_context_projects_installed_external_provider_declarations(self) -> None:
|
||||
declaration = SimpleNamespace(
|
||||
id="connectors.example",
|
||||
to_dict=lambda: {
|
||||
"id": "connectors.example",
|
||||
"maturity": "read",
|
||||
"authority_modes": ["external_mirror"],
|
||||
},
|
||||
)
|
||||
registry = SimpleNamespace(
|
||||
manifests=lambda: (
|
||||
SimpleNamespace(id="access", version="0.1.14"),
|
||||
SimpleNamespace(id="connectors", version="0.1.14"),
|
||||
),
|
||||
capability_names=lambda: ("connectors.profiles",),
|
||||
external_provider_declarations=lambda: (declaration,),
|
||||
)
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||
return_value=registry,
|
||||
):
|
||||
context = _configuration_context(principal)
|
||||
|
||||
self.assertEqual("0.1.14", context.installed_modules["connectors"])
|
||||
self.assertIn("connectors.profiles", context.capabilities)
|
||||
self.assertEqual(
|
||||
"external_mirror",
|
||||
context.external_provider_declarations["connectors.example"][
|
||||
"authority_modes"
|
||||
][0],
|
||||
)
|
||||
|
||||
def test_context_projects_tenant_runtime_provider_state(self) -> None:
|
||||
declaration = SimpleNamespace(
|
||||
id="calendar.caldav_sync",
|
||||
to_dict=lambda: {
|
||||
"id": "calendar.caldav_sync",
|
||||
"maturity": "synchronize",
|
||||
"authority_modes": ["governed_sync"],
|
||||
},
|
||||
)
|
||||
registration = ExternalProviderStateProviderRegistration(
|
||||
module_id="calendar",
|
||||
provider_id="calendar.caldav_sync",
|
||||
provider=lambda context: (
|
||||
ExternalProviderRuntimeState(
|
||||
provider_id="calendar.caldav_sync",
|
||||
binding_ref="calendar:sync-source:one",
|
||||
authority_mode="governed_sync",
|
||||
observed_at=datetime(2026, 8, 1, 12, 0, tzinfo=UTC),
|
||||
configured=True,
|
||||
active=True,
|
||||
health="healthy",
|
||||
freshness="current",
|
||||
conflict="clear",
|
||||
recovery="ready",
|
||||
metrics={"tenant_matches": context.tenant_id == "tenant-1"},
|
||||
),
|
||||
),
|
||||
)
|
||||
registry = SimpleNamespace(
|
||||
manifests=lambda: (SimpleNamespace(id="calendar", version="0.1.8"),),
|
||||
capability_names=lambda: (),
|
||||
external_provider_declarations=lambda: (declaration,),
|
||||
external_provider_state_providers=lambda: (registration,),
|
||||
)
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||
return_value=registry,
|
||||
):
|
||||
context = _configuration_context(principal, session=object())
|
||||
|
||||
state = context.external_provider_states["calendar.caldav_sync"]
|
||||
self.assertEqual("healthy", state["health"])
|
||||
self.assertEqual("calendar:sync-source:one", state["binding_ref"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.admin.service import ensure_default_roles, set_user_roles
|
||||
from govoplan_access.backend.db.models import Account, Role, User, UserRoleAssignment
|
||||
from govoplan_access.backend.security.sessions import collect_user_authorization_context
|
||||
from govoplan_core.core.modules import RoleTemplate
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||
|
||||
|
||||
DOCS_READER = RoleTemplate(
|
||||
slug="docs_reader",
|
||||
name="Documentation reader",
|
||||
description="Authenticated documentation baseline.",
|
||||
permissions=("docs:documentation:read",),
|
||||
default_authenticated=True,
|
||||
)
|
||||
|
||||
|
||||
class DefaultAuthenticatedRoleTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||
self.session.add(self.tenant)
|
||||
self.session.flush()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
scope_registry.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def _add_user(self, suffix: str) -> User:
|
||||
account = Account(
|
||||
id=f"account-{suffix}",
|
||||
email=f"{suffix}@example.test",
|
||||
normalized_email=f"{suffix}@example.test",
|
||||
)
|
||||
user = User(
|
||||
id=f"user-{suffix}",
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
)
|
||||
self.session.add_all([account, user])
|
||||
self.session.flush()
|
||||
return user
|
||||
|
||||
def test_materialized_default_role_is_implicit_and_cannot_be_assigned_or_removed(self) -> None:
|
||||
first = self._add_user("first")
|
||||
self._add_user("second")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_access.backend.admin.service.role_templates_for_level",
|
||||
return_value=(DOCS_READER,),
|
||||
),
|
||||
patch(
|
||||
"govoplan_access.backend.security.sessions.role_templates_for_level",
|
||||
return_value=(DOCS_READER,),
|
||||
),
|
||||
):
|
||||
roles = ensure_default_roles(self.session, self.tenant)
|
||||
default_role = roles["docs_reader"]
|
||||
self.assertFalse(default_role.is_assignable)
|
||||
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
|
||||
|
||||
# The database row is only an administration projection. Even if
|
||||
# stale or tampered, it must never broaden the manifest baseline.
|
||||
default_role.permissions = ["mail:profile:write"]
|
||||
self.session.flush()
|
||||
|
||||
set_user_roles(self.session, user=first, role_ids=[])
|
||||
context = collect_user_authorization_context(
|
||||
self.session,
|
||||
first,
|
||||
account=first.account,
|
||||
include_system=False,
|
||||
)
|
||||
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
|
||||
self.assertIn("docs:documentation:read", context.scopes)
|
||||
self.assertNotIn("mail:profile:write", context.scopes)
|
||||
self.assertEqual([role.slug for role in context.tenant_roles], ["docs_reader"])
|
||||
|
||||
def test_first_authorized_request_grants_default_without_database_mutation(self) -> None:
|
||||
user = self._add_user("new")
|
||||
|
||||
with patch(
|
||||
"govoplan_access.backend.security.sessions.role_templates_for_level",
|
||||
return_value=(DOCS_READER,),
|
||||
):
|
||||
context = collect_user_authorization_context(
|
||||
self.session,
|
||||
user,
|
||||
account=user.account,
|
||||
include_system=False,
|
||||
)
|
||||
|
||||
self.assertIn("docs:documentation:read", context.scopes)
|
||||
self.assertEqual(context.tenant_roles, [])
|
||||
self.assertEqual(self.session.query(Role).count(), 0)
|
||||
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
|
||||
self.assertFalse(self.session.new)
|
||||
self.assertFalse(self.session.dirty)
|
||||
|
||||
user_id = user.id
|
||||
self.session.commit()
|
||||
self.session.close()
|
||||
self.session = self.Session()
|
||||
persisted_user = self.session.get(User, user_id)
|
||||
with patch(
|
||||
"govoplan_access.backend.security.sessions.role_templates_for_level",
|
||||
return_value=(DOCS_READER,),
|
||||
):
|
||||
reopened_context = collect_user_authorization_context(
|
||||
self.session,
|
||||
persisted_user,
|
||||
account=persisted_user.account,
|
||||
include_system=False,
|
||||
)
|
||||
self.assertIn("docs:documentation:read", reopened_context.scopes)
|
||||
self.assertEqual(self.session.query(Role).count(), 0)
|
||||
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, User
|
||||
from govoplan_access.backend.dsar_provider import AccessDsarProvider
|
||||
from govoplan_core.core.dsar import DsarSubjectRef
|
||||
|
||||
|
||||
class AccessDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine(
|
||||
"sqlite+pysqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
AccessBase.metadata.create_all(bind=self.engine)
|
||||
self.session = sessionmaker(bind=self.engine, expire_on_commit=False)()
|
||||
self.account = Account(
|
||||
id="account-1",
|
||||
email="ada@example.test",
|
||||
normalized_email="ada@example.test",
|
||||
display_name="Ada Example",
|
||||
password_hash="secret-hash",
|
||||
)
|
||||
self.user = User(
|
||||
id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id=self.account.id,
|
||||
email="ada@example.test",
|
||||
display_name="Ada Example",
|
||||
password_hash="tenant-secret-hash",
|
||||
settings={"locale": "de"},
|
||||
mail_profile_policy={"profile": "one"},
|
||||
)
|
||||
self.key = ApiKey(
|
||||
id="key-1",
|
||||
tenant_id="tenant-1",
|
||||
user_id=self.user.id,
|
||||
name="Automation",
|
||||
prefix="gpn_example",
|
||||
key_hash="do-not-export",
|
||||
scopes=["files:read"],
|
||||
)
|
||||
self.auth_session = AuthSession(
|
||||
id="session-1",
|
||||
tenant_id="tenant-1",
|
||||
user_id=self.user.id,
|
||||
account_id=self.account.id,
|
||||
token_hash="do-not-export",
|
||||
csrf_token_hash="do-not-export",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
user_agent="Browser fingerprint",
|
||||
ip_address="192.0.2.10",
|
||||
)
|
||||
self.session.add_all([self.account, self.user, self.key, self.auth_session])
|
||||
self.session.commit()
|
||||
self.provider = AccessDsarProvider()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
AccessBase.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_search_omits_secret_and_client_fingerprint_material(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(email="ADA@example.test"),
|
||||
)
|
||||
serialized = repr([record.to_dict() for record in records])
|
||||
self.assertIn("membership-1", serialized)
|
||||
self.assertNotIn("do-not-export", serialized)
|
||||
self.assertNotIn("Browser fingerprint", serialized)
|
||||
self.assertNotIn("192.0.2.10", serialized)
|
||||
|
||||
def test_multiple_subject_selectors_must_identify_the_same_membership(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
membership_id=self.user.id,
|
||||
email="different@example.test",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual((), records)
|
||||
|
||||
def test_plan_and_execution_anonymize_membership_and_revoke_credentials(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id=self.account.id),
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id=self.account.id),
|
||||
records=records,
|
||||
)
|
||||
executable = tuple(action for action in actions if action.executable)
|
||||
self.assertEqual(3, len(executable))
|
||||
self.assertTrue(any(action.kind == "manual_review" for action in actions))
|
||||
|
||||
results = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id=self.account.id),
|
||||
actions=executable,
|
||||
request_id="dsar-1",
|
||||
)
|
||||
self.assertEqual({"executed"}, {result.status for result in results})
|
||||
self.assertTrue(self.user.email.endswith("@invalid.govoplan"))
|
||||
self.assertFalse(self.user.is_active)
|
||||
self.assertEqual({}, self.user.settings)
|
||||
self.assertIsNotNone(self.key.revoked_at)
|
||||
self.assertIsNotNone(self.auth_session.revoked_at)
|
||||
self.assertIsNone(self.auth_session.user_agent)
|
||||
self.assertIsNone(self.auth_session.ip_address)
|
||||
self.assertEqual("ada@example.test", self.account.email)
|
||||
|
||||
repeated = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id=self.account.id),
|
||||
actions=executable,
|
||||
request_id="dsar-1",
|
||||
)
|
||||
self.assertEqual({"unchanged"}, {result.status for result in repeated})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, event, inspect, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.api.v1.routes import router
|
||||
from govoplan_access.backend.auth.dependencies import get_api_principal
|
||||
from govoplan_access.backend.db.models import ExternalFunctionRoleAssignment, Role
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.db.migrations import alembic_config
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
|
||||
TABLE_NAME = "access_external_function_role_assignments"
|
||||
REPAIR_REVISION = "d8f1b4e7a0c3"
|
||||
|
||||
|
||||
class ExternalFunctionMappingMigrationTests(unittest.TestCase):
|
||||
def test_release_missing_table_repair(self) -> None:
|
||||
self._verify_upgrade("release", missing=True)
|
||||
|
||||
def test_release_existing_mappings_preserved(self) -> None:
|
||||
self._verify_upgrade("release", missing=False)
|
||||
|
||||
def test_dev_missing_table_repair(self) -> None:
|
||||
self._verify_upgrade("dev", missing=True)
|
||||
|
||||
def test_dev_existing_mappings_preserved(self) -> None:
|
||||
self._verify_upgrade("dev", missing=False)
|
||||
|
||||
def _verify_upgrade(self, track: str, *, missing: bool) -> None:
|
||||
previous = "c7e0a3d6f9b2" if track == "release" else "b6d9f2a5c8e1"
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-function-mapping-upgrade-") as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'upgrade.db'}"
|
||||
config = alembic_config(database_url=url, enabled_modules=("access",), migration_track=track)
|
||||
command.upgrade(config, "4f2a9c8e7b6d")
|
||||
command.upgrade(config, previous)
|
||||
engine = create_engine(url, connect_args={"check_same_thread": False})
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def enforce_foreign_keys(connection, _record) -> None:
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
session.add_all([
|
||||
Tenant(id="tenant-1", slug="tenant-1", name="Existing tenant"),
|
||||
Tenant(id="tenant-2", slug="tenant-2", name="Other tenant"),
|
||||
])
|
||||
session.flush()
|
||||
session.add_all([
|
||||
Role(id="role-1", tenant_id="tenant-1", slug="role-1", name="Existing role", permissions=["access:function:read"]),
|
||||
Role(id="role-2", tenant_id="tenant-2", slug="role-2", name="Other role", permissions=["access:role:read"]),
|
||||
])
|
||||
session.commit()
|
||||
if missing:
|
||||
# Reproduce only in this isolated database: a recorded baseline
|
||||
# with the exact missing table observed in the live 500 response.
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("DROP TABLE access_external_function_role_assignments"))
|
||||
else:
|
||||
self._insert_mapping(engine, "mapping-1", "tenant-1", "role-1")
|
||||
self._insert_mapping(engine, "mapping-2", "tenant-2", "role-2")
|
||||
with engine.connect() as connection:
|
||||
tables_before = set(inspect(connection).get_table_names())
|
||||
parents_before = self._parent_rows(connection)
|
||||
mappings_before = [] if missing else self._mapping_rows(connection)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
principal = ApiPrincipal(
|
||||
principal=PrincipalRef(account_id="reader", membership_id="reader-1", tenant_id="tenant-1", scopes=frozenset({"access:function:read"})),
|
||||
account=None,
|
||||
user=None,
|
||||
)
|
||||
|
||||
def test_session():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_session] = test_session
|
||||
app.dependency_overrides[get_api_principal] = lambda: principal
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
path = "/api/v1/admin/external-function-role-mappings"
|
||||
if missing:
|
||||
self.assertEqual(client.get(f"{path}/delta").status_code, 500)
|
||||
command.upgrade(config, REPAIR_REVISION)
|
||||
command.upgrade(config, REPAIR_REVISION)
|
||||
for suffix in ("", "/delta"):
|
||||
response = client.get(f"{path}{suffix}")
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual([item["id"] for item in response.json()["mappings"]], [] if missing else ["mapping-1"])
|
||||
self.assertEqual(response.json()["total"], 0 if missing else 1)
|
||||
self.assertEqual(client.get(f"{path}{suffix}?tenant_id=tenant-2").status_code, 409)
|
||||
principal.principal = PrincipalRef(account_id="reader", membership_id="reader-1", tenant_id="tenant-1", scopes=frozenset())
|
||||
self.assertEqual(client.get(f"{path}/delta").status_code, 403)
|
||||
|
||||
with engine.connect() as connection:
|
||||
inspector = inspect(connection)
|
||||
self.assertEqual(set(inspector.get_table_names()), tables_before | {TABLE_NAME})
|
||||
self.assertEqual(self._parent_rows(connection), parents_before)
|
||||
self.assertEqual(self._mapping_rows(connection), mappings_before)
|
||||
columns = inspector.get_columns(TABLE_NAME)
|
||||
self.assertEqual({item["name"] for item in columns}, {"id", "tenant_id", "source_module", "function_id", "role_id", "settings", "created_at", "updated_at"})
|
||||
self.assertTrue(all(not item["nullable"] for item in columns))
|
||||
self.assertEqual(inspector.get_pk_constraint(TABLE_NAME)["constrained_columns"], ["id"])
|
||||
self.assertIn(["tenant_id", "source_module", "function_id", "role_id"], [item["column_names"] for item in inspector.get_unique_constraints(TABLE_NAME)])
|
||||
self.assertEqual({tuple(item["column_names"]) for item in inspector.get_indexes(TABLE_NAME)}, {("tenant_id",), ("role_id",), ("function_id",), ("source_module",)})
|
||||
self.assertEqual({(tuple(item["constrained_columns"]), item["referred_table"], item["options"]["ondelete"]) for item in inspector.get_foreign_keys(TABLE_NAME)}, {(("role_id",), "access_roles", "CASCADE"), (("tenant_id",), "core_scopes", "CASCADE")})
|
||||
|
||||
self._insert_mapping(engine, "mapping-after-repair", "tenant-1", "role-1", function_id="new-function")
|
||||
with self.assertRaises(IntegrityError):
|
||||
self._insert_mapping(engine, "duplicate", "tenant-1", "role-1", function_id="new-function")
|
||||
with self.assertRaises(IntegrityError):
|
||||
self._insert_mapping(engine, "bad-role", "tenant-1", "missing-role")
|
||||
with self.assertRaises(IntegrityError):
|
||||
self._insert_mapping(engine, "bad-tenant", "missing-tenant", "role-1")
|
||||
with engine.connect() as connection:
|
||||
all_mappings = self._mapping_rows(connection)
|
||||
command.downgrade(config, previous)
|
||||
command.upgrade(config, REPAIR_REVISION)
|
||||
with engine.connect() as connection:
|
||||
self.assertEqual(self._mapping_rows(connection), all_mappings)
|
||||
self.assertEqual(self._parent_rows(connection), parents_before)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
@staticmethod
|
||||
def _insert_mapping(engine, mapping_id: str, tenant_id: str, role_id: str, *, function_id: str = "function-1") -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
with Session(engine) as session:
|
||||
session.add(ExternalFunctionRoleAssignment(
|
||||
id=mapping_id, tenant_id=tenant_id, role_id=role_id,
|
||||
source_module="organizations", function_id=function_id,
|
||||
settings={"meaning": "Existing mapping", "nested": {"retained": True}},
|
||||
created_at=now, updated_at=now,
|
||||
))
|
||||
session.commit()
|
||||
|
||||
@staticmethod
|
||||
def _mapping_rows(connection):
|
||||
return [dict(row) for row in connection.execute(text("SELECT * FROM access_external_function_role_assignments ORDER BY id")).mappings()]
|
||||
|
||||
@staticmethod
|
||||
def _parent_rows(connection):
|
||||
return {
|
||||
"roles": [dict(row) for row in connection.execute(text("SELECT * FROM access_roles ORDER BY id")).mappings()],
|
||||
"tenants": [dict(row) for row in connection.execute(text("SELECT * FROM core_scopes ORDER BY id")).mappings()],
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
Role,
|
||||
SystemRoleAssignment,
|
||||
User,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_access.backend.security.passwords import verify_password
|
||||
from govoplan_access.backend.tenancy.provisioning import LegacyFirstAdminProvisioner
|
||||
from govoplan_core.core.access import FirstAdminProvisioningError
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||
|
||||
|
||||
class FirstAdminProvisioningTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
AccessBase.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine, expire_on_commit=False)
|
||||
self.session = self.Session()
|
||||
self.tenant = Tenant(id="tenant-1", slug="default", name="Default Tenant")
|
||||
self.session.add(self.tenant)
|
||||
self.session.flush()
|
||||
self.provisioner = LegacyFirstAdminProvisioner()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
AccessBase.metadata.drop_all(bind=self.engine)
|
||||
scope_registry.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_creates_one_system_owner_with_a_login_membership(self) -> None:
|
||||
created = self.provisioner.create_first_system_administrator(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
email="Owner@Example.test",
|
||||
display_name="System Owner",
|
||||
password="a-production-password",
|
||||
)
|
||||
self.session.flush()
|
||||
|
||||
account = self.session.get(Account, created.account_id)
|
||||
membership = self.session.get(User, created.membership_id)
|
||||
self.assertIsNotNone(account)
|
||||
self.assertIsNotNone(membership)
|
||||
assert account is not None
|
||||
assert membership is not None
|
||||
self.assertTrue(verify_password("a-production-password", account.password_hash))
|
||||
self.assertEqual(membership.tenant_id, self.tenant.id)
|
||||
self.assertTrue(membership.is_tenant_admin)
|
||||
system_role = (
|
||||
self.session.query(Role)
|
||||
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
|
||||
.filter(SystemRoleAssignment.account_id == account.id)
|
||||
.one()
|
||||
)
|
||||
tenant_role = (
|
||||
self.session.query(Role)
|
||||
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
|
||||
.filter(UserRoleAssignment.user_id == membership.id)
|
||||
.one()
|
||||
)
|
||||
self.assertEqual(system_role.slug, "system_owner")
|
||||
self.assertEqual(tenant_role.slug, "owner")
|
||||
self.assertTrue(
|
||||
self.provisioner.has_durable_system_administrator(self.session)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(FirstAdminProvisioningError, "already exists"):
|
||||
self.provisioner.create_first_system_administrator(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
email="second@example.test",
|
||||
display_name=None,
|
||||
password="another-production-password",
|
||||
)
|
||||
|
||||
def test_refuses_to_promote_or_reset_an_existing_account(self) -> None:
|
||||
self.session.add(
|
||||
Account(
|
||||
email="existing@example.test",
|
||||
normalized_email="existing@example.test",
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
self.session.flush()
|
||||
|
||||
with self.assertRaisesRegex(FirstAdminProvisioningError, "already belongs"):
|
||||
self.provisioner.create_first_system_administrator(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
email="existing@example.test",
|
||||
display_name=None,
|
||||
password="a-production-password",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, Role, User, UserRoleAssignment
|
||||
from govoplan_access.backend.governance_materializer import SqlAccessGovernanceMaterializer
|
||||
from govoplan_core.core.access import (
|
||||
GovernanceProjectionBatch,
|
||||
GovernanceProjectionCommand,
|
||||
GovernanceTemplateMaterialization,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
def _command(index: int, *, kind: str = "role", operation: str = "upsert") -> GovernanceProjectionCommand:
|
||||
return GovernanceProjectionCommand(
|
||||
assignment_id=f"assignment-{kind}-{index}",
|
||||
operation=operation, # type: ignore[arg-type]
|
||||
template=GovernanceTemplateMaterialization(
|
||||
template_id=f"template-{kind}",
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
tenant_id=f"tenant-{index}",
|
||||
slug=f"managed-{kind}",
|
||||
name=f"Managed {kind}",
|
||||
permissions=("access:role:read",) if kind == "role" else (),
|
||||
required=True,
|
||||
),
|
||||
provenance={"source": "test", "assignment_mode": "required"},
|
||||
)
|
||||
|
||||
|
||||
class GovernanceProjectionTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.materializer = SqlAccessGovernanceMaterializer()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_bulk_projection_is_idempotent_and_returns_per_assignment_outcomes(self) -> None:
|
||||
commands = tuple(_command(index) for index in range(5))
|
||||
first = self.materializer.reconcile(
|
||||
self.session,
|
||||
GovernanceProjectionBatch(operation_id="first", commands=commands),
|
||||
)
|
||||
second = self.materializer.reconcile(
|
||||
self.session,
|
||||
GovernanceProjectionBatch(operation_id="second", commands=commands),
|
||||
)
|
||||
|
||||
self.assertEqual(["created"] * 5, [item.status for item in first.outcomes])
|
||||
self.assertEqual(["unchanged"] * 5, [item.status for item in second.outcomes])
|
||||
self.assertEqual(5, self.session.query(Role).count())
|
||||
self.assertEqual(
|
||||
{item.assignment_id for item in commands},
|
||||
{item.assignment_id for item in second.outcomes},
|
||||
)
|
||||
self.assertTrue(all(item.provenance["source"] == "test" for item in second.outcomes))
|
||||
|
||||
def test_removal_isolated_blocker_preserves_other_batch_outcomes(self) -> None:
|
||||
first, second = _command(1), _command(2)
|
||||
created = self.materializer.reconcile(
|
||||
self.session,
|
||||
GovernanceProjectionBatch(operation_id="create", commands=(first, second)),
|
||||
)
|
||||
roles = {item.tenant_id: item.resource_id for item in created.outcomes}
|
||||
account = Account(
|
||||
id="account-1",
|
||||
email="assigned@example.test",
|
||||
normalized_email="assigned@example.test",
|
||||
)
|
||||
user = User(
|
||||
id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
)
|
||||
self.session.add_all([account, user])
|
||||
self.session.flush()
|
||||
self.session.add(
|
||||
UserRoleAssignment(
|
||||
tenant_id="tenant-1",
|
||||
user_id=user.id,
|
||||
role_id=roles["tenant-1"],
|
||||
)
|
||||
)
|
||||
self.session.flush()
|
||||
|
||||
removals = tuple(
|
||||
GovernanceProjectionCommand(
|
||||
assignment_id=item.assignment_id,
|
||||
operation="remove",
|
||||
template=item.template,
|
||||
provenance=item.provenance,
|
||||
)
|
||||
for item in (first, second)
|
||||
)
|
||||
result = self.materializer.reconcile(
|
||||
self.session,
|
||||
GovernanceProjectionBatch(operation_id="remove", commands=removals),
|
||||
)
|
||||
|
||||
self.assertEqual(["blocked", "removed"], [item.status for item in result.outcomes])
|
||||
self.assertEqual(("role_has_users",), result.outcomes[0].blocker_codes)
|
||||
self.assertIsNotNone(self.session.get(Role, roles["tenant-1"]))
|
||||
self.assertIsNone(self.session.get(Role, roles["tenant-2"]))
|
||||
|
||||
def test_dry_run_does_not_mutate(self) -> None:
|
||||
result = self.materializer.reconcile(
|
||||
self.session,
|
||||
GovernanceProjectionBatch(
|
||||
operation_id="preview",
|
||||
commands=(_command(1, kind="group"),),
|
||||
dry_run=True,
|
||||
),
|
||||
)
|
||||
self.assertEqual("created", result.outcomes[0].status)
|
||||
self.assertEqual(0, self.session.query(Group).count())
|
||||
|
||||
def test_bulk_read_query_count_does_not_grow_per_assignment(self) -> None:
|
||||
def select_count(size: int) -> int:
|
||||
count = 0
|
||||
|
||||
def record_select(_conn, _cursor, statement, _parameters, _context, _executemany):
|
||||
nonlocal count
|
||||
if statement.lstrip().upper().startswith("SELECT"):
|
||||
count += 1
|
||||
|
||||
event.listen(self.engine, "before_cursor_execute", record_select)
|
||||
try:
|
||||
self.materializer.reconcile(
|
||||
self.session,
|
||||
GovernanceProjectionBatch(
|
||||
operation_id=f"preview-{size}",
|
||||
commands=tuple(
|
||||
_command(index, kind="group" if index % 2 else "role")
|
||||
for index in range(size)
|
||||
),
|
||||
dry_run=True,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
event.remove(self.engine, "before_cursor_execute", record_select)
|
||||
return count
|
||||
|
||||
small = select_count(2)
|
||||
large = select_count(200)
|
||||
self.assertEqual(small, large)
|
||||
self.assertLessEqual(large, 4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_access.backend.manifest import manifest
|
||||
|
||||
|
||||
class InterfaceDocumentationContractTests(unittest.TestCase):
|
||||
def test_password_change_flag_is_documented_as_unenforced(self) -> None:
|
||||
topic = next(item for item in manifest.documentation if item.id == "access.reference.authentication-fields")
|
||||
self.assertIn("currently advisory metadata", topic.body)
|
||||
self.assertIn("server-side enforcement are not implemented", topic.body)
|
||||
self.assertIn("serverseitige Durchsetzung sind noch nicht umgesetzt", topic.translations["de"]["body"])
|
||||
|
||||
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_access_admin_topics_publish_stable_help_contexts(self) -> None:
|
||||
topics = {topic.id: topic for topic in manifest.documentation}
|
||||
|
||||
expected_contexts = {
|
||||
"access.workflow.grant-user-access": {
|
||||
"access.admin.users",
|
||||
"access.admin.groups",
|
||||
"access.admin.roles",
|
||||
"access.admin.blocked",
|
||||
},
|
||||
"access.reference.admin-access-fields": {
|
||||
"access.admin.system-users",
|
||||
"access.admin.system-roles",
|
||||
"access.admin.tenant-users",
|
||||
"access.admin.tenant-groups",
|
||||
"access.admin.tenant-roles",
|
||||
},
|
||||
"access.workflow.manage-api-keys": {
|
||||
"access.admin.api-keys",
|
||||
"access.api-keys.action.create",
|
||||
"access.api-keys.action.revoke",
|
||||
"access.api-keys.field.owner",
|
||||
"access.api-keys.field.expiry",
|
||||
"access.api-keys.field.scopes",
|
||||
"access.api-keys.secret",
|
||||
"access.api-keys.confirm-revoke",
|
||||
},
|
||||
"access.workflow.manage-reusable-credentials": {
|
||||
"access.admin.system-credentials",
|
||||
"access.admin.tenant-credentials",
|
||||
"access.admin.group-credentials",
|
||||
"access.admin.user-credentials",
|
||||
"access.settings.credentials",
|
||||
"access.credentials",
|
||||
"access.credentials.field.secret",
|
||||
"access.credentials.field.clear-secret",
|
||||
"access.credentials.field.inherit-to-lower-scopes",
|
||||
"access.credentials.action.delete",
|
||||
"access.credentials.confirm-delete",
|
||||
},
|
||||
"access.reference.external-function-role-mappings": {
|
||||
"access.admin.function-mappings",
|
||||
"access.explanation",
|
||||
},
|
||||
"access.workflow.manage-service-account-credentials": {
|
||||
"access.admin.service-accounts",
|
||||
"access.service-accounts.action.create",
|
||||
"access.service-accounts.action.activation",
|
||||
"access.service-accounts.action.retire",
|
||||
"access.service-accounts.field.scope-ceiling",
|
||||
"access.service-accounts.action.rotate-credential",
|
||||
"access.service-accounts.action.revoke-credential",
|
||||
"access.service-accounts.field.credential-expiry",
|
||||
"access.service-accounts.field.credential-scopes",
|
||||
"access.service-accounts.secret",
|
||||
"access.service-accounts.confirm-retire",
|
||||
},
|
||||
"access.workflow.manage-sessions": {
|
||||
"access.settings.sessions",
|
||||
"access.sessions.action.revoke",
|
||||
"access.sessions.action.revoke-others",
|
||||
"access.admin.user-sessions",
|
||||
},
|
||||
}
|
||||
|
||||
for topic_id, expected in expected_contexts.items():
|
||||
self.assertIn(topic_id, topics)
|
||||
metadata = topics[topic_id].metadata or {}
|
||||
self.assertTrue(
|
||||
expected.issubset(set(metadata.get("help_contexts", ()))),
|
||||
topic_id,
|
||||
)
|
||||
|
||||
credential_topic = topics["access.workflow.manage-reusable-credentials"]
|
||||
self.assertEqual(
|
||||
{"title", "summary", "body"},
|
||||
set(credential_topic.translations["de"]),
|
||||
)
|
||||
self.assertIn(
|
||||
"nicht rückgängig gemacht",
|
||||
credential_topic.translations["de"]["body"],
|
||||
)
|
||||
|
||||
api_key_topic = topics["access.workflow.manage-api-keys"]
|
||||
self.assertEqual(
|
||||
{"title", "summary", "body"},
|
||||
set(api_key_topic.translations["de"]),
|
||||
)
|
||||
self.assertIn(
|
||||
"sofort und kann für diesen Schlüssel nicht rückgängig gemacht werden",
|
||||
api_key_topic.translations["de"]["body"],
|
||||
)
|
||||
|
||||
service_account_topic = topics[
|
||||
"access.workflow.manage-service-account-credentials"
|
||||
]
|
||||
self.assertEqual(
|
||||
{"title", "summary", "body"},
|
||||
set(service_account_topic.translations["de"]),
|
||||
)
|
||||
self.assertIn(
|
||||
"widerruft sämtliche aktiven Zugangsdaten",
|
||||
service_account_topic.translations["de"]["body"],
|
||||
)
|
||||
|
||||
def test_access_admin_surfaces_remain_declared(self) -> None:
|
||||
surface_ids = {surface.id for surface in manifest.frontend.view_surfaces}
|
||||
self.assertTrue(
|
||||
{
|
||||
"access.admin.system-roles",
|
||||
"access.admin.system-users",
|
||||
"access.admin.system-credentials",
|
||||
"access.admin.tenant-roles",
|
||||
"access.admin.tenant-function-mappings",
|
||||
"access.admin.tenant-groups",
|
||||
"access.admin.tenant-users",
|
||||
"access.admin.tenant-credentials",
|
||||
"access.admin.tenant-api-keys",
|
||||
"access.admin.tenant-service-accounts",
|
||||
"access.admin.group-credentials",
|
||||
"access.admin.user-credentials",
|
||||
"access.settings.credentials",
|
||||
"access.settings.sessions",
|
||||
}.issubset(surface_ids)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_access.backend.api.v1 import auth
|
||||
from govoplan_access.backend.security.passwords import (
|
||||
DUMMY_PASSWORD_HASH,
|
||||
verify_password,
|
||||
)
|
||||
from govoplan_core.api.v1.schemas import LoginRequest
|
||||
|
||||
|
||||
class LoginSecurityTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _session_with_account(account: object | None) -> MagicMock:
|
||||
session = MagicMock()
|
||||
session.query.return_value.filter.return_value.one_or_none.return_value = (
|
||||
account
|
||||
)
|
||||
return session
|
||||
|
||||
def test_absent_identity_verifies_a_valid_dummy_hash_before_generic_failure(
|
||||
self,
|
||||
) -> None:
|
||||
self.assertTrue(verify_password("not-a-user-password", DUMMY_PASSWORD_HASH))
|
||||
payload = LoginRequest(
|
||||
email="missing@example.test", password="attempted-password"
|
||||
)
|
||||
|
||||
with patch.object(auth, "verify_password", return_value=True) as verifier:
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
auth._resolve_login_user(self._session_with_account(None), payload)
|
||||
|
||||
verifier.assert_called_once_with(payload.password, DUMMY_PASSWORD_HASH)
|
||||
self.assertEqual(raised.exception.status_code, 401)
|
||||
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||
|
||||
def test_present_identity_verifies_account_hash_before_same_generic_failure(
|
||||
self,
|
||||
) -> None:
|
||||
account_hash = "pbkdf2_sha256$260000$account-salt$account-digest"
|
||||
account = SimpleNamespace(password_hash=account_hash)
|
||||
payload = LoginRequest(email="known@example.test", password="wrong-password")
|
||||
|
||||
with patch.object(auth, "verify_password", return_value=False) as verifier:
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
auth._resolve_login_user(self._session_with_account(account), payload) # type: ignore[arg-type]
|
||||
|
||||
verifier.assert_called_once_with(payload.password, account_hash)
|
||||
self.assertEqual(raised.exception.status_code, 401)
|
||||
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||
|
||||
def test_passwordless_account_cannot_authenticate_with_dummy_password(self) -> None:
|
||||
account = SimpleNamespace(password_hash=None)
|
||||
payload = LoginRequest(
|
||||
email="passwordless@example.test", password="not-a-user-password"
|
||||
)
|
||||
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
auth._resolve_login_user(self._session_with_account(account), payload) # type: ignore[arg-type]
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 401)
|
||||
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||
|
||||
def test_account_without_active_membership_uses_same_generic_failure(self) -> None:
|
||||
account = SimpleNamespace(
|
||||
id="account-1",
|
||||
password_hash="pbkdf2_sha256$260000$account-salt$account-digest",
|
||||
)
|
||||
session = self._session_with_account(account)
|
||||
session.query.return_value.join.return_value.filter.return_value.order_by.return_value.first.return_value = None
|
||||
payload = LoginRequest(email="known@example.test", password="correct-password")
|
||||
|
||||
with patch.object(auth, "verify_password", return_value=True):
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
auth._resolve_login_user(session, payload) # type: ignore[arg-type]
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 401)
|
||||
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,219 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from govoplan_access.backend.api.v1 import auth
|
||||
from govoplan_access.backend.security.login_throttle import (
|
||||
AttemptBucket,
|
||||
InMemoryLoginAttemptStore,
|
||||
LoginThrottle,
|
||||
LoginThrottleDecision,
|
||||
ResilientLoginAttemptStore,
|
||||
)
|
||||
from govoplan_core.api.v1.schemas import LoginRequest
|
||||
|
||||
|
||||
class LoginThrottleTests(unittest.TestCase):
|
||||
def test_identity_and_client_buckets_enforce_independent_limits(self) -> None:
|
||||
throttle = LoginThrottle(
|
||||
InMemoryLoginAttemptStore(),
|
||||
identity_limit=2,
|
||||
client_limit=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
context = {
|
||||
"normalized_email": "person@example.test",
|
||||
"tenant_slug": "tenant-a",
|
||||
"client_address": "192.0.2.4",
|
||||
}
|
||||
|
||||
self.assertTrue(throttle.record_failure(**context).allowed)
|
||||
self.assertFalse(throttle.record_failure(**context).allowed)
|
||||
|
||||
other_identity = {**context, "normalized_email": "other@example.test"}
|
||||
self.assertFalse(throttle.record_failure(**other_identity).allowed)
|
||||
|
||||
def test_success_clears_identity_bucket_without_erasing_client_failures(self) -> None:
|
||||
throttle = LoginThrottle(
|
||||
InMemoryLoginAttemptStore(),
|
||||
identity_limit=2,
|
||||
client_limit=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
context = {
|
||||
"normalized_email": "person@example.test",
|
||||
"tenant_slug": "tenant-a",
|
||||
"client_address": "192.0.2.8",
|
||||
}
|
||||
self.assertTrue(throttle.record_failure(**context).allowed)
|
||||
|
||||
throttle.record_success(
|
||||
normalized_email=context["normalized_email"],
|
||||
tenant_slug=context["tenant_slug"],
|
||||
)
|
||||
|
||||
self.assertTrue(throttle.check(**context).allowed)
|
||||
other_identity = {**context, "normalized_email": "other@example.test"}
|
||||
self.assertTrue(throttle.record_failure(**other_identity).allowed)
|
||||
self.assertFalse(throttle.record_failure(**other_identity).allowed)
|
||||
|
||||
def test_rotating_untrusted_tenant_slug_does_not_bypass_identity_limit(self) -> None:
|
||||
throttle = LoginThrottle(
|
||||
InMemoryLoginAttemptStore(),
|
||||
identity_limit=2,
|
||||
client_limit=100,
|
||||
window_seconds=60,
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
throttle.record_failure(
|
||||
normalized_email="person@example.test",
|
||||
tenant_slug="tenant-a",
|
||||
client_address="192.0.2.1",
|
||||
).allowed
|
||||
)
|
||||
self.assertFalse(
|
||||
throttle.record_failure(
|
||||
normalized_email="person@example.test",
|
||||
tenant_slug="made-up-tenant",
|
||||
client_address="198.51.100.9",
|
||||
).allowed
|
||||
)
|
||||
|
||||
def test_bucket_keys_do_not_contain_identity_or_client_data(self) -> None:
|
||||
store = MagicMock()
|
||||
store.increment.return_value = AttemptBucket(count=1, retry_after_seconds=60)
|
||||
throttle = LoginThrottle(
|
||||
store,
|
||||
identity_limit=10,
|
||||
client_limit=100,
|
||||
window_seconds=60,
|
||||
)
|
||||
|
||||
throttle.record_failure(
|
||||
normalized_email="private.person@example.test",
|
||||
tenant_slug="private-tenant",
|
||||
client_address="192.0.2.9",
|
||||
)
|
||||
|
||||
keys = [call.args[0] for call in store.increment.call_args_list]
|
||||
self.assertEqual(len(keys), 2)
|
||||
for key in keys:
|
||||
self.assertNotIn("private", key)
|
||||
self.assertNotIn("example", key)
|
||||
self.assertNotIn("192.0.2.9", key)
|
||||
|
||||
def test_redis_failure_uses_local_store_during_retry_window(self) -> None:
|
||||
primary = MagicMock()
|
||||
primary.read.side_effect = RedisError("not available")
|
||||
fallback = InMemoryLoginAttemptStore()
|
||||
store = ResilientLoginAttemptStore(primary, fallback, retry_seconds=60)
|
||||
|
||||
with self.assertLogs(
|
||||
"govoplan_access.backend.security.login_throttle",
|
||||
level="WARNING",
|
||||
) as captured:
|
||||
self.assertEqual(store.read("bucket"), AttemptBucket())
|
||||
result = store.increment("bucket", window_seconds=60)
|
||||
|
||||
self.assertEqual(result.count, 1)
|
||||
self.assertEqual(primary.read.call_count, 1)
|
||||
primary.increment.assert_not_called()
|
||||
self.assertIn("process-local fallback", captured.output[0])
|
||||
|
||||
def test_redis_recovery_does_not_erase_failures_counted_by_the_fallback(self) -> None:
|
||||
primary = MagicMock()
|
||||
primary.read.side_effect = [RedisError("not available"), AttemptBucket(count=1, retry_after_seconds=30)]
|
||||
primary.increment.return_value = AttemptBucket(count=2, retry_after_seconds=30)
|
||||
fallback = InMemoryLoginAttemptStore()
|
||||
store = ResilientLoginAttemptStore(primary, fallback, retry_seconds=60)
|
||||
|
||||
with self.assertLogs("govoplan_access.backend.security.login_throttle", level="WARNING"):
|
||||
store.read("bucket")
|
||||
store.increment("bucket", window_seconds=60)
|
||||
store.increment("bucket", window_seconds=60)
|
||||
|
||||
store._primary_unavailable_until = 0 # Simulate the next Redis retry window.
|
||||
recovered = store.read("bucket")
|
||||
incremented = store.increment("bucket", window_seconds=60)
|
||||
|
||||
self.assertEqual(recovered.count, 2)
|
||||
self.assertEqual(incremented.count, 3)
|
||||
|
||||
def test_in_memory_store_is_bounded(self) -> None:
|
||||
store = InMemoryLoginAttemptStore(max_entries=2)
|
||||
for key in ("one", "two", "three"):
|
||||
store.increment(key, window_seconds=60)
|
||||
|
||||
active = sum(store.read(key).count > 0 for key in ("one", "two", "three"))
|
||||
self.assertEqual(active, 2)
|
||||
|
||||
|
||||
class LoginThrottleRouteTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.payload = LoginRequest(
|
||||
email="Person@Example.Test",
|
||||
password="attempt",
|
||||
tenant_slug="tenant-a",
|
||||
)
|
||||
self.request = SimpleNamespace(client=SimpleNamespace(host="192.0.2.10"))
|
||||
|
||||
def test_throttled_identity_gets_same_generic_failure_detail(self) -> None:
|
||||
throttle = MagicMock()
|
||||
throttle.check.return_value = LoginThrottleDecision(False, 42)
|
||||
|
||||
with patch.object(auth, "_configured_login_throttle", return_value=throttle):
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 429)
|
||||
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||
self.assertEqual(raised.exception.headers, {"Retry-After": "42"})
|
||||
|
||||
def test_failed_login_is_counted_and_threshold_response_stays_generic(self) -> None:
|
||||
throttle = MagicMock()
|
||||
throttle.check.return_value = LoginThrottleDecision(True)
|
||||
throttle.record_failure.return_value = LoginThrottleDecision(False, 60)
|
||||
generic_failure = HTTPException(status_code=401, detail="Invalid login")
|
||||
|
||||
with (
|
||||
patch.object(auth, "_configured_login_throttle", return_value=throttle),
|
||||
patch.object(auth, "_resolve_login_user", side_effect=generic_failure),
|
||||
):
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 429)
|
||||
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||
throttle.record_failure.assert_called_once_with(
|
||||
normalized_email="person@example.test",
|
||||
tenant_slug="tenant-a",
|
||||
client_address="192.0.2.10",
|
||||
)
|
||||
|
||||
def test_success_clears_only_the_identity_bucket(self) -> None:
|
||||
throttle = MagicMock()
|
||||
throttle.check.return_value = LoginThrottleDecision(True)
|
||||
resolved = (object(), object(), object())
|
||||
|
||||
with (
|
||||
patch.object(auth, "_configured_login_throttle", return_value=throttle),
|
||||
patch.object(auth, "_resolve_login_user", return_value=resolved),
|
||||
):
|
||||
result = auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
|
||||
|
||||
self.assertEqual(result, resolved)
|
||||
throttle.record_failure.assert_not_called()
|
||||
throttle.record_success.assert_called_once_with(
|
||||
normalized_email="person@example.test",
|
||||
tenant_slug="tenant-a",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
import tomllib
|
||||
import unittest
|
||||
|
||||
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
SystemSettingsItem,
|
||||
TenantCreateRequest,
|
||||
)
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class OptionalTenancyContractTests(unittest.TestCase):
|
||||
def test_compatibility_admin_schemas_use_german_reference_default(self) -> None:
|
||||
tenant = TenantCreateRequest(slug="example", name="Example")
|
||||
|
||||
self.assertEqual("de", tenant.default_locale)
|
||||
self.assertEqual("de", SystemSettingsItem().default_locale)
|
||||
|
||||
def test_access_package_does_not_require_tenancy_to_install(self) -> None:
|
||||
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
|
||||
dependencies = tuple(project["dependencies"])
|
||||
|
||||
self.assertTrue(
|
||||
any(item.startswith("govoplan-core>=") for item in dependencies)
|
||||
)
|
||||
self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies)
|
||||
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))
|
||||
|
||||
def test_tenancy_is_declared_as_optional_module_integration(self) -> None:
|
||||
manifest_path = ROOT / "src" / "govoplan_access" / "backend" / "manifest.py"
|
||||
tree = ast.parse(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest_call = next(
|
||||
node.value
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Assign)
|
||||
and any(isinstance(target, ast.Name) and target.id == "manifest" for target in node.targets)
|
||||
and isinstance(node.value, ast.Call)
|
||||
)
|
||||
optional_dependencies = next(
|
||||
keyword.value
|
||||
for keyword in manifest_call.keywords
|
||||
if keyword.arg == "optional_dependencies"
|
||||
)
|
||||
|
||||
self.assertIsInstance(optional_dependencies, ast.Tuple)
|
||||
self.assertIn(
|
||||
"tenancy",
|
||||
{
|
||||
item.value
|
||||
for item in optional_dependencies.elts
|
||||
if isinstance(item, ast.Constant) and isinstance(item.value, str)
|
||||
},
|
||||
)
|
||||
|
||||
def test_access_source_does_not_import_tenancy_module_internals(self) -> None:
|
||||
offenders: list[str] = []
|
||||
for path in (ROOT / "src" / "govoplan_access").rglob("*.py"):
|
||||
source = path.read_text(encoding="utf-8")
|
||||
if "govoplan_tenancy" in source:
|
||||
offenders.append(str(path.relative_to(ROOT)))
|
||||
|
||||
self.assertEqual([], offenders)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.models import Account, User
|
||||
from govoplan_access.backend.manifest import manifest
|
||||
from govoplan_access.backend.people_search import AccessPeopleSearchProvider
|
||||
from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH, PeopleSearchError, PeopleSearchProvider
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class AccessPeopleSearchTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.provider = AccessPeopleSearchProvider()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def _add_user(
|
||||
self,
|
||||
suffix: str,
|
||||
*,
|
||||
tenant_id: str = "tenant-1",
|
||||
display_name: str | None = None,
|
||||
user_active: bool = True,
|
||||
account_active: bool = True,
|
||||
) -> None:
|
||||
email = f"{suffix}@example.test"
|
||||
account = Account(
|
||||
id=f"account-{suffix}",
|
||||
email=email,
|
||||
normalized_email=email,
|
||||
display_name=display_name,
|
||||
is_active=account_active,
|
||||
)
|
||||
self.session.add_all([
|
||||
account,
|
||||
User(
|
||||
id=f"user-{suffix}",
|
||||
tenant_id=tenant_id,
|
||||
account_id=account.id,
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
is_active=user_active,
|
||||
),
|
||||
])
|
||||
self.session.flush()
|
||||
|
||||
def test_search_is_tenant_bounded_and_excludes_inactive_records(self) -> None:
|
||||
self._add_user("ada", display_name="Ada Lovelace")
|
||||
self._add_user("other", tenant_id="tenant-2", display_name="Ada Other Tenant")
|
||||
self._add_user("inactive-user", display_name="Ada Inactive User", user_active=False)
|
||||
self._add_user("inactive-account", display_name="Ada Inactive Account", account_active=False)
|
||||
|
||||
groups = self.provider.search_people(
|
||||
self.session,
|
||||
SimpleNamespace(tenant_id="tenant-1"),
|
||||
query="ada",
|
||||
)
|
||||
|
||||
self.assertEqual([group.key for group in groups], ["accounts"])
|
||||
self.assertEqual([item.reference_id for item in groups[0].candidates], ["account-ada"])
|
||||
self.assertEqual(groups[0].candidates[0].email, "ada@example.test")
|
||||
self.assertNotIn("tenant_id", groups[0].candidates[0].metadata)
|
||||
|
||||
def test_search_escapes_like_wildcards_and_requires_tenant_context(self) -> None:
|
||||
self._add_user("ada", display_name="Ada Lovelace")
|
||||
|
||||
groups = self.provider.search_people(
|
||||
self.session,
|
||||
SimpleNamespace(tenant_id="tenant-1"),
|
||||
query="%",
|
||||
)
|
||||
self.assertEqual(groups[0].candidates, ())
|
||||
with self.assertRaises(PeopleSearchError):
|
||||
self.provider.search_people(self.session, SimpleNamespace(tenant_id=None), query="ada")
|
||||
|
||||
def test_manifest_exposes_the_shared_interface(self) -> None:
|
||||
self.assertIn(CAPABILITY_ACCESS_PEOPLE_SEARCH, manifest.capability_factories)
|
||||
self.assertIn(CAPABILITY_ACCESS_PEOPLE_SEARCH, {item.name for item in manifest.provides_interfaces})
|
||||
self.assertIsInstance(self.provider, PeopleSearchProvider)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_access.backend.permissions import catalog as access_catalog
|
||||
from govoplan_core.security import permissions as core_permissions
|
||||
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
|
||||
|
||||
|
||||
class PermissionCatalogContractTests(unittest.TestCase):
|
||||
def test_access_reuses_core_legacy_scope_aliases(self) -> None:
|
||||
self.assertIs(access_catalog.LEGACY_SCOPE_ALIASES, LEGACY_SCOPE_ALIASES)
|
||||
self.assertIs(core_permissions.LEGACY_SCOPE_ALIASES, LEGACY_SCOPE_ALIASES)
|
||||
|
||||
def test_legacy_alias_grants_match_in_core_and_access(self) -> None:
|
||||
for legacy_scope, aliases in LEGACY_SCOPE_ALIASES.items():
|
||||
for alias in aliases:
|
||||
self.assertTrue(core_permissions.scope_grants(legacy_scope, alias))
|
||||
self.assertTrue(access_catalog.scope_grants(legacy_scope, alias))
|
||||
|
||||
def test_access_legacy_catalog_includes_non_access_platform_scopes(self) -> None:
|
||||
scopes = {permission.scope for permission in access_catalog.permission_catalog(include_legacy=True)}
|
||||
self.assertIn("campaign:queue", scopes)
|
||||
self.assertIn("files:upload", scopes)
|
||||
self.assertIn("mail_servers:test", scopes)
|
||||
|
||||
def test_expand_scopes_preserves_explicit_canonical_scope_with_legacy_alias(self) -> None:
|
||||
scopes = access_catalog.expand_scopes(("files:file:read",))
|
||||
|
||||
self.assertIn("files:file:read", scopes)
|
||||
self.assertIn("files:read", scopes)
|
||||
|
||||
def test_api_key_intersection_excludes_canonical_and_legacy_system_scopes(self) -> None:
|
||||
for scope in ("access:system_credential:write", "access:tenant:create", "system:tenants:create"):
|
||||
with self.subTest(scope=scope):
|
||||
self.assertEqual([], access_catalog.intersect_api_key_scopes([scope], [scope]))
|
||||
|
||||
def test_api_key_module_wildcards_expand_only_to_concrete_tenant_scopes(self) -> None:
|
||||
scopes = access_catalog.intersect_api_key_scopes(["access:*"], ["access:*"])
|
||||
self.assertIn("access:membership:read", scopes)
|
||||
self.assertNotIn("access:*", scopes)
|
||||
self.assertFalse(access_catalog.scopes_grant(scopes, "access:system_credential:write"))
|
||||
catalog = access_catalog.permission_map()
|
||||
self.assertTrue(all(catalog[scope].level == "tenant" for scope in scopes if scope in catalog))
|
||||
|
||||
def test_api_key_intersection_preserves_unknown_concrete_module_grants(self) -> None:
|
||||
self.assertEqual(
|
||||
["optional-module:record:read"],
|
||||
access_catalog.intersect_api_key_scopes(["optional-module:record:read"], ["optional-module:record:read"]),
|
||||
)
|
||||
|
||||
def test_api_key_intersection_preserves_concrete_tenant_compatibility_aliases(self) -> None:
|
||||
scopes = access_catalog.intersect_api_key_scopes(["files:read"], ["files:file:read"])
|
||||
self.assertIn("files:read", scopes)
|
||||
self.assertIn("files:file:read", scopes)
|
||||
self.assertTrue(access_catalog.scopes_grant(scopes, "files:file:read"))
|
||||
|
||||
def test_api_key_intersection_does_not_retain_unknown_wildcards(self) -> None:
|
||||
self.assertEqual([], access_catalog.intersect_api_key_scopes(["optional-module:*"], ["optional-module:*"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_access.backend.reference_options import (
|
||||
SqlAccessReferenceOptionProvider,
|
||||
)
|
||||
from govoplan_core.core.references import ReferenceSearchRequest
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class AccessReferenceOptionProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[Account.__table__, User.__table__, Group.__table__],
|
||||
)
|
||||
self.session = Session(self.engine)
|
||||
for index in range(120):
|
||||
account = Account(
|
||||
id=f"account-{index:03}",
|
||||
email=f"person-{index:03}@example.test",
|
||||
normalized_email=f"person-{index:03}@example.test",
|
||||
)
|
||||
self.session.add(account)
|
||||
self.session.add(
|
||||
User(
|
||||
id=f"membership-{index:03}",
|
||||
tenant_id="tenant-1",
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=f"Person {index:03}",
|
||||
)
|
||||
)
|
||||
for index in range(75):
|
||||
self.session.add(
|
||||
Group(
|
||||
id=f"group-{index:03}",
|
||||
tenant_id="tenant-1",
|
||||
slug=f"group-{index:03}",
|
||||
name=f"Group {index:03}",
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
self.provider = SqlAccessReferenceOptionProvider()
|
||||
self.admin = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-000",
|
||||
group_ids=frozenset(),
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_large_directory_search_is_bounded_and_paged(self) -> None:
|
||||
first = self.provider.search_reference_options(
|
||||
self.session,
|
||||
self.admin,
|
||||
request=ReferenceSearchRequest(
|
||||
kind="membership",
|
||||
tenant_id="tenant-1",
|
||||
limit=25,
|
||||
context={"administrative": True},
|
||||
),
|
||||
)
|
||||
second = self.provider.search_reference_options(
|
||||
self.session,
|
||||
self.admin,
|
||||
request=ReferenceSearchRequest(
|
||||
kind="membership",
|
||||
tenant_id="tenant-1",
|
||||
limit=25,
|
||||
cursor=first.next_cursor,
|
||||
context={"administrative": True},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(25, len(first.options))
|
||||
self.assertTrue(first.has_more)
|
||||
self.assertEqual("offset:25", first.next_cursor)
|
||||
self.assertEqual("membership-000", first.options[0].value)
|
||||
self.assertEqual("membership-025", second.options[0].value)
|
||||
|
||||
def test_search_and_selected_values_do_not_materialize_the_directory(self) -> None:
|
||||
page = self.provider.search_reference_options(
|
||||
self.session,
|
||||
self.admin,
|
||||
request=ReferenceSearchRequest(
|
||||
kind="membership",
|
||||
tenant_id="tenant-1",
|
||||
query="PERSON 119",
|
||||
selected_values=("membership-005", "removed-membership"),
|
||||
limit=10,
|
||||
context={"administrative": True},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
["membership-119", "membership-005"],
|
||||
[option.value for option in page.options],
|
||||
)
|
||||
self.assertFalse(page.has_more)
|
||||
|
||||
def test_non_administrators_only_search_their_permitted_references(self) -> None:
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-004",
|
||||
group_ids=frozenset({"group-007"}),
|
||||
)
|
||||
|
||||
users = self.provider.search_reference_options(
|
||||
self.session,
|
||||
principal,
|
||||
request=ReferenceSearchRequest(
|
||||
kind="user",
|
||||
tenant_id="tenant-1",
|
||||
),
|
||||
)
|
||||
groups = self.provider.search_reference_options(
|
||||
self.session,
|
||||
principal,
|
||||
request=ReferenceSearchRequest(
|
||||
kind="group",
|
||||
tenant_id="tenant-1",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(["account-004"], [option.value for option in users.options])
|
||||
self.assertEqual(["group-007"], [option.value for option in groups.options])
|
||||
|
||||
def test_invalid_cursor_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "Invalid reference search cursor"):
|
||||
self.provider.search_reference_options(
|
||||
self.session,
|
||||
self.admin,
|
||||
request=ReferenceSearchRequest(
|
||||
kind="group",
|
||||
tenant_id="tenant-1",
|
||||
cursor="page:2",
|
||||
context={"administrative": True},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS,
|
||||
AccessExplanationSubjectDecision,
|
||||
PrincipalRef,
|
||||
)
|
||||
from govoplan_access.backend.api.v1.routes import (
|
||||
_access_explanation_subject_decision,
|
||||
)
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _principal() -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
),
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
|
||||
|
||||
class _SubjectPolicy:
|
||||
def decide_subject_selection(
|
||||
self,
|
||||
session: object,
|
||||
principal: PrincipalRef,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> AccessExplanationSubjectDecision:
|
||||
del session, principal, tenant_id
|
||||
return AccessExplanationSubjectDecision(
|
||||
allow_other_users=True,
|
||||
reason="Permitted by test policy.",
|
||||
source="test.policy",
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider: object | None = None) -> None:
|
||||
self.provider = provider
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return (
|
||||
name == CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS
|
||||
and self.provider is not None
|
||||
)
|
||||
|
||||
def require_capability(self, name: str) -> object:
|
||||
if not self.has_capability(name):
|
||||
raise KeyError(name)
|
||||
return self.provider
|
||||
|
||||
|
||||
class ResourceAccessExplanationSubjectTests(unittest.TestCase):
|
||||
def test_missing_policy_defaults_to_current_user(self) -> None:
|
||||
with patch(
|
||||
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||
return_value=None,
|
||||
):
|
||||
decision = _access_explanation_subject_decision(
|
||||
object(), # type: ignore[arg-type]
|
||||
_principal(),
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
|
||||
self.assertFalse(decision.allow_other_users)
|
||||
self.assertEqual("access.safe_default", decision.source)
|
||||
|
||||
def test_policy_capability_controls_cross_user_selection(self) -> None:
|
||||
with patch(
|
||||
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||
return_value=_Registry(_SubjectPolicy()),
|
||||
):
|
||||
decision = _access_explanation_subject_decision(
|
||||
object(), # type: ignore[arg-type]
|
||||
_principal(),
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
|
||||
self.assertTrue(decision.allow_other_users)
|
||||
self.assertEqual("test.policy", decision.source)
|
||||
|
||||
def test_route_contract_is_tenant_bounded_and_audited(self) -> None:
|
||||
source = (
|
||||
ROOT / "src/govoplan_access/backend/api/v1/routes.py"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn('User.tenant_id == tenant.id', source)
|
||||
self.assertIn('User.id == principal.membership_id', source)
|
||||
self.assertIn(
|
||||
'action="access.resource_explanation.selected_user_viewed"',
|
||||
source,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,332 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
AuthSession,
|
||||
User,
|
||||
)
|
||||
from govoplan_access.backend.service_accounts import (
|
||||
ServiceAccountConflictError,
|
||||
create_service_account,
|
||||
create_service_account_credential,
|
||||
revoke_service_account_credential,
|
||||
retire_service_account,
|
||||
rotate_service_account_credential,
|
||||
service_account_credential_summaries,
|
||||
update_service_account,
|
||||
)
|
||||
from govoplan_access.backend.auth.dependencies import (
|
||||
_resolve_api_key_principal_context,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.tenancy.scope import (
|
||||
Tenant,
|
||||
create_scope_tables,
|
||||
scope_registry,
|
||||
)
|
||||
|
||||
|
||||
class ServiceAccountTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
AccessBase.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.tenant = Tenant(
|
||||
id="tenant-1",
|
||||
slug="tenant-1",
|
||||
name="Tenant 1",
|
||||
)
|
||||
self.account = Account(
|
||||
id="account-1",
|
||||
email="admin@example.test",
|
||||
normalized_email="admin@example.test",
|
||||
)
|
||||
self.user = User(
|
||||
id="user-1",
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=self.account.id,
|
||||
email=self.account.email,
|
||||
)
|
||||
self.session.add_all(
|
||||
(self.tenant, self.account, self.user)
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
AccessBase.metadata.drop_all(bind=self.engine)
|
||||
scope_registry.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def _principal(
|
||||
self,
|
||||
scopes: frozenset[str] = frozenset({"tenant:*"}),
|
||||
) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id=self.account.id,
|
||||
membership_id=self.user.id,
|
||||
tenant_id=self.tenant.id,
|
||||
scopes=scopes,
|
||||
),
|
||||
account=self.account,
|
||||
user=self.user,
|
||||
)
|
||||
|
||||
def test_create_builds_a_non_login_identity_without_secrets(self) -> None:
|
||||
item = create_service_account(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
principal=self._principal(),
|
||||
name=" Monthly import ",
|
||||
description="Runs the governed monthly import.",
|
||||
scope_ceiling=(
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
),
|
||||
)
|
||||
|
||||
backing_account = self.session.get(
|
||||
Account,
|
||||
item.account_id,
|
||||
)
|
||||
membership = self.session.get(
|
||||
User,
|
||||
item.membership_id,
|
||||
)
|
||||
self.assertEqual("Monthly import", item.name)
|
||||
self.assertEqual(
|
||||
[
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
],
|
||||
item.scope_ceiling,
|
||||
)
|
||||
self.assertEqual(
|
||||
"service_account",
|
||||
backing_account.auth_provider,
|
||||
)
|
||||
self.assertIsNone(backing_account.password_hash)
|
||||
self.assertEqual(
|
||||
"service_account",
|
||||
membership.auth_provider,
|
||||
)
|
||||
self.assertIsNone(membership.password_hash)
|
||||
self.assertEqual(
|
||||
0,
|
||||
self.session.query(ApiKey)
|
||||
.filter(ApiKey.user_id == membership.id)
|
||||
.count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
0,
|
||||
self.session.query(AuthSession)
|
||||
.filter(AuthSession.user_id == membership.id)
|
||||
.count(),
|
||||
)
|
||||
|
||||
def test_scope_escalation_and_stale_updates_fail_closed(self) -> None:
|
||||
principal = self._principal(
|
||||
frozenset({"dataflow:pipeline:run"})
|
||||
)
|
||||
with self.assertRaises(PermissionError):
|
||||
create_service_account(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
principal=principal,
|
||||
name="Escalating worker",
|
||||
description=None,
|
||||
scope_ceiling=("system:settings:write",),
|
||||
)
|
||||
|
||||
item = create_service_account(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
principal=principal,
|
||||
name="Bounded worker",
|
||||
description=None,
|
||||
scope_ceiling=("dataflow:pipeline:run",),
|
||||
)
|
||||
updated = update_service_account(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
principal=principal,
|
||||
expected_revision=1,
|
||||
changes={"description": "Updated"},
|
||||
)
|
||||
self.assertEqual(2, updated.revision)
|
||||
with self.assertRaises(ServiceAccountConflictError):
|
||||
update_service_account(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
principal=principal,
|
||||
expected_revision=1,
|
||||
changes={"description": "Stale"},
|
||||
)
|
||||
|
||||
def test_retirement_revokes_the_backing_principal(self) -> None:
|
||||
principal = self._principal()
|
||||
item = create_service_account(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
principal=principal,
|
||||
name="Retired worker",
|
||||
description=None,
|
||||
scope_ceiling=("dataflow:pipeline:run",),
|
||||
)
|
||||
|
||||
retired = retire_service_account(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
principal=principal,
|
||||
expected_revision=1,
|
||||
)
|
||||
|
||||
self.assertFalse(retired.is_active)
|
||||
self.assertIsNotNone(retired.retired_at)
|
||||
self.assertFalse(
|
||||
self.session.get(Account, retired.account_id).is_active
|
||||
)
|
||||
self.assertFalse(
|
||||
self.session.get(User, retired.membership_id).is_active
|
||||
)
|
||||
|
||||
def test_credentials_are_one_time_scope_bounded_and_rotatable(self) -> None:
|
||||
principal = self._principal()
|
||||
item = create_service_account(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
principal=principal,
|
||||
name="Monthly worker",
|
||||
description=None,
|
||||
scope_ceiling=("dataflow:pipeline:run",),
|
||||
)
|
||||
item, first = create_service_account_credential(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
principal=principal,
|
||||
expected_revision=1,
|
||||
name="Worker credential",
|
||||
scopes=("dataflow:pipeline:run",),
|
||||
expires_at=None,
|
||||
)
|
||||
self.assertEqual(2, item.revision)
|
||||
self.assertTrue(first.secret.startswith("gpn_"))
|
||||
self.assertNotEqual(first.secret, first.model.key_hash)
|
||||
|
||||
item, previous, replacement = rotate_service_account_credential(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
credential_id=first.model.id,
|
||||
principal=principal,
|
||||
expected_revision=2,
|
||||
name=None,
|
||||
scopes=None,
|
||||
expires_at=None,
|
||||
)
|
||||
self.assertEqual(3, item.revision)
|
||||
self.assertIsNotNone(previous.revoked_at)
|
||||
self.assertIsNone(replacement.model.revoked_at)
|
||||
self.assertNotEqual(first.secret, replacement.secret)
|
||||
|
||||
with self.assertRaises(ServiceAccountConflictError):
|
||||
revoke_service_account_credential(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
credential_id=replacement.model.id,
|
||||
principal=principal,
|
||||
expected_revision=2,
|
||||
)
|
||||
|
||||
item, revoked = revoke_service_account_credential(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
credential_id=replacement.model.id,
|
||||
principal=principal,
|
||||
expected_revision=3,
|
||||
)
|
||||
self.assertEqual(4, item.revision)
|
||||
self.assertIsNotNone(revoked.revoked_at)
|
||||
summary = service_account_credential_summaries(
|
||||
self.session,
|
||||
service_accounts=(item,),
|
||||
)[item.id]
|
||||
self.assertEqual(2, summary.credential_count)
|
||||
self.assertEqual(0, summary.active_credential_count)
|
||||
|
||||
def test_service_account_credential_uses_current_ceiling(self) -> None:
|
||||
principal = self._principal()
|
||||
item = create_service_account(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
principal=principal,
|
||||
name="Bounded API worker",
|
||||
description=None,
|
||||
scope_ceiling=("dataflow:pipeline:run",),
|
||||
)
|
||||
item, created = create_service_account_credential(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
principal=principal,
|
||||
expected_revision=1,
|
||||
name="Runtime",
|
||||
scopes=("dataflow:pipeline:run",),
|
||||
expires_at=None,
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
context = _resolve_api_key_principal_context(
|
||||
self.session,
|
||||
token=created.secret,
|
||||
idm_directory=None,
|
||||
identity_directory=None,
|
||||
organization_directory=None,
|
||||
)
|
||||
self.assertIsNotNone(context)
|
||||
self.assertEqual("service_account", context.principal.auth_method)
|
||||
self.assertEqual(item.id, context.principal.service_account_id)
|
||||
self.assertEqual(created.model.id, context.principal.api_key_id)
|
||||
self.assertEqual(
|
||||
frozenset({"dataflow:pipeline:run"}),
|
||||
context.principal.scopes,
|
||||
)
|
||||
|
||||
update_service_account(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
principal=principal,
|
||||
expected_revision=2,
|
||||
changes={"scope_ceiling": []},
|
||||
)
|
||||
self.session.commit()
|
||||
narrowed = _resolve_api_key_principal_context(
|
||||
self.session,
|
||||
token=created.secret,
|
||||
idm_directory=None,
|
||||
identity_directory=None,
|
||||
organization_directory=None,
|
||||
)
|
||||
self.assertEqual(frozenset(), narrowed.principal.scopes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,277 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import Account, AuthSession, User
|
||||
from govoplan_access.backend.security.sessions import authenticate_session_token, hash_session_token
|
||||
from govoplan_access.backend.security.passwords import hash_password
|
||||
from govoplan_access.backend.api.v1.admin_schemas import AdminSessionItem
|
||||
from govoplan_access.backend.api.v1.auth import AccountSessionInfo
|
||||
from govoplan_access.backend.api.v1.routes import _require_session_admin_reauthorization
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from fastapi import HTTPException
|
||||
from govoplan_access.backend.session_management import (
|
||||
MAX_CLIENT_LABEL_LENGTH,
|
||||
list_account_sessions,
|
||||
revoke_account_session,
|
||||
revoke_other_account_sessions,
|
||||
)
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||
|
||||
|
||||
class SessionManagementTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
AccessBase.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.now = datetime(2026, 8, 19, 20, 0, tzinfo=timezone.utc)
|
||||
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||
other_tenant = Tenant(id="tenant-2", slug="tenant-2", name="Tenant 2")
|
||||
self.account = Account(
|
||||
id="account-1",
|
||||
email="person@example.test",
|
||||
normalized_email="person@example.test",
|
||||
)
|
||||
other_account = Account(
|
||||
id="account-2",
|
||||
email="other@example.test",
|
||||
normalized_email="other@example.test",
|
||||
)
|
||||
user = User(
|
||||
id="user-1",
|
||||
tenant_id=tenant.id,
|
||||
account_id=self.account.id,
|
||||
email=self.account.email,
|
||||
)
|
||||
other_user = User(
|
||||
id="user-2",
|
||||
tenant_id=tenant.id,
|
||||
account_id=other_account.id,
|
||||
email=other_account.email,
|
||||
)
|
||||
self.session.add_all((tenant, other_tenant, self.account, other_account, user, other_user))
|
||||
self.session.flush()
|
||||
self.tokens = {
|
||||
"current": "ms_current",
|
||||
"other": "ms_other",
|
||||
"expired": "ms_expired",
|
||||
"revoked": "ms_revoked",
|
||||
"other-account": "ms_other_account",
|
||||
}
|
||||
self.session.add_all(
|
||||
(
|
||||
self._auth_session("current", "tenant-1", "user-1", "account-1"),
|
||||
self._auth_session("other", "tenant-2", "user-1", "account-1"),
|
||||
self._auth_session("expired", "tenant-1", "user-1", "account-1", expires=-1),
|
||||
self._auth_session("revoked", "tenant-1", "user-1", "account-1", revoked=True),
|
||||
self._auth_session("other-account", "tenant-1", "user-2", "account-2"),
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
AccessBase.metadata.drop_all(bind=self.engine)
|
||||
scope_registry.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def _auth_session(
|
||||
self,
|
||||
name: str,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
account_id: str,
|
||||
*,
|
||||
expires: int = 2,
|
||||
revoked: bool = False,
|
||||
) -> AuthSession:
|
||||
return AuthSession(
|
||||
id=f"session-{name}",
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
token_hash=hash_session_token(self.tokens[name]),
|
||||
expires_at=self.now + timedelta(hours=expires),
|
||||
last_seen_at=self.now - timedelta(minutes=5),
|
||||
revoked_at=self.now - timedelta(minutes=1) if revoked else None,
|
||||
user_agent="Browser " + ("x" * 500),
|
||||
ip_address="192.0.2.55",
|
||||
)
|
||||
|
||||
def test_listing_is_account_scoped_bounded_and_redacted(self) -> None:
|
||||
sensitive = {
|
||||
"token",
|
||||
"token_hash",
|
||||
"csrf_token_hash",
|
||||
"cookie",
|
||||
"ip_address",
|
||||
}
|
||||
self.assertTrue(sensitive.isdisjoint(AccountSessionInfo.model_fields))
|
||||
self.assertTrue(sensitive.isdisjoint(AdminSessionItem.model_fields))
|
||||
active = list_account_sessions(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
current_session_id="session-current",
|
||||
now=self.now,
|
||||
)
|
||||
self.assertEqual({"session-current", "session-other"}, {item.id for item in active})
|
||||
self.assertTrue(next(item for item in active if item.id == "session-current").current)
|
||||
self.assertTrue(all(len(item.client or "") <= MAX_CLIENT_LABEL_LENGTH for item in active))
|
||||
self.assertNotIn("192.0.2.55", repr(active))
|
||||
self.assertNotIn("token_hash", repr(active))
|
||||
|
||||
all_states = list_account_sessions(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
current_session_id="session-current",
|
||||
include_inactive=True,
|
||||
now=self.now,
|
||||
)
|
||||
self.assertEqual(
|
||||
{"active", "expired", "revoked"},
|
||||
{item.status for item in all_states},
|
||||
)
|
||||
tenant_only = list_account_sessions(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
current_session_id="session-current",
|
||||
tenant_id="tenant-1",
|
||||
include_inactive=True,
|
||||
now=self.now,
|
||||
)
|
||||
self.assertNotIn("session-other", {item.id for item in tenant_only})
|
||||
|
||||
def test_single_revocation_is_idempotent_and_effective_on_next_request(self) -> None:
|
||||
item, changed = revoke_account_session(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
session_id="session-other",
|
||||
protected_session_id="session-current",
|
||||
now=self.now,
|
||||
)
|
||||
self.assertTrue(changed)
|
||||
self.session.commit()
|
||||
self.assertIsNotNone(item)
|
||||
self.assertIsNone(
|
||||
authenticate_session_token(self.session, self.tokens["other"])
|
||||
)
|
||||
|
||||
repeated, changed = revoke_account_session(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
session_id="session-other",
|
||||
protected_session_id="session-current",
|
||||
now=self.now,
|
||||
)
|
||||
self.assertIs(item, repeated)
|
||||
self.assertFalse(changed)
|
||||
hidden, changed = revoke_account_session(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
session_id="session-other-account",
|
||||
now=self.now,
|
||||
)
|
||||
self.assertIsNone(hidden)
|
||||
self.assertFalse(changed)
|
||||
|
||||
def test_listing_applies_activity_filter_and_limit_before_loading_history(self) -> None:
|
||||
statements: list[str] = []
|
||||
|
||||
def capture_query(connection, cursor, statement, parameters, context, executemany):
|
||||
if statement.lstrip().upper().startswith("SELECT") and "access_auth_sessions" in statement:
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(self.engine, "before_cursor_execute", capture_query)
|
||||
try:
|
||||
for include_inactive in (False, True):
|
||||
with self.subTest(include_inactive=include_inactive):
|
||||
statements.clear()
|
||||
summaries = list_account_sessions(
|
||||
self.session,
|
||||
account_id="account-1",
|
||||
current_session_id="session-current",
|
||||
include_inactive=include_inactive,
|
||||
limit=1,
|
||||
now=self.now,
|
||||
)
|
||||
self.assertEqual(1, len(summaries))
|
||||
self.assertEqual(1, len(statements))
|
||||
self.assertIn("LIMIT", statements[0])
|
||||
if not include_inactive:
|
||||
self.assertEqual("active", summaries[0].status)
|
||||
self.assertIn("revoked_at IS NULL", statements[0])
|
||||
self.assertIn("expires_at >", statements[0])
|
||||
finally:
|
||||
event.remove(self.engine, "before_cursor_execute", capture_query)
|
||||
|
||||
def test_current_session_is_protected_and_revoke_others_skips_expired(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "current session"):
|
||||
revoke_account_session(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
session_id="session-current",
|
||||
protected_session_id="session-current",
|
||||
now=self.now,
|
||||
)
|
||||
revoked = revoke_other_account_sessions(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
current_session_id="session-current",
|
||||
now=self.now,
|
||||
)
|
||||
self.assertEqual(("session-other",), revoked)
|
||||
self.assertIsNone(self.session.get(AuthSession, "session-current").revoked_at)
|
||||
self.assertIsNone(self.session.get(AuthSession, "session-expired").revoked_at)
|
||||
self.assertEqual(
|
||||
(),
|
||||
revoke_other_account_sessions(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
current_session_id="session-current",
|
||||
now=self.now,
|
||||
),
|
||||
)
|
||||
|
||||
def test_administrative_revocation_requires_session_and_current_password(self) -> None:
|
||||
self.account.password_hash = hash_password("correct horse")
|
||||
membership = self.session.get(User, "user-1")
|
||||
current = self.session.get(AuthSession, "session-current")
|
||||
principal_ref = PrincipalRef(
|
||||
account_id=self.account.id,
|
||||
membership_id=membership.id,
|
||||
tenant_id=membership.tenant_id,
|
||||
scopes=frozenset({"access:membership:update"}),
|
||||
auth_method="session",
|
||||
session_id=current.id,
|
||||
)
|
||||
without_session = ApiPrincipal(
|
||||
principal=principal_ref,
|
||||
account=self.account,
|
||||
user=membership,
|
||||
)
|
||||
with self.assertRaises(HTTPException) as missing:
|
||||
_require_session_admin_reauthorization(without_session, "correct horse")
|
||||
self.assertEqual(403, missing.exception.status_code)
|
||||
|
||||
principal = ApiPrincipal(
|
||||
principal=principal_ref,
|
||||
account=self.account,
|
||||
user=membership,
|
||||
auth_session=current,
|
||||
)
|
||||
with self.assertRaises(HTTPException) as incorrect:
|
||||
_require_session_admin_reauthorization(principal, "incorrect")
|
||||
self.assertEqual(403, incorrect.exception.status_code)
|
||||
_require_session_admin_reauthorization(principal, "correct horse")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
AuthSession,
|
||||
Group,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from govoplan_access.backend.tenant_erasure_provider import (
|
||||
AccessTenantErasureProvider,
|
||||
)
|
||||
|
||||
|
||||
def test_access_erasure_is_tenant_bounded_and_retains_global_account() -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
AccessBase.metadata.create_all(engine)
|
||||
now = datetime.now(UTC)
|
||||
with Session(engine) as session:
|
||||
account = Account(
|
||||
email="shared@example.test",
|
||||
normalized_email="shared@example.test",
|
||||
is_active=True,
|
||||
auth_provider="local",
|
||||
)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
first = User(
|
||||
tenant_id="tenant-1",
|
||||
account_id=account.id,
|
||||
email="shared@example.test",
|
||||
is_active=True,
|
||||
is_tenant_admin=True,
|
||||
auth_provider="local",
|
||||
)
|
||||
second = User(
|
||||
tenant_id="tenant-2",
|
||||
account_id=account.id,
|
||||
email="shared@example.test",
|
||||
is_active=True,
|
||||
is_tenant_admin=False,
|
||||
auth_provider="local",
|
||||
)
|
||||
session.add_all(
|
||||
[
|
||||
first,
|
||||
second,
|
||||
Group(tenant_id="tenant-1", slug="group", name="Group"),
|
||||
Group(tenant_id="tenant-2", slug="group", name="Group"),
|
||||
Role(tenant_id="tenant-1", slug="role", name="Role"),
|
||||
Role(tenant_id="tenant-2", slug="role", name="Role"),
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
session.add_all(
|
||||
[
|
||||
ApiKey(
|
||||
tenant_id="tenant-1",
|
||||
user_id=first.id,
|
||||
name="key",
|
||||
prefix="prefix",
|
||||
key_hash="hash",
|
||||
scopes=[],
|
||||
),
|
||||
AuthSession(
|
||||
tenant_id="tenant-1",
|
||||
user_id=first.id,
|
||||
account_id=account.id,
|
||||
token_hash="token-hash",
|
||||
expires_at=now + timedelta(hours=1),
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
provider = AccessTenantErasureProvider()
|
||||
preview = provider.preview_tenant_erasure(session, "tenant-1")
|
||||
|
||||
assert preview.allowed
|
||||
assert [step.step_id for step in preview.steps] == [
|
||||
"revoke-tenant-credentials",
|
||||
"erase-tenant-access",
|
||||
]
|
||||
assert "revoke-tenant-credentials" in preview.steps[1].depends_on
|
||||
|
||||
revoked = provider.execute_tenant_erasure_step(
|
||||
session,
|
||||
"tenant-1",
|
||||
"revoke-tenant-credentials",
|
||||
"operation:access:credentials",
|
||||
)
|
||||
erased = provider.execute_tenant_erasure_step(
|
||||
session,
|
||||
"tenant-1",
|
||||
"erase-tenant-access",
|
||||
"operation:access:tenant",
|
||||
)
|
||||
session.commit()
|
||||
|
||||
assert revoked.state == "completed"
|
||||
assert erased.state == "completed"
|
||||
assert provider.preview_tenant_erasure(session, "tenant-1").steps == ()
|
||||
assert session.scalar(select(Account).where(Account.id == account.id)) is not None
|
||||
assert session.scalar(select(User).where(User.tenant_id == "tenant-2")) is not None
|
||||
assert session.scalar(select(Group).where(Group.tenant_id == "tenant-2")) is not None
|
||||
assert session.scalar(select(Role).where(Role.tenant_id == "tenant-2")) is not None
|
||||
|
||||
|
||||
def test_access_erasure_replay_is_idempotent() -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
AccessBase.metadata.create_all(engine)
|
||||
provider = AccessTenantErasureProvider()
|
||||
with Session(engine) as session:
|
||||
first = provider.execute_tenant_erasure_step(
|
||||
session,
|
||||
"tenant-1",
|
||||
"erase-tenant-access",
|
||||
"operation:access:tenant",
|
||||
)
|
||||
second = provider.reconcile_tenant_erasure_step(
|
||||
session,
|
||||
"tenant-1",
|
||||
"erase-tenant-access",
|
||||
"operation:access:tenant",
|
||||
)
|
||||
|
||||
assert first.metrics == {"deleted": 0}
|
||||
assert second.metrics == {"deleted": 0}
|
||||
+9
-6
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -13,11 +16,11 @@
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.5",
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const read = (path) => readFileSync(resolve(root, path), "utf8");
|
||||
|
||||
const adminPage = read("src/features/admin/AdminPage.tsx");
|
||||
const users = read("src/features/admin/UsersPanel.tsx");
|
||||
const groups = read("src/features/admin/GroupsPanel.tsx");
|
||||
const roles = read("src/features/admin/RolesPanel.tsx");
|
||||
const systemUsers = read("src/features/admin/SystemUsersPanel.tsx");
|
||||
const systemRoles = read("src/features/admin/SystemRolesPanel.tsx");
|
||||
const apiKeys = read("src/features/admin/ApiKeysPanel.tsx");
|
||||
const serviceAccounts = read("src/features/admin/ServiceAccountsPanel.tsx");
|
||||
const mappings = read("src/features/admin/ExternalFunctionRoleMappingsPanel.tsx");
|
||||
const credentials = read("src/features/admin/CredentialEnvelopesPanel.tsx");
|
||||
const files = read("src/features/admin/FileConnectorsPanel.tsx");
|
||||
const mail = read("src/features/admin/MailProfilesPanel.tsx");
|
||||
const moduleSource = read("src/module.ts");
|
||||
const sessions = read("src/features/sessions/SessionSettingsPanel.tsx");
|
||||
const surfaces = [users, groups, roles, systemUsers, systemRoles, apiKeys, mappings];
|
||||
const allAdminSource = [adminPage, credentials, files, mail, serviceAccounts, ...surfaces].join("\n");
|
||||
|
||||
assert.match(adminPage, /TreeSubnav/);
|
||||
assert.match(adminPage, /ActionBlockerHint/);
|
||||
assert.match(adminPage, /ACCESS_WORKFLOW_DOCUMENTATION/);
|
||||
|
||||
for (const source of surfaces) {
|
||||
assert.match(source, /AdminPageLayout/);
|
||||
assert.match(source, /DataGrid/);
|
||||
assert.match(source, /DocumentationHelpLink/);
|
||||
assert.match(source, /disabledReason/);
|
||||
}
|
||||
|
||||
for (const source of [users, groups, roles, systemUsers, systemRoles, apiKeys, mappings]) {
|
||||
assert.match(source, /ConfirmDialog/);
|
||||
}
|
||||
|
||||
assert.match(credentials, /CredentialEnvelopeManager/);
|
||||
assert.match(credentials, /DocumentationHelpLink/);
|
||||
assert.match(files, /usePlatformUiCapability<FilesConnectorsUiCapability>/);
|
||||
assert.match(files, /ActionBlockerHint/);
|
||||
assert.match(mail, /usePlatformUiCapability<MailProfilesUiCapability>/);
|
||||
assert.match(mail, /ActionBlockerHint/);
|
||||
assert.match(serviceAccounts, /Service accounts/);
|
||||
assert.match(serviceAccounts, /createServiceAccountCredential/);
|
||||
assert.match(serviceAccounts, /rotateServiceAccountCredential/);
|
||||
assert.match(serviceAccounts, /revokeServiceAccountCredential/);
|
||||
assert.match(serviceAccounts, /Secrets are shown once/);
|
||||
assert.match(serviceAccounts, /<ConfirmDialog[\s\S]*Retire service account/);
|
||||
|
||||
for (const contextId of [
|
||||
"access.api-keys.action.create",
|
||||
"access.api-keys.action.revoke",
|
||||
"access.api-keys.field.owner",
|
||||
"access.api-keys.field.expiry",
|
||||
"access.api-keys.field.scopes",
|
||||
"access.api-keys.secret",
|
||||
"access.api-keys.confirm-revoke"
|
||||
]) {
|
||||
assert.ok(apiKeys.includes(contextId), `API-key help context ${contextId} is missing`);
|
||||
}
|
||||
|
||||
for (const contextId of [
|
||||
"access.service-accounts.action.create",
|
||||
"access.service-accounts.action.activation",
|
||||
"access.service-accounts.action.retire",
|
||||
"access.service-accounts.field.scope-ceiling",
|
||||
"access.service-accounts.action.rotate-credential",
|
||||
"access.service-accounts.action.revoke-credential",
|
||||
"access.service-accounts.field.credential-expiry",
|
||||
"access.service-accounts.field.credential-scopes",
|
||||
"access.service-accounts.secret",
|
||||
"access.service-accounts.confirm-retire"
|
||||
]) {
|
||||
assert.ok(serviceAccounts.includes(contextId), `Service-account help context ${contextId} is missing`);
|
||||
}
|
||||
assert.match(moduleSource, /access\.admin\.tenant-service-accounts/);
|
||||
assert.match(moduleSource, /access\.settings\.sessions/);
|
||||
assert.match(moduleSource, /"settings\.sections": accessSettingsSections/);
|
||||
assert.match(sessions, /PageActionBar/);
|
||||
assert.match(sessions, /reloadAction/);
|
||||
assert.match(sessions, /destructiveActions/);
|
||||
assert.match(sessions, /DataGrid/);
|
||||
assert.match(sessions, /ConfirmDialog/);
|
||||
assert.doesNotMatch(sessions, /window\.(alert|confirm|prompt)\s*\(/);
|
||||
assert.match(users, /fetchAdminUserSessions/);
|
||||
assert.match(users, /revokeAdminUserSession/);
|
||||
assert.match(users, /admin-user-sessions-v1/);
|
||||
assert.match(users, /PasswordField/);
|
||||
assert.match(users, /canRevokeSessions/);
|
||||
assert.match(moduleSource, /translations,/);
|
||||
assert.match(moduleSource, /version: "0\.1\.11"/);
|
||||
|
||||
assert.doesNotMatch(allAdminSource, /window\.(alert|confirm|prompt)\s*\(/);
|
||||
assert.doesNotMatch(allAdminSource, /@govoplan\/(files|mail|organizations|idm)-webui\//);
|
||||
|
||||
console.log("Access interface pattern-language checks passed.");
|
||||
@@ -0,0 +1,31 @@
|
||||
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type ActingContext = {
|
||||
assignment_id: string;
|
||||
acting_for_account_id: string;
|
||||
function_id: string;
|
||||
organization_unit_id: string;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
};
|
||||
|
||||
export type ActingContextList = {
|
||||
contexts: ActingContext[];
|
||||
active_assignment_id?: string | null;
|
||||
};
|
||||
|
||||
export function fetchActingContexts(settings: ApiSettings): Promise<ActingContextList> {
|
||||
return apiFetch<ActingContextList>(settings, "/api/v1/auth/acting-contexts", {
|
||||
cache: "no-store"
|
||||
});
|
||||
}
|
||||
|
||||
export function switchActingContext(
|
||||
settings: ApiSettings,
|
||||
assignmentId: string | null
|
||||
): Promise<ActingContextList> {
|
||||
return apiFetch<ActingContextList>(settings, "/api/v1/auth/switch-acting-context", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ assignment_id: assignmentId })
|
||||
});
|
||||
}
|
||||
+301
-231
@@ -1,53 +1,24 @@
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { apiFetch } from "@govoplan/core-webui";
|
||||
|
||||
export type PermissionItem = {
|
||||
scope: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: string;
|
||||
level: "tenant" | "system";
|
||||
system_template_id?: string | null;
|
||||
system_required?: boolean;
|
||||
};
|
||||
|
||||
export type AdminOverview = {
|
||||
active_tenant_id: string;
|
||||
active_tenant_name: string;
|
||||
tenant_count?: number | null;
|
||||
system_account_count?: number | null;
|
||||
system_group_template_count?: number | null;
|
||||
system_role_template_count?: number | null;
|
||||
user_count: number;
|
||||
active_user_count: number;
|
||||
group_count: number;
|
||||
role_count: number;
|
||||
active_api_key_count: number;
|
||||
capabilities: string[];
|
||||
};
|
||||
|
||||
export type TenantAdminItem = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
default_locale: string;
|
||||
settings: Record<string, unknown>;
|
||||
allow_custom_groups?: boolean | null;
|
||||
allow_custom_roles?: boolean | null;
|
||||
allow_api_keys?: boolean | null;
|
||||
effective_governance: Record<string, boolean>;
|
||||
is_active: boolean;
|
||||
counts: Record<string, number>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type TenantOwnerCandidate = {
|
||||
account_id: string;
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
};
|
||||
import type {
|
||||
AccessDecisionProvenanceItem as CoreAccessDecisionProvenanceItem,
|
||||
ApiSettings,
|
||||
DeltaDeletedItem,
|
||||
PrivacyRetentionPolicy,
|
||||
ResourceAccessExplanationOptions,
|
||||
ResourceAccessExplanationResponse as CoreResourceAccessExplanationResponse,
|
||||
TenantAdminItem
|
||||
} from "@govoplan/core-webui";
|
||||
import { apiFetch, apiGetList, apiPath, apiQuery, fetchResourceAccessExplanation as fetchCoreResourceAccessExplanation } from "@govoplan/core-webui";
|
||||
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
|
||||
export type {
|
||||
AdminOverview,
|
||||
PrivacyRetentionLimitPermissionPatch,
|
||||
PrivacyRetentionLimitPermissions,
|
||||
PrivacyRetentionPolicy,
|
||||
PrivacyRetentionPolicyFieldKey,
|
||||
PrivacyRetentionPolicyPatch,
|
||||
PermissionItem,
|
||||
TenantAdminItem
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
export type RoleSummary = {
|
||||
id: string;
|
||||
@@ -92,6 +63,8 @@ export type UserAdminItem = {
|
||||
last_login_at?: string | null;
|
||||
groups: GroupSummary[];
|
||||
roles: RoleSummary[];
|
||||
function_assignment_ids: string[];
|
||||
function_delegation_ids: string[];
|
||||
effective_scopes: string[];
|
||||
is_owner: boolean;
|
||||
is_last_active_owner: boolean;
|
||||
@@ -99,6 +72,69 @@ export type UserAdminItem = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AccessRoleSourceType = "direct_role" | "group_role" | "legacy_function_role" | "idm_function_role" | "system_role";
|
||||
|
||||
export type AccessRoleSourceItem = {
|
||||
source_type: AccessRoleSourceType;
|
||||
role_id: string;
|
||||
role_slug: string;
|
||||
role_name: string;
|
||||
permissions: string[];
|
||||
tenant_id?: string | null;
|
||||
group_id?: string | null;
|
||||
group_name?: string | null;
|
||||
function_assignment_id?: string | null;
|
||||
function_id?: string | null;
|
||||
function_name?: string | null;
|
||||
organization_unit_id?: string | null;
|
||||
organization_unit_name?: string | null;
|
||||
identity_id?: string | null;
|
||||
account_id?: string | null;
|
||||
source_module?: string | null;
|
||||
assignment_source?: string | null;
|
||||
applies_to_subunits: boolean;
|
||||
delegated_from_assignment_id?: string | null;
|
||||
delegation_id?: string | null;
|
||||
acting_for_account_id?: string | null;
|
||||
};
|
||||
|
||||
export type AccessScopeExplanationItem = {
|
||||
scope: string;
|
||||
sources: AccessRoleSourceItem[];
|
||||
};
|
||||
|
||||
export type AccessDecisionProvenanceItem = Omit<CoreAccessDecisionProvenanceItem, "details"> & {
|
||||
details: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type FunctionFactExplanationItem = {
|
||||
source_module: string;
|
||||
assignment_id: string;
|
||||
tenant_id: string;
|
||||
identity_id?: string | null;
|
||||
account_id?: string | null;
|
||||
function_id: string;
|
||||
function_name?: string | null;
|
||||
organization_unit_id: string;
|
||||
organization_unit_name?: string | null;
|
||||
applies_to_subunits: boolean;
|
||||
assignment_source: string;
|
||||
status: string;
|
||||
delegated_from_assignment_id?: string | null;
|
||||
acting_for_account_id?: string | null;
|
||||
role_ids: string[];
|
||||
role_names: string[];
|
||||
};
|
||||
|
||||
export type UserAccessExplanationResponse = {
|
||||
user: UserAdminItem;
|
||||
role_sources: AccessRoleSourceItem[];
|
||||
scopes: AccessScopeExplanationItem[];
|
||||
function_facts: FunctionFactExplanationItem[];
|
||||
};
|
||||
|
||||
export type ResourceAccessExplanationResponse = CoreResourceAccessExplanationResponse<UserAdminItem, AccessDecisionProvenanceItem>;
|
||||
|
||||
export type SystemAccountItem = {
|
||||
account_id: string;
|
||||
email: string;
|
||||
@@ -119,77 +155,21 @@ export type SystemMembershipDraft = {
|
||||
is_last_active_owner?: boolean;
|
||||
};
|
||||
|
||||
export type PrivacyRetentionPolicyFieldKey =
|
||||
| "store_raw_campaign_json"
|
||||
| "raw_campaign_json_retention_days"
|
||||
| "generated_eml_retention_days"
|
||||
| "stored_report_detail_retention_days"
|
||||
| "mock_mailbox_retention_days"
|
||||
| "audit_detail_retention_days"
|
||||
| "audit_detail_level";
|
||||
|
||||
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
|
||||
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
|
||||
|
||||
export type PrivacyRetentionPolicy = {
|
||||
store_raw_campaign_json: boolean;
|
||||
raw_campaign_json_retention_days?: number | null;
|
||||
generated_eml_retention_days?: number | null;
|
||||
stored_report_detail_retention_days?: number | null;
|
||||
mock_mailbox_retention_days?: number | null;
|
||||
audit_detail_retention_days?: number | null;
|
||||
audit_detail_level: "full" | "redacted" | "minimal";
|
||||
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
|
||||
};
|
||||
|
||||
export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
|
||||
allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch;
|
||||
};
|
||||
export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
|
||||
|
||||
export type PolicySourceStep = {
|
||||
scope_type: string;
|
||||
scope_id?: string | null;
|
||||
label: string;
|
||||
applied_fields?: string[];
|
||||
policy?: PrivacyRetentionPolicyPatch | PrivacyRetentionPolicy | null;
|
||||
};
|
||||
|
||||
export type PrivacyRetentionPolicyScopeResponse = {
|
||||
scope_type: PrivacyRetentionPolicyScope;
|
||||
scope_id?: string | null;
|
||||
policy: PrivacyRetentionPolicyPatch;
|
||||
effective_policy: PrivacyRetentionPolicy;
|
||||
parent_policy?: PrivacyRetentionPolicy | null;
|
||||
effective_policy_sources?: PolicySourceStep[];
|
||||
parent_policy_sources?: PolicySourceStep[];
|
||||
};
|
||||
|
||||
export type SystemSettingsItem = {
|
||||
default_locale: string;
|
||||
allow_tenant_custom_groups: boolean;
|
||||
allow_tenant_custom_roles: boolean;
|
||||
allow_tenant_api_keys: boolean;
|
||||
privacy_retention_policy: PrivacyRetentionPolicy;
|
||||
available_languages?: LanguagePackage[];
|
||||
enabled_language_codes?: string[];
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TenantSettingsItem = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
default_locale: string;
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type RetentionRunResponse = {
|
||||
result: {
|
||||
dry_run: boolean;
|
||||
policy: PrivacyRetentionPolicy;
|
||||
cutoffs: Record<string, string | null>;
|
||||
effective_policy_scope?: string;
|
||||
counts: Record<string, Record<string, number>>;
|
||||
};
|
||||
export type LanguagePackage = {
|
||||
code: string;
|
||||
label: string;
|
||||
native_label?: string | null;
|
||||
};
|
||||
|
||||
export type GovernanceAssignment = {
|
||||
@@ -224,80 +204,85 @@ export type ApiKeyAdminItem = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type AuditAdminItem = {
|
||||
export type ServiceAccountItem = {
|
||||
id: string;
|
||||
scope: "tenant" | "system";
|
||||
tenant_id?: string | null;
|
||||
actor_email?: string | null;
|
||||
action: string;
|
||||
object_type?: string | null;
|
||||
object_id?: string | null;
|
||||
details: Record<string, unknown>;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
scope_ceiling: string[];
|
||||
is_active: boolean;
|
||||
revision: number;
|
||||
credential_count: number;
|
||||
active_credential_count: number;
|
||||
last_credential_used_at?: string | null;
|
||||
retired_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ServiceAccountCredentialItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
scopes: string[];
|
||||
expires_at?: string | null;
|
||||
last_used_at?: string | null;
|
||||
revoked_at?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ServiceAccountCredentialListResponse = {
|
||||
service_account_revision: number;
|
||||
items: ServiceAccountCredentialItem[];
|
||||
};
|
||||
|
||||
export type ServiceAccountCredentialMutationResponse = {
|
||||
service_account_revision: number;
|
||||
credential: ServiceAccountCredentialItem;
|
||||
};
|
||||
|
||||
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
|
||||
return apiFetch(settings, "/api/v1/admin/overview");
|
||||
}
|
||||
|
||||
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
|
||||
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
|
||||
return response.permissions;
|
||||
}
|
||||
|
||||
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
|
||||
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
|
||||
return response.tenants;
|
||||
}
|
||||
|
||||
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
|
||||
const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates");
|
||||
return response.accounts;
|
||||
}
|
||||
|
||||
export function createTenant(settings: ApiSettings, payload: {
|
||||
slug: string;
|
||||
name: string;
|
||||
owner_account_id?: string | null;
|
||||
description?: string | null;
|
||||
default_locale?: string;
|
||||
settings?: Record<string, unknown>;
|
||||
allow_custom_groups?: boolean | null;
|
||||
allow_custom_roles?: boolean | null;
|
||||
allow_api_keys?: boolean | null;
|
||||
}): Promise<TenantAdminItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/tenants", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateTenant(settings: ApiSettings, tenantId: string, payload: Partial<{
|
||||
name: string;
|
||||
description: string | null;
|
||||
default_locale: string;
|
||||
export type ExternalFunctionRoleMappingItem = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
source_module: string;
|
||||
function_id: string;
|
||||
role_id: string;
|
||||
settings: Record<string, unknown>;
|
||||
allow_custom_groups?: boolean | null;
|
||||
allow_custom_roles?: boolean | null;
|
||||
allow_api_keys?: boolean | null;
|
||||
effective_governance: Record<string, boolean>;
|
||||
is_active: boolean;
|
||||
}>): Promise<TenantAdminItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type DeltaResponseFields = {
|
||||
deleted: DeltaDeletedItem[];
|
||||
watermark?: string | null;
|
||||
has_more: boolean;
|
||||
full: boolean;
|
||||
};
|
||||
|
||||
export type UserListDeltaResponse = { users: UserAdminItem[] } & DeltaResponseFields;
|
||||
export type GroupListDeltaResponse = { groups: GroupSummary[] } & DeltaResponseFields;
|
||||
export type RoleListDeltaResponse = { roles: RoleSummary[] } & DeltaResponseFields;
|
||||
export type SystemAccountListDeltaResponse = { accounts: SystemAccountItem[]; roles: RoleSummary[] } & DeltaResponseFields;
|
||||
export type ApiKeyListDeltaResponse = { api_keys: ApiKeyAdminItem[] } & DeltaResponseFields;
|
||||
export type GovernanceTemplateListDeltaResponse = { templates: GovernanceTemplateItem[] } & DeltaResponseFields;
|
||||
export type ExternalFunctionRoleMappingListDeltaResponse = { mappings: ExternalFunctionRoleMappingItem[] } & DeltaResponseFields;
|
||||
|
||||
function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
|
||||
return apiQuery(options);
|
||||
}
|
||||
|
||||
export function fetchTenantSettings(settings: ApiSettings): Promise<TenantSettingsItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/tenant/settings");
|
||||
}
|
||||
|
||||
export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string }): Promise<TenantSettingsItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/tenant/settings", { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> {
|
||||
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users");
|
||||
return response.users;
|
||||
}
|
||||
|
||||
export function fetchUsersDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<UserListDeltaResponse> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/users/delta${suffix}`);
|
||||
}
|
||||
|
||||
export function createUser(settings: ApiSettings, payload: {
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
@@ -319,9 +304,24 @@ export function updateUser(settings: ApiSettings, userId: string, payload: Parti
|
||||
return apiFetch(settings, `/api/v1/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function fetchUserAccessExplanation(settings: ApiSettings, userId: string): Promise<UserAccessExplanationResponse> {
|
||||
return apiFetch(settings, `/api/v1/admin/users/${userId}/access-explanation`);
|
||||
}
|
||||
|
||||
export function fetchResourceAccessExplanation(
|
||||
settings: ApiSettings,
|
||||
options: ResourceAccessExplanationOptions
|
||||
): Promise<ResourceAccessExplanationResponse> {
|
||||
return fetchCoreResourceAccessExplanation<UserAdminItem, AccessDecisionProvenanceItem>(settings, options);
|
||||
}
|
||||
|
||||
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
|
||||
const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups");
|
||||
return response.groups;
|
||||
return apiGetList<GroupSummary, "groups">(settings, "/api/v1/admin/groups", "groups");
|
||||
}
|
||||
|
||||
export function fetchGroupsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GroupListDeltaResponse> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/groups/delta${suffix}`);
|
||||
}
|
||||
|
||||
export function createGroup(settings: ApiSettings, payload: {
|
||||
@@ -346,8 +346,12 @@ export function updateGroup(settings: ApiSettings, groupId: string, payload: Par
|
||||
}
|
||||
|
||||
export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> {
|
||||
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles");
|
||||
return response.roles;
|
||||
return apiGetList<RoleSummary, "roles">(settings, "/api/v1/admin/roles", "roles");
|
||||
}
|
||||
|
||||
export function fetchRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/roles/delta${suffix}`);
|
||||
}
|
||||
|
||||
export function createRole(settings: ApiSettings, payload: {
|
||||
@@ -372,9 +376,38 @@ export function deleteRole(settings: ApiSettings, roleId: string): Promise<void>
|
||||
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function fetchExternalFunctionRoleMappingsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<ExternalFunctionRoleMappingListDeltaResponse> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/external-function-role-mappings/delta${suffix}`);
|
||||
}
|
||||
|
||||
export function createExternalFunctionRoleMapping(settings: ApiSettings, payload: {
|
||||
source_module: string;
|
||||
function_id: string;
|
||||
role_id: string;
|
||||
settings?: Record<string, unknown>;
|
||||
}): Promise<ExternalFunctionRoleMappingItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/external-function-role-mappings", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateExternalFunctionRoleMapping(settings: ApiSettings, mappingId: string, payload: {
|
||||
role_id?: string;
|
||||
settings?: Record<string, unknown>;
|
||||
}): Promise<ExternalFunctionRoleMappingItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/external-function-role-mappings/${mappingId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function deleteExternalFunctionRoleMapping(settings: ApiSettings, mappingId: string): Promise<void> {
|
||||
return apiFetch(settings, `/api/v1/admin/external-function-role-mappings/${mappingId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> {
|
||||
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles");
|
||||
return response.roles;
|
||||
return apiGetList<RoleSummary, "roles">(settings, "/api/v1/admin/system/roles", "roles");
|
||||
}
|
||||
|
||||
export function fetchSystemRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/system/roles/delta${suffix}`);
|
||||
}
|
||||
|
||||
export function createSystemRole(settings: ApiSettings, payload: {
|
||||
@@ -403,6 +436,11 @@ export async function fetchSystemAccounts(settings: ApiSettings): Promise<{ acco
|
||||
return apiFetch(settings, "/api/v1/admin/system/accounts");
|
||||
}
|
||||
|
||||
export function fetchSystemAccountsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<SystemAccountListDeltaResponse> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/system/accounts/delta${suffix}`);
|
||||
}
|
||||
|
||||
export function updateSystemAccount(settings: ApiSettings, accountId: string, payload: {
|
||||
display_name?: string | null;
|
||||
is_active?: boolean;
|
||||
@@ -422,11 +460,15 @@ export function updateSystemAccountRoles(settings: ApiSettings, accountId: strin
|
||||
}
|
||||
|
||||
export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (includeRevoked) params.set("include_revoked", "true");
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`);
|
||||
return response.api_keys;
|
||||
return apiGetList<ApiKeyAdminItem, "api_keys">(settings, "/api/v1/admin/api-keys", "api_keys", { include_revoked: includeRevoked ? true : undefined });
|
||||
}
|
||||
|
||||
export function fetchApiKeysDelta(settings: ApiSettings, includeRevoked = false, options: { since?: string | null; limit?: number } = {}): Promise<ApiKeyListDeltaResponse> {
|
||||
return apiFetch(settings, apiPath("/api/v1/admin/api-keys/delta", {
|
||||
include_revoked: includeRevoked ? true : undefined,
|
||||
since: options.since,
|
||||
limit: options.limit
|
||||
}));
|
||||
}
|
||||
|
||||
export function createApiKey(settings: ApiSettings, payload: {
|
||||
@@ -442,37 +484,78 @@ export function revokeApiKey(settings: ApiSettings, keyId: string): Promise<ApiK
|
||||
return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" });
|
||||
}
|
||||
|
||||
export type AuditQueryOptions = {
|
||||
tenantId?: string | null;
|
||||
allTenants?: boolean;
|
||||
scope?: "tenant" | "system";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: "time" | "actor" | "action" | "object" | "tenant";
|
||||
sortDirection?: "asc" | "desc";
|
||||
filters?: Partial<Record<"time" | "actor" | "action" | "object" | "tenant", string>>;
|
||||
};
|
||||
|
||||
export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number }> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.tenantId) params.set("tenant_id", options.tenantId);
|
||||
if (options.allTenants) params.set("all_tenants", "true");
|
||||
if (options.scope) params.set("scope", options.scope);
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
if (options.offset) params.set("offset", String(options.offset));
|
||||
if (options.page) params.set("page", String(options.page));
|
||||
if (options.pageSize) params.set("page_size", String(options.pageSize));
|
||||
if (options.sortBy) params.set("sort_by", options.sortBy);
|
||||
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
|
||||
for (const [column, value] of Object.entries(options.filters ?? {})) {
|
||||
if (value?.trim()) params.set(`filter_${column}`, value);
|
||||
}
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch(settings, `/api/v1/admin/audit${suffix}`);
|
||||
export async function fetchServiceAccounts(settings: ApiSettings): Promise<ServiceAccountItem[]> {
|
||||
const response = await apiFetch<{ items: ServiceAccountItem[] }>(settings, "/api/v1/admin/service-accounts");
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export function createServiceAccount(settings: ApiSettings, payload: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
scope_ceiling: string[];
|
||||
}): Promise<ServiceAccountItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/service-accounts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateServiceAccount(settings: ApiSettings, serviceAccountId: string, payload: {
|
||||
expected_revision: number;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
scope_ceiling?: string[];
|
||||
is_active?: boolean;
|
||||
}): Promise<ServiceAccountItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function retireServiceAccount(settings: ApiSettings, serviceAccountId: string, expectedRevision: number): Promise<ServiceAccountItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/retire`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expected_revision: expectedRevision })
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchServiceAccountCredentials(settings: ApiSettings, serviceAccountId: string, includeRevoked = true): Promise<ServiceAccountCredentialListResponse> {
|
||||
return apiFetch(settings, apiPath(`/api/v1/admin/service-accounts/${serviceAccountId}/credentials`, {
|
||||
include_revoked: includeRevoked
|
||||
}));
|
||||
}
|
||||
|
||||
export function createServiceAccountCredential(settings: ApiSettings, serviceAccountId: string, payload: {
|
||||
expected_revision: number;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
expires_at?: string | null;
|
||||
}): Promise<ServiceAccountCredentialMutationResponse & { secret: string }> {
|
||||
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/credentials`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function rotateServiceAccountCredential(settings: ApiSettings, serviceAccountId: string, credentialId: string, payload: {
|
||||
expected_revision: number;
|
||||
name?: string | null;
|
||||
scopes?: string[] | null;
|
||||
expires_at?: string | null;
|
||||
}): Promise<ServiceAccountCredentialMutationResponse & { secret: string }> {
|
||||
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/credentials/${credentialId}/rotate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function revokeServiceAccountCredential(settings: ApiSettings, serviceAccountId: string, credentialId: string, expectedRevision: number): Promise<ServiceAccountCredentialMutationResponse> {
|
||||
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/credentials/${credentialId}/revoke`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expected_revision: expectedRevision })
|
||||
});
|
||||
}
|
||||
|
||||
export function createSystemAccount(settings: ApiSettings, payload: {
|
||||
email: string;
|
||||
@@ -502,34 +585,21 @@ export type SystemSettingsUpdatePayload = {
|
||||
allow_tenant_custom_roles: boolean;
|
||||
allow_tenant_api_keys: boolean;
|
||||
privacy_retention_policy?: PrivacyRetentionPolicy | null;
|
||||
available_languages?: LanguagePackage[] | null;
|
||||
enabled_language_codes?: string[] | null;
|
||||
};
|
||||
|
||||
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function getPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (scopeId) params.set("scope_id", scopeId);
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`);
|
||||
}
|
||||
|
||||
export function updatePrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, policy: PrivacyRetentionPolicyPatch, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (scopeId) params.set("scope_id", scopeId);
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`, { method: "PUT", body: JSON.stringify({ policy }) });
|
||||
}
|
||||
|
||||
export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise<RetentionRunResponse> {
|
||||
return apiFetch(settings, "/api/v1/admin/system/retention/run", { method: "POST", body: JSON.stringify({ dry_run: dryRun }) });
|
||||
}
|
||||
|
||||
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
|
||||
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
|
||||
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
|
||||
return response.templates;
|
||||
return apiGetList<GovernanceTemplateItem, "templates">(settings, "/api/v1/admin/system/governance-templates", "templates", { kind });
|
||||
}
|
||||
|
||||
export function fetchGovernanceTemplatesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GovernanceTemplateListDeltaResponse> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/system/governance-templates/delta${suffix}`);
|
||||
}
|
||||
|
||||
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type AccountSession = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
current: boolean;
|
||||
status: "active" | "expired" | "revoked";
|
||||
created_at: string;
|
||||
last_seen_at?: string | null;
|
||||
expires_at: string;
|
||||
revoked_at?: string | null;
|
||||
client?: string | null;
|
||||
};
|
||||
|
||||
export type AccountSessionList = {
|
||||
sessions: AccountSession[];
|
||||
};
|
||||
|
||||
export function fetchAccountSessions(
|
||||
settings: ApiSettings
|
||||
): Promise<AccountSessionList> {
|
||||
return apiFetch<AccountSessionList>(settings, "/api/v1/auth/sessions", {
|
||||
cache: "no-store"
|
||||
});
|
||||
}
|
||||
|
||||
export function revokeAccountSession(
|
||||
settings: ApiSettings,
|
||||
sessionId: string
|
||||
): Promise<{ session: AccountSession; revoked: boolean }> {
|
||||
return apiFetch(settings, `/api/v1/auth/sessions/${encodeURIComponent(sessionId)}/revoke`, {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
|
||||
export function revokeOtherAccountSessions(
|
||||
settings: ApiSettings
|
||||
): Promise<{ revoked_count: number }> {
|
||||
return apiFetch(settings, "/api/v1/auth/sessions/revoke-others", {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchAdminUserSessions(
|
||||
settings: ApiSettings,
|
||||
userId: string
|
||||
): Promise<AccountSessionList> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/admin/users/${encodeURIComponent(userId)}/sessions`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
}
|
||||
|
||||
export function revokeAdminUserSession(
|
||||
settings: ApiSettings,
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
currentPassword: string
|
||||
): Promise<{ session: AccountSession; revoked: boolean }> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/admin/users/${encodeURIComponent(userId)}/sessions/${encodeURIComponent(sessionId)}/revoke`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ current_password: currentPassword })
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
fetchMe,
|
||||
type ActingContextSelectorProps
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
fetchActingContexts,
|
||||
switchActingContext,
|
||||
type ActingContext
|
||||
} from "../../api/actingContext";
|
||||
|
||||
export default function ActingContextSelector({
|
||||
settings,
|
||||
auth,
|
||||
onAuthChange
|
||||
}: ActingContextSelectorProps) {
|
||||
const [contexts, setContexts] = useState<ActingContext[]>([]);
|
||||
const [activeId, setActiveId] = useState<string>("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void fetchActingContexts(settings)
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
setContexts(response.contexts);
|
||||
setActiveId(response.active_assignment_id ?? "");
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (active) setError(reason instanceof Error ? reason.message : "Acting context could not be loaded.");
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [settings.apiBaseUrl, settings.accessToken, settings.apiKey, auth.active_tenant?.id, auth.tenant.id]);
|
||||
|
||||
if (!contexts.length && !activeId) return null;
|
||||
|
||||
async function selectContext(assignmentId: string) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await switchActingContext(settings, assignmentId || null);
|
||||
setContexts(response.contexts);
|
||||
setActiveId(response.active_assignment_id ?? "");
|
||||
onAuthChange(await fetchMe(settings));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Acting context could not be changed.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="acting-context-selector" title={error || "Select whose authority is represented by this session."}>
|
||||
<span>Acting as</span>
|
||||
<select
|
||||
aria-label="Acting context"
|
||||
value={activeId}
|
||||
disabled={busy}
|
||||
onChange={(event) => void selectContext(event.target.value)}
|
||||
>
|
||||
<option value="">Own account</option>
|
||||
{contexts.map((context) => (
|
||||
<option key={context.assignment_id} value={context.assignment_id}>
|
||||
{context.function_id} / {context.organization_unit_id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { fetchAdminAudit, type AuditAdminItem } from "../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn, type DataGridQueryState } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
|
||||
|
||||
const DEFAULT_QUERY: DataGridQueryState = {
|
||||
sort: { columnId: "time", direction: "desc" },
|
||||
filters: {}
|
||||
};
|
||||
|
||||
export default function AdminAuditPanel({
|
||||
settings,
|
||||
auth,
|
||||
systemMode = false
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
systemMode?: boolean;
|
||||
}) {
|
||||
const [items, setItems] = useState<AuditAdminItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [query, setQuery] = useState<DataGridQueryState>(DEFAULT_QUERY);
|
||||
const [selected, setSelected] = useState<AuditAdminItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const tenantId = (auth.active_tenant ?? auth.tenant).id;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const sortColumn = query.sort?.columnId;
|
||||
const response = await fetchAdminAudit(settings, {
|
||||
scope: systemMode ? "system" : "tenant",
|
||||
page,
|
||||
pageSize,
|
||||
sortBy: sortColumn && ["time", "actor", "action", "object", "tenant"].includes(sortColumn)
|
||||
? sortColumn as "time" | "actor" | "action" | "object" | "tenant"
|
||||
: "time",
|
||||
sortDirection: query.sort?.direction ?? "desc",
|
||||
filters: query.filters
|
||||
});
|
||||
setItems(response.items);
|
||||
setTotal(response.total);
|
||||
if (response.page !== page) setPage(response.page);
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [settings.accessToken, settings.apiBaseUrl, systemMode, tenantId, page, pageSize, query, reloadToken]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const handleQueryChange = useCallback((next: DataGridQueryState) => {
|
||||
setQuery((current) => {
|
||||
if (JSON.stringify(current) === JSON.stringify(next)) return current;
|
||||
setPage(1);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const columns = useMemo<DataGridColumn<AuditAdminItem>[]>(() => [
|
||||
{ id: "time", header: "Time", width: 190, minWidth: 150, maxWidth: 260, resizable: true, sticky: "start", sortable: true, filterable: true, filterType: "date", value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) },
|
||||
{ id: "actor", header: "Actor", width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "System" },
|
||||
{ id: "action", header: "Action", width: 250, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => row.action },
|
||||
{ id: "object", header: "Object", width: 300, minWidth: 180, maxWidth: 640, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => `${row.object_type || "—"} ${row.object_id || ""}`.trim() },
|
||||
...(systemMode ? [{ id: "tenant", header: "Tenant context", width: 190, minWidth: 150, maxWidth: 300, resizable: true, sortable: true, filterable: true, value: (row: AuditAdminItem) => row.tenant_id || "—" }] : []),
|
||||
{ id: "actions", header: "Actions", width: 70, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"><AdminIconButton label="Inspect audit event" icon={<Search />} onClick={() => setSelected(row)} /></div> }
|
||||
], [systemMode]);
|
||||
|
||||
const firstShown = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const lastShown = Math.min(total, page * pageSize);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title={systemMode ? "System audit" : "Tenant audit"}
|
||||
description={systemMode
|
||||
? `System-level administrative history. Showing ${firstShown}–${lastShown} of ${total} records.`
|
||||
: `Tenant-level administrative history for the active tenant. Showing ${firstShown}–${lastShown} of ${total} records.`}
|
||||
loading={loading}
|
||||
error={error}
|
||||
actions={<Button onClick={() => setReloadToken((value) => value + 1)} disabled={loading}>Reload</Button>}
|
||||
>
|
||||
<div className="admin-table-surface">
|
||||
<DataGrid
|
||||
id={systemMode ? "admin-system-audit-v5" : "admin-tenant-audit-v5"}
|
||||
rows={items}
|
||||
columns={columns}
|
||||
initialFit="container" getRowKey={(row) => row.id}
|
||||
emptyText="No administrative audit records found."
|
||||
className="admin-audit-grid"
|
||||
initialSort={{ columnId: "time", direction: "desc" }}
|
||||
pagination={{
|
||||
mode: "server",
|
||||
page,
|
||||
pageSize,
|
||||
totalRows: total,
|
||||
pageSizeOptions: [10, 25, 50, 100, 250],
|
||||
disabled: loading,
|
||||
onPageChange: setPage,
|
||||
onPageSizeChange: (next) => { setPageSize(next); setPage(1); }
|
||||
}}
|
||||
onQueryChange={handleQueryChange}
|
||||
/>
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
<Dialog open={Boolean(selected)} title="Audit event details" onClose={() => setSelected(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setSelected(null)}>Close</Button>}>
|
||||
{selected && <><dl className="admin-details-grid"><div><dt>Scope</dt><dd>{selected.scope}</dd></div><div><dt>Action</dt><dd>{selected.action}</dd></div><div><dt>Actor</dt><dd>{selected.actor_email || "System"}</dd></div><div><dt>Object</dt><dd>{selected.object_type || "—"} {selected.object_id || ""}</dd></div><div><dt>Tenant context</dt><dd>{selected.tenant_id || "—"}</dd></div><div><dt>Time</dt><dd>{formatDateTime(selected.created_at)}</dd></div></dl><pre className="admin-json-preview">{JSON.stringify(selected.details, null, 2)}</pre></>}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,130 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { AdminSectionContribution, AdminSectionsUiCapability, ApiSettings, AuthInfo, MailProfilesUiCapability } from "@govoplan/core-webui";
|
||||
import { fetchMe } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
|
||||
import { useSearchParams } from "react-router";
|
||||
import type {
|
||||
AdminSectionContribution,
|
||||
AdminSectionsUiCapability,
|
||||
ApiSettings,
|
||||
AuthInfo,
|
||||
AuthUpdate,
|
||||
FilesConnectorsUiCapability,
|
||||
MailProfilesUiCapability,
|
||||
OrganizationFunctionPickerUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import { fetchShellAuth } from "@govoplan/core-webui";
|
||||
import { ActionBlockerHint } from "@govoplan/core-webui";
|
||||
import { PageLayout, WorkspaceLayout } from "@govoplan/core-webui";
|
||||
import { PageScrollViewport } from "@govoplan/core-webui";
|
||||
import {
|
||||
TreeSubnav,
|
||||
type TreeSubnavNode
|
||||
} from "@govoplan/core-webui";
|
||||
import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
|
||||
import SystemUsersPanel from "./SystemUsersPanel";
|
||||
import TenantSettingsPanel from "./TenantSettingsPanel";
|
||||
import SystemRolesPanel from "./SystemRolesPanel";
|
||||
import TenantsPanel from "./TenantsPanel";
|
||||
import UsersPanel from "./UsersPanel";
|
||||
import GroupsPanel from "./GroupsPanel";
|
||||
import RolesPanel from "./RolesPanel";
|
||||
import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPanel";
|
||||
import ApiKeysPanel from "./ApiKeysPanel";
|
||||
import AdminAuditPanel from "./AdminAuditPanel";
|
||||
import ServiceAccountsPanel from "./ServiceAccountsPanel";
|
||||
import FileConnectorsPanel from "./FileConnectorsPanel";
|
||||
import MailProfilesPanel from "./MailProfilesPanel";
|
||||
import RetentionPoliciesPanel from "./RetentionPoliciesPanel";
|
||||
import { usePlatformModuleInstalled, usePlatformUiCapabilities, usePlatformUiCapability } from "@govoplan/core-webui";
|
||||
import CredentialEnvelopesPanel from "./CredentialEnvelopesPanel";
|
||||
import {
|
||||
ACCESS_INTERFACE_I18N,
|
||||
ACCESS_WORKFLOW_DOCUMENTATION
|
||||
} from "./interfacePatterns";
|
||||
import {
|
||||
isViewSurfaceVisible,
|
||||
useEffectiveView,
|
||||
usePlatformUiCapabilities,
|
||||
usePlatformUiCapability,
|
||||
useViewSurfaces
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
type AdminSection = string;
|
||||
type OrderedAdminNavItem = { id: AdminSection; label: string; order: number };
|
||||
type OrderedAdminNavItem = {
|
||||
id: AdminSection;
|
||||
label: string;
|
||||
order: number;
|
||||
moduleId?: string;
|
||||
kind?: "management" | "settings";
|
||||
};
|
||||
type AdminNavGroup = {
|
||||
id: string;
|
||||
title: string;
|
||||
items: OrderedAdminNavItem[];
|
||||
};
|
||||
|
||||
const handledAdminSectionIds = new Set<string>([
|
||||
"overview",
|
||||
"system-settings",
|
||||
"system-configuration-changes",
|
||||
"system-configuration-packages",
|
||||
"system-modules",
|
||||
"system-roles",
|
||||
"system-role-templates",
|
||||
"system-groups",
|
||||
"system-users",
|
||||
"system-file-connectors",
|
||||
"system-mail-servers",
|
||||
"system-credentials",
|
||||
"tenant-roles",
|
||||
"tenant-function-role-mappings",
|
||||
"tenant-groups",
|
||||
"tenant-users",
|
||||
"tenant-file-connectors",
|
||||
"tenant-mail-servers",
|
||||
"tenant-credentials",
|
||||
"tenant-api-keys",
|
||||
"tenant-service-accounts",
|
||||
"tenant-group-file-connectors",
|
||||
"tenant-group-mail-servers",
|
||||
"tenant-group-credentials",
|
||||
"tenant-user-file-connectors",
|
||||
"tenant-user-mail-servers",
|
||||
"tenant-user-credentials"
|
||||
]);
|
||||
|
||||
const builtInAdminSurfaceIds: Record<string, string> = {
|
||||
"system-roles": "access.admin.system-roles",
|
||||
"system-users": "access.admin.system-users",
|
||||
"system-credentials": "access.admin.system-credentials",
|
||||
"tenant-roles": "access.admin.tenant-roles",
|
||||
"tenant-function-role-mappings": "access.admin.tenant-function-mappings",
|
||||
"tenant-groups": "access.admin.tenant-groups",
|
||||
"tenant-users": "access.admin.tenant-users",
|
||||
"tenant-credentials": "access.admin.tenant-credentials",
|
||||
"tenant-api-keys": "access.admin.tenant-api-keys",
|
||||
"tenant-service-accounts": "access.admin.tenant-service-accounts",
|
||||
"tenant-group-credentials": "access.admin.group-credentials",
|
||||
"tenant-user-credentials": "access.admin.user-credentials",
|
||||
"system-mail-servers": "mail.admin.system-servers",
|
||||
"tenant-mail-servers": "mail.admin.tenant-servers",
|
||||
"tenant-group-mail-servers": "mail.admin.group-servers",
|
||||
"tenant-user-mail-servers": "mail.admin.user-servers",
|
||||
"tenant-group-file-connectors": "files.admin.group-connectors",
|
||||
"tenant-user-file-connectors": "files.admin.user-connectors"
|
||||
};
|
||||
|
||||
const builtInAdminSectionMetadata: Record<
|
||||
string,
|
||||
Pick<OrderedAdminNavItem, "moduleId" | "kind">
|
||||
> = {
|
||||
"system-settings": { moduleId: "admin", kind: "settings" },
|
||||
"system-file-connectors": { moduleId: "files", kind: "settings" },
|
||||
"system-mail-servers": { moduleId: "mail", kind: "settings" },
|
||||
"system-credentials": { moduleId: "access", kind: "settings" },
|
||||
"tenant-file-connectors": { moduleId: "files", kind: "settings" },
|
||||
"tenant-mail-servers": { moduleId: "mail", kind: "settings" },
|
||||
"tenant-credentials": { moduleId: "access", kind: "settings" },
|
||||
"tenant-group-file-connectors": { moduleId: "files", kind: "settings" },
|
||||
"tenant-group-mail-servers": { moduleId: "mail", kind: "settings" },
|
||||
"tenant-group-credentials": { moduleId: "access", kind: "settings" },
|
||||
"tenant-user-file-connectors": { moduleId: "files", kind: "settings" },
|
||||
"tenant-user-mail-servers": { moduleId: "mail", kind: "settings" },
|
||||
"tenant-user-credentials": { moduleId: "access", kind: "settings" }
|
||||
};
|
||||
|
||||
export default function AdminPage({
|
||||
settings,
|
||||
@@ -28,19 +133,30 @@ export default function AdminPage({
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
}) {
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||
const organizationFunctionPicker = usePlatformUiCapability<OrganizationFunctionPickerUiCapability>("organizations.functionPicker");
|
||||
const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
|
||||
const effectiveView = useEffectiveView();
|
||||
const viewSurfaces = useViewSurfaces();
|
||||
const mailProfilesAvailable = Boolean(mailProfilesUi);
|
||||
const auditAvailable = usePlatformModuleInstalled("audit");
|
||||
const policyAvailable = usePlatformModuleInstalled("policy");
|
||||
const tenancyAvailable = usePlatformModuleInstalled("tenancy");
|
||||
const contributedSections = useMemo(() => (
|
||||
adminSectionCapabilities
|
||||
.flatMap((capability) => capability.sections)
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100))
|
||||
), [adminSectionCapabilities]);
|
||||
const fileConnectorsAvailable = Boolean(fileConnectorsUi);
|
||||
const contributedSections = useMemo(
|
||||
() =>
|
||||
adminSectionCapabilities
|
||||
.flatMap((capability) => capability.sections)
|
||||
.filter((section) =>
|
||||
isViewSurfaceVisible(
|
||||
effectiveView,
|
||||
section.surfaceId,
|
||||
viewSurfaces
|
||||
)
|
||||
)
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
|
||||
[adminSectionCapabilities, effectiveView, viewSurfaces]
|
||||
);
|
||||
const contributionById = useMemo(() => {
|
||||
const mapped = new Map<string, AdminSectionContribution>();
|
||||
for (const section of contributedSections) {
|
||||
@@ -55,31 +171,43 @@ export default function AdminPage({
|
||||
if (canUseContributedSection(auth, section)) sections.add(section.id);
|
||||
}
|
||||
if (hasScope(auth, "system:settings:read")) {
|
||||
if (policyAvailable) sections.add("system-retention");
|
||||
if (mailProfilesAvailable) sections.add("system-mail-servers");
|
||||
}
|
||||
if (tenancyAvailable && hasScope(auth, "system:tenants:read")) sections.add("system-tenants");
|
||||
if (hasAnyScope(auth, ["system:settings:read", "access:system_credential:read"])) {
|
||||
sections.add("system-credentials");
|
||||
}
|
||||
if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users");
|
||||
if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles");
|
||||
if (auditAvailable && hasScope(auth, "system:audit:read")) sections.add("system-audit");
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-users");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-groups");
|
||||
if (hasScope(auth, "admin:roles:read")) sections.add("tenant-roles");
|
||||
if (organizationFunctionPicker && hasAnyScope(auth, ["admin:roles:read", "access:function:read", "access:role:read"])) sections.add("tenant-function-role-mappings");
|
||||
if (hasScope(auth, "admin:api_keys:read")) sections.add("tenant-api-keys");
|
||||
if (hasScope(auth, "access:service_account:read")) sections.add("tenant-service-accounts");
|
||||
if (mailProfilesAvailable && hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) {
|
||||
sections.add("tenant-mail-servers");
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-mail-servers");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-mail-servers");
|
||||
}
|
||||
if (policyAvailable && hasScope(auth, "admin:policies:read")) {
|
||||
sections.add("tenant-retention");
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-retention");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-retention");
|
||||
if (fileConnectorsAvailable && hasAnyScope(auth, ["files:file:admin", "admin:settings:read"])) {
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-file-connectors");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors");
|
||||
}
|
||||
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
|
||||
if (auditAvailable && hasScope(auth, "audit:read")) sections.add("tenant-audit");
|
||||
return sections;
|
||||
}, [auth, auditAvailable, contributedSections, mailProfilesAvailable, policyAvailable, tenancyAvailable]);
|
||||
if (hasAnyScope(auth, ["admin:settings:read", "access:credential:read"])) {
|
||||
sections.add("tenant-credentials");
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-credentials");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-credentials");
|
||||
}
|
||||
return new Set(
|
||||
[...sections].filter((sectionId) =>
|
||||
isViewSurfaceVisible(
|
||||
effectiveView,
|
||||
builtInAdminSurfaceIds[sectionId],
|
||||
viewSurfaces
|
||||
)
|
||||
)
|
||||
);
|
||||
}, [auth, contributedSections, effectiveView, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker, viewSurfaces]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const requestedSection = searchParams.get("section") as AdminSection | null;
|
||||
const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview");
|
||||
@@ -97,102 +225,164 @@ export default function AdminPage({
|
||||
}, [requestedSection, available, fallbackSection]);
|
||||
|
||||
async function refreshAuth() {
|
||||
onAuthChange(await fetchMe(settings));
|
||||
onAuthChange(await fetchShellAuth(settings));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAuth().catch(() => undefined);
|
||||
}, [settings.accessToken, settings.apiBaseUrl]);
|
||||
|
||||
if (!hasAnyScope(auth, adminReadScopes)) {
|
||||
return <div className="content-pad"><Card title="Administration unavailable"><p>Your current roles do not grant administrative access.</p></Card></div>;
|
||||
return (
|
||||
<PageScrollViewport>
|
||||
<div className="content-pad">
|
||||
<ActionBlockerHint
|
||||
tone="warning"
|
||||
reason={{
|
||||
summary: "i18n:govoplan-access.administration_unavailable.b86d4cb5",
|
||||
details: "i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69",
|
||||
requiredAction: ACCESS_INTERFACE_I18N.requestAdministrationAccess,
|
||||
actor: ACCESS_INTERFACE_I18N.accessAdministrator,
|
||||
target: ACCESS_INTERFACE_I18N.accessAdministration
|
||||
}}
|
||||
labels={{
|
||||
requiredAction: ACCESS_INTERFACE_I18N.requiredAction,
|
||||
actor: ACCESS_INTERFACE_I18N.actor,
|
||||
target: ACCESS_INTERFACE_I18N.destinationLabel
|
||||
}}
|
||||
documentation={ACCESS_WORKFLOW_DOCUMENTATION}
|
||||
/>
|
||||
</div>
|
||||
</PageScrollViewport>
|
||||
);
|
||||
}
|
||||
|
||||
const rootItems = contributedNavItems(contributedSections, available, "ROOT");
|
||||
const adminSubnav: ModuleSubnavGroup<AdminSection>[] = [
|
||||
{ items: asSubnavItems(rootItems) },
|
||||
const adminNavGroups: AdminNavGroup[] = [
|
||||
{
|
||||
title: "SYSTEM",
|
||||
items: asSubnavItems(sortNavItems([
|
||||
...contributedNavItems(contributedSections, available, "SYSTEM"),
|
||||
visibleNavItem(available, "system-tenants", "Tenants", 20),
|
||||
visibleNavItem(available, "system-roles", "System roles", 30),
|
||||
visibleNavItem(available, "system-users", "Users", 60),
|
||||
visibleNavItem(available, "system-mail-servers", "Mail servers", 70),
|
||||
visibleNavItem(available, "system-retention", "Retention", 80),
|
||||
visibleNavItem(available, "system-audit", "Audit", 90)
|
||||
]))
|
||||
id: "administration",
|
||||
title: "i18n:govoplan-access.admin.4e7afebc",
|
||||
items: sortNavItems([
|
||||
...contributedNavItems(contributedSections, available, "ROOT"),
|
||||
visibleNavItem(available, "system-modules", "i18n:govoplan-access.modules.04e9462c", 10),
|
||||
visibleNavItem(available, "system-configuration-packages", "i18n:govoplan-access.packages.0a999012", 20),
|
||||
visibleNavItem(available, "system-settings", "i18n:govoplan-access.maintenance.94de303b", 30),
|
||||
visibleNavItem(available, "system-configuration-changes", "i18n:govoplan-access.changes.8aa57de6", 40),
|
||||
...contributedNavItems(contributedSections, available, "ADMINISTRATION", handledAdminSectionIds)
|
||||
])
|
||||
},
|
||||
{
|
||||
title: "TENANT",
|
||||
items: [
|
||||
...(available.has("tenant-settings") ? [{ id: "tenant-settings" as const, label: "General" }] : []),
|
||||
...(available.has("tenant-roles") ? [{ id: "tenant-roles" as const, label: "Roles" }] : []),
|
||||
...(available.has("tenant-groups") ? [{ id: "tenant-groups" as const, label: "Groups" }] : []),
|
||||
...(available.has("tenant-users") ? [{ id: "tenant-users" as const, label: "Users" }] : []),
|
||||
...(available.has("tenant-mail-servers") ? [{ id: "tenant-mail-servers" as const, label: "Mail servers" }] : []),
|
||||
...(available.has("tenant-retention") ? [{ id: "tenant-retention" as const, label: "Retention" }] : []),
|
||||
...(available.has("tenant-api-keys") ? [{ id: "tenant-api-keys" as const, label: "API keys" }] : []),
|
||||
...(available.has("tenant-audit") ? [{ id: "tenant-audit" as const, label: "Audit" }] : [])
|
||||
]
|
||||
id: "global",
|
||||
title: "i18n:govoplan-access.global",
|
||||
items: sortNavItems([
|
||||
visibleNavItem(available, "system-roles", "i18n:govoplan-access.system_roles.a9461aa6", 20),
|
||||
visibleNavItem(available, "system-role-templates", "i18n:govoplan-access.tenant_role_templates", 30),
|
||||
visibleNavItem(available, "system-groups", "i18n:govoplan-access.group_templates", 40),
|
||||
visibleNavItem(available, "system-users", "i18n:govoplan-access.users.57f2b181", 50),
|
||||
visibleNavItem(available, "system-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 60),
|
||||
visibleNavItem(available, "system-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 70),
|
||||
visibleNavItem(available, "system-credentials", "i18n:govoplan-core.credentials.dd097a22", 80),
|
||||
...contributedNavItems(contributedSections, available, "GLOBAL", handledAdminSectionIds),
|
||||
...contributedNavItems(contributedSections, available, "SYSTEM", handledAdminSectionIds)
|
||||
])
|
||||
},
|
||||
{
|
||||
title: "GROUP",
|
||||
items: [
|
||||
...(available.has("tenant-group-mail-servers") ? [{ id: "tenant-group-mail-servers" as const, label: "Mail servers" }] : []),
|
||||
...(available.has("tenant-group-retention") ? [{ id: "tenant-group-retention" as const, label: "Retention" }] : []),
|
||||
]
|
||||
id: "tenant",
|
||||
title: "i18n:govoplan-access.tenant.3ca93c78",
|
||||
items: sortNavItems([
|
||||
visibleNavItem(available, "tenant-roles", "i18n:govoplan-access.roles.47dcc27d", 10),
|
||||
visibleNavItem(available, "tenant-function-role-mappings", "i18n:govoplan-access.function_role_mappings.2b64e9c3", 20),
|
||||
visibleNavItem(available, "tenant-groups", "i18n:govoplan-access.groups.ae9629f4", 30),
|
||||
visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 40),
|
||||
visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 50),
|
||||
visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60),
|
||||
visibleNavItem(available, "tenant-credentials", "i18n:govoplan-core.credentials.dd097a22", 70),
|
||||
visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 80),
|
||||
visibleNavItem(available, "tenant-service-accounts", "Service accounts", 90),
|
||||
...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds)
|
||||
])
|
||||
},
|
||||
{
|
||||
title: "USER",
|
||||
items: [
|
||||
...(available.has("tenant-user-mail-servers") ? [{ id: "tenant-user-mail-servers" as const, label: "Mail servers" }] : []),
|
||||
...(available.has("tenant-user-retention") ? [{ id: "tenant-user-retention" as const, label: "Retention" }] : []),
|
||||
]
|
||||
id: "group",
|
||||
title: "i18n:govoplan-access.group.171a0606",
|
||||
items: sortNavItems([
|
||||
visibleNavItem(available, "tenant-group-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
|
||||
visibleNavItem(available, "tenant-group-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
|
||||
visibleNavItem(available, "tenant-group-credentials", "i18n:govoplan-core.credentials.dd097a22", 30),
|
||||
...contributedNavItems(contributedSections, available, "GROUP", handledAdminSectionIds)
|
||||
])
|
||||
},
|
||||
{
|
||||
id: "user",
|
||||
title: "i18n:govoplan-access.user.9f8a2389",
|
||||
items: sortNavItems([
|
||||
visibleNavItem(available, "tenant-user-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
|
||||
visibleNavItem(available, "tenant-user-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
|
||||
visibleNavItem(available, "tenant-user-credentials", "i18n:govoplan-core.credentials.dd097a22", 30),
|
||||
...contributedNavItems(contributedSections, available, "USER", handledAdminSectionIds)
|
||||
])
|
||||
}
|
||||
].filter((group) => group.items.length > 0);
|
||||
const adminTree = adminNavigationTree(adminNavGroups);
|
||||
const contributedSection = contributionById.get(active);
|
||||
const contributionContext = { settings, auth, onAuthChange, refreshAuth, availableSections: available, selectSection };
|
||||
|
||||
return (
|
||||
<div className="workspace module-workspace">
|
||||
<ModuleSubnav active={active} groups={adminSubnav} onSelect={selectSection} />
|
||||
<section className="workspace-content">
|
||||
<div className="content-pad workspace-data-page">
|
||||
<WorkspaceLayout
|
||||
className="module-workspace"
|
||||
primary={(
|
||||
<TreeSubnav
|
||||
active={active}
|
||||
nodes={adminTree}
|
||||
onSelect={selectSection}
|
||||
ariaLabel="i18n:govoplan-access.admin.4e7afebc"
|
||||
/>
|
||||
)}
|
||||
primaryLabel="i18n:govoplan-access.admin.4e7afebc"
|
||||
contentLabel="i18n:govoplan-access.admin.4e7afebc"
|
||||
documentationType="admin"
|
||||
>
|
||||
<PageLayout
|
||||
archetype="workspace"
|
||||
title="i18n:govoplan-access.admin.4e7afebc"
|
||||
mode="workspace"
|
||||
showHeader={false}
|
||||
documentationType="admin"
|
||||
>
|
||||
{contributedSection && contributedSection.render(contributionContext)}
|
||||
{!contributedSection && active === "system-retention" && <RetentionPoliciesPanel settings={settings} scopeType="system" canWrite={hasScope(auth, "system:settings:write")} />}
|
||||
{!contributedSection && active === "system-mail-servers" && <MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />}
|
||||
{!contributedSection && active === "system-tenants" && <TenantsPanel settings={settings} auth={auth} canCreate={hasScope(auth, "system:tenants:create")} canUpdate={hasScope(auth, "system:tenants:update")} canSuspend={hasScope(auth, "system:tenants:suspend")} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "system-users" && <SystemUsersPanel
|
||||
settings={settings}
|
||||
canCreate={hasScope(auth, "system:accounts:create")}
|
||||
canUpdate={hasScope(auth, "system:accounts:update")}
|
||||
canSuspend={hasScope(auth, "system:accounts:suspend")}
|
||||
canAssignRoles={hasAnyScope(auth, ["system:roles:assign", "system:access:assign"])}
|
||||
canManageMemberships={hasScope(auth, "system:accounts:update") && hasScope(auth, "system:access:assign")}
|
||||
onAuthRefresh={refreshAuth}
|
||||
/>}
|
||||
{!contributedSection && active === "system-roles" && <SystemRolesPanel
|
||||
settings={settings}
|
||||
canWrite={hasScope(auth, "system:roles:write")}
|
||||
onAuthRefresh={refreshAuth}
|
||||
/>}
|
||||
{!contributedSection && active === "system-audit" && <AdminAuditPanel settings={settings} auth={auth} systemMode />}
|
||||
{!contributedSection && active === "tenant-users" && <UsersPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:users:create")} canUpdate={hasScope(auth, "admin:users:update")} canSuspend={hasScope(auth, "admin:users:suspend")} canManageGroups={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "system-mail-servers" && (
|
||||
<MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />
|
||||
)}
|
||||
{!contributedSection && active === "system-credentials" && (
|
||||
<CredentialEnvelopesPanel
|
||||
settings={settings}
|
||||
scopeType="system"
|
||||
canWrite={hasAnyScope(auth, ["system:settings:write", "access:system_credential:write"])}
|
||||
/>
|
||||
)}
|
||||
{!contributedSection && active === "system-users" && (
|
||||
<SystemUsersPanel
|
||||
settings={settings}
|
||||
canCreate={hasScope(auth, "system:accounts:create")}
|
||||
canUpdate={hasScope(auth, "system:accounts:update")}
|
||||
canSuspend={hasScope(auth, "system:accounts:suspend")}
|
||||
canAssignRoles={hasAnyScope(auth, ["system:roles:assign", "system:access:assign"])}
|
||||
canManageMemberships={hasScope(auth, "system:accounts:update") && hasScope(auth, "system:access:assign")}
|
||||
onAuthRefresh={refreshAuth}
|
||||
/>
|
||||
)}
|
||||
{!contributedSection && active === "system-roles" && <SystemRolesPanel settings={settings} canWrite={hasScope(auth, "system:roles:write")} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "tenant-users" && <UsersPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:users:create")} canUpdate={hasScope(auth, "admin:users:update")} canSuspend={hasScope(auth, "admin:users:suspend")} canManageGroups={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} canRevokeSessions={hasAnyScope(auth, ["admin:users:update", "access:membership:update"])} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "tenant-groups" && <GroupsPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:groups:write")} canManageMembers={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "tenant-roles" && <RolesPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:roles:write")} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && <ExternalFunctionRoleMappingsPanel settings={settings} auth={auth} functionPicker={organizationFunctionPicker} canWrite={hasAnyScope(auth, ["admin:roles:write", "access:function:write", "access:role:assign"])} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "tenant-api-keys" && <ApiKeysPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:api_keys:create")} canRevoke={hasScope(auth, "admin:api_keys:revoke")} />}
|
||||
{!contributedSection && active === "tenant-service-accounts" && <ServiceAccountsPanel settings={settings} auth={auth} canWrite={hasScope(auth, "access:service_account:write")} />}
|
||||
{!contributedSection && active === "tenant-mail-servers" && <MailProfilesPanel settings={settings} scopeType="tenant" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasScope(auth, "admin:policies:write")} />}
|
||||
{!contributedSection && active === "tenant-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="tenant" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
|
||||
{!contributedSection && active === "tenant-user-mail-servers" && <MailProfilesPanel settings={settings} scopeType="user" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
|
||||
{!contributedSection && active === "tenant-group-mail-servers" && <MailProfilesPanel settings={settings} scopeType="group" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
|
||||
{!contributedSection && active === "tenant-retention" && <RetentionPoliciesPanel settings={settings} scopeType="tenant" canWrite={hasScope(auth, "admin:policies:write")} />}
|
||||
{!contributedSection && active === "tenant-user-retention" && <RetentionPoliciesPanel settings={settings} scopeType="user" canWrite={hasScope(auth, "admin:policies:write")} />}
|
||||
{!contributedSection && active === "tenant-group-retention" && <RetentionPoliciesPanel settings={settings} scopeType="group" canWrite={hasScope(auth, "admin:policies:write")} />}
|
||||
{!contributedSection && active === "tenant-settings" && <TenantSettingsPanel settings={settings} canWrite={hasScope(auth, "admin:settings:write")} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "tenant-audit" && <AdminAuditPanel settings={settings} auth={auth} />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{!contributedSection && active === "tenant-user-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
|
||||
{!contributedSection && active === "tenant-group-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
|
||||
{!contributedSection && active === "tenant-user-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
|
||||
{!contributedSection && active === "tenant-group-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
|
||||
</PageLayout>
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,20 +392,86 @@ function canUseContributedSection(auth: AuthInfo, section: AdminSectionContribut
|
||||
return true;
|
||||
}
|
||||
|
||||
function contributedNavItems(sections: AdminSectionContribution[], available: ReadonlySet<string>, group: string): OrderedAdminNavItem[] {
|
||||
function contributedNavItems(
|
||||
sections: AdminSectionContribution[],
|
||||
available: ReadonlySet<string>,
|
||||
group: string,
|
||||
excludedIds: ReadonlySet<string> = new Set()
|
||||
): OrderedAdminNavItem[] {
|
||||
return sections
|
||||
.filter((section) => (section.group ?? "SYSTEM") === group && available.has(section.id))
|
||||
.map((section) => ({ id: section.id, label: section.label, order: section.order ?? 100 }));
|
||||
.filter((section) => (section.group ?? "SYSTEM") === group && available.has(section.id) && !excludedIds.has(section.id))
|
||||
.map((section) => ({
|
||||
id: section.id,
|
||||
label: section.label,
|
||||
order: section.order ?? 100,
|
||||
moduleId: section.moduleId,
|
||||
kind: section.kind
|
||||
}));
|
||||
}
|
||||
|
||||
function visibleNavItem(available: ReadonlySet<string>, id: AdminSection, label: string, order: number): OrderedAdminNavItem | null {
|
||||
return available.has(id) ? { id, label, order } : null;
|
||||
return available.has(id)
|
||||
? { id, label, order, ...builtInAdminSectionMetadata[id] }
|
||||
: null;
|
||||
}
|
||||
|
||||
function sortNavItems(items: Array<OrderedAdminNavItem | null>): OrderedAdminNavItem[] {
|
||||
return items.filter((item): item is OrderedAdminNavItem => item !== null).sort((left, right) => left.order - right.order);
|
||||
}
|
||||
|
||||
function asSubnavItems(items: OrderedAdminNavItem[]) {
|
||||
return items.map(({ id, label }) => ({ id, label }));
|
||||
function adminNavigationTree(
|
||||
groups: AdminNavGroup[]
|
||||
): TreeSubnavNode<AdminSection>[] {
|
||||
return groups.map((group) => {
|
||||
const managementItems = group.items.filter(
|
||||
(item) => item.kind !== "settings"
|
||||
);
|
||||
const settingsItems = group.items.filter(
|
||||
(item) => item.kind === "settings"
|
||||
);
|
||||
const children: TreeSubnavNode<AdminSection>[] = managementItems.map(
|
||||
({ id, label }) => ({ id, label })
|
||||
);
|
||||
if (settingsItems.length > 0) {
|
||||
const byModule = new Map<string, OrderedAdminNavItem[]>();
|
||||
for (const item of settingsItems) {
|
||||
const moduleId = item.moduleId ?? "platform";
|
||||
byModule.set(moduleId, [...(byModule.get(moduleId) ?? []), item]);
|
||||
}
|
||||
children.push({
|
||||
branchId: `admin-${group.id}-settings`,
|
||||
label: "i18n:govoplan-core.settings.c7f73bb5",
|
||||
defaultExpanded: false,
|
||||
children: [...byModule.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([moduleId, items]) => ({
|
||||
branchId: `admin-${group.id}-settings-${moduleId}`,
|
||||
label: moduleLabel(moduleId),
|
||||
defaultExpanded: items.some(
|
||||
(item) => item.id === "system-settings"
|
||||
),
|
||||
children: items.map(({ id, label }) => ({ id, label }))
|
||||
}))
|
||||
});
|
||||
}
|
||||
return {
|
||||
branchId: `admin-${group.id}`,
|
||||
label: group.title,
|
||||
defaultExpanded: group.id === "administration",
|
||||
children
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function moduleLabel(moduleId: string): string {
|
||||
if (moduleId === "platform") return "i18n:govoplan-access.platform_administration";
|
||||
if (moduleId === "access") return "i18n:govoplan-access.access.2f81a22d";
|
||||
if (moduleId === "admin") return "i18n:govoplan-access.admin.4e7afebc";
|
||||
if (moduleId === "files") return "i18n:govoplan-access.files.6ce6c512";
|
||||
if (moduleId === "mail") return "i18n:govoplan-access.mail_servers.d627326a";
|
||||
return moduleId
|
||||
.split(/[-_]/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part[0].toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
@@ -1,80 +1,141 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createApiKey, fetchApiKeys, fetchPermissionCatalog, fetchUsers, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
|
||||
import { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { DateTimeField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
|
||||
import { scopeGrants } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, useDeltaWatermarks } from "@govoplan/core-webui";
|
||||
import { scopeGrants, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
import { ACCESS_INTERFACE_I18N, ACCESS_REFERENCE_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||
|
||||
export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: { settings: ApiSettings; auth: AuthInfo; canCreate: boolean; canRevoke: boolean }) {
|
||||
function defaultDraft(userId: string) {
|
||||
return { name: "", userId, scopes: ["campaign:read"], expiresAt: "" };
|
||||
}
|
||||
|
||||
export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canRevoke: boolean;}) {
|
||||
const [keys, setKeys] = useState<ApiKeyAdminItem[]>([]);
|
||||
const [users, setUsers] = useState<UserAdminItem[]>([]);
|
||||
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
||||
const keysRef = useRef<ApiKeyAdminItem[]>([]);
|
||||
const usersRef = useRef<UserAdminItem[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [showRevoked, setShowRevoked] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [viewing, setViewing] = useState<ApiKeyAdminItem | null>(null);
|
||||
const [draft, setDraft] = useState({ name: "", userId: auth.user.id, scopes: ["campaign:read"], expiresAt: "" });
|
||||
const [secret, setSecret] = useState<{ name: string; value: string } | null>(null);
|
||||
const [draft, setDraft] = useState(() => defaultDraft(auth.user.id));
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(() => draftKey(defaultDraft(auth.user.id)));
|
||||
const [secret, setSecret] = useState<{name: string;value: string;} | null>(null);
|
||||
const [revoking, setRevoking] = useState<ApiKeyAdminItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const dirty = creating && draftKey(draft) !== savedDraftKey;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: closeCreate
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextKeys, nextUsers, nextPermissions] = await Promise.all([fetchApiKeys(settings, showRevoked), fetchUsers(settings), fetchPermissionCatalog(settings)]);
|
||||
const keyScope = showRevoked ? "all" : "active";
|
||||
const [nextKeys, nextUsers, nextPermissions] = await Promise.all([
|
||||
loadDeltaRows(
|
||||
keysRef.current,
|
||||
`access:api-keys:${keyScope}`,
|
||||
getDeltaWatermark,
|
||||
setDeltaWatermark,
|
||||
(since) => fetchApiKeysDelta(settings, showRevoked, { since }),
|
||||
(response) => response.api_keys,
|
||||
(key) => key.id,
|
||||
"access_api_key",
|
||||
sortApiKeys
|
||||
),
|
||||
loadDeltaRows(
|
||||
usersRef.current,
|
||||
"access:api-keys:users",
|
||||
getDeltaWatermark,
|
||||
setDeltaWatermark,
|
||||
(since) => fetchUsersDelta(settings, { since }),
|
||||
(response) => response.users,
|
||||
(user) => user.id,
|
||||
"access_user",
|
||||
sortUsers
|
||||
),
|
||||
fetchPermissionCatalog(settings)
|
||||
]);
|
||||
keysRef.current = nextKeys;
|
||||
usersRef.current = nextUsers;
|
||||
setKeys(nextKeys);
|
||||
setUsers(nextUsers.filter((user) => user.is_active && user.account_is_active));
|
||||
setPermissions(nextPermissions.filter((permission) => permission.level === "tenant"));
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setLoading(false); }
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setLoading(false);}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, showRevoked]);
|
||||
useEffect(() => {
|
||||
keysRef.current = [];
|
||||
usersRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, showRevoked, resetDeltaWatermark]);
|
||||
|
||||
const selectedUser = users.find((user) => user.id === draft.userId);
|
||||
const allowedPermissions = permissions.filter((permission) => selectedUser?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope)));
|
||||
|
||||
const columns = useMemo<DataGridColumn<ApiKeyAdminItem>[]>(() => [
|
||||
{ id: "name", header: "Name", width: "minmax(190px, 1fr)", minWidth: 170, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => row.name, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.prefix}…</div></div> },
|
||||
{ id: "owner", header: "Owner", width: 250, minWidth: 180, maxWidth: 480, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.user_email },
|
||||
{ id: "scopes", header: "Scopes", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.scopes.length, render: (row) => String(row.scopes.length) },
|
||||
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.revoked_at ? "revoked" : "active", render: (row) => <StatusBadge status={row.revoked_at ? "revoked" : "active"} /> },
|
||||
{ id: "last_used", header: "Last used", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) },
|
||||
{ id: "expires", header: "Expires", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "No expiry" },
|
||||
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} disabled />
|
||||
<AdminIconButton label={`Revoke ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setRevoking(row)} disabled={!canRevoke || Boolean(row.revoked_at)} />
|
||||
</div> }
|
||||
], [canRevoke]);
|
||||
{ id: "name", header: "i18n:govoplan-access.name.709a2322", width: "minmax(190px, 1fr)", minWidth: 170, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => row.name, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.prefix}…</div></div> },
|
||||
{ id: "owner", header: "i18n:govoplan-access.owner.89ff3122", width: 250, minWidth: 180, maxWidth: 480, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.user_email },
|
||||
{ id: "scopes", header: "i18n:govoplan-access.scopes.c23540e5", width: 120, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.scopes.length, render: (row) => String(row.scopes.length) },
|
||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.revoked_at ? "revoked" : "active", render: (row) => <StatusBadge status={row.revoked_at ? "revoked" : "active"} /> },
|
||||
{ id: "last_used", header: "i18n:govoplan-access.last_used.f1109d3d", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) },
|
||||
{ id: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa" },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 108, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, helpContextId: "access.api-keys.action.inspect", helpModuleId: "access", onClick: () => setViewing(row) },
|
||||
{ id: "revoke", label: i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name }), icon: <Trash2 />, variant: "danger", helpContextId: "access.api-keys.action.revoke", helpModuleId: "access", applicable: !row.revoked_at, disabled: !canRevoke, disabledReason: row.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : !canRevoke ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => setRevoking(row) }
|
||||
]} /> }],
|
||||
[canRevoke]);
|
||||
|
||||
function openCreate() {
|
||||
const defaultUser = users.find((user) => user.id === auth.user.id) ?? users[0];
|
||||
setDraft({ name: "", userId: defaultUser?.id || "", scopes: ["campaign:read"], expiresAt: "" });
|
||||
const nextDraft = defaultDraft(defaultUser?.id || "");
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
setCreating(true);
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
function closeCreate() {
|
||||
setCreating(false);
|
||||
const nextDraft = defaultDraft(auth.user.id);
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await createApiKey(settings, { name: draft.name, user_id: draft.userId, scopes: draft.scopes, expires_at: draft.expiresAt ? new Date(draft.expiresAt).toISOString() : null });
|
||||
setSecret({ name: created.name, value: created.secret });
|
||||
setCreating(false);
|
||||
setSuccess(`API key ${created.name} created.`);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.api_key_value_created.0ccdfbb2", { value0: created.name }));
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
return true;
|
||||
} catch (err) {setError(adminErrorMessage(err));return false;} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
async function revoke() {
|
||||
@@ -83,42 +144,54 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
|
||||
setError("");
|
||||
try {
|
||||
await revokeApiKey(settings, revoking.id);
|
||||
setSuccess(`API key ${revoking.name} revoked.`);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.api_key_value_revoked.b4431f0e", { value0: revoking.name }));
|
||||
setRevoking(null);
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="Tenant API keys" description="Tenant-scoped automation credentials are capped by their owner's current effective permissions. API keys are immutable after creation and are revoked rather than edited." loading={loading} error={error} success={success} actions={<><label className="admin-inline-check"><input type="checkbox" checked={showRevoked} onChange={(event) => setShowRevoked(event.target.checked)} /> Show revoked</label><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add API key" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-api-keys-v3" rows={keys} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No API keys found." /></div>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_api_keys.4b1d81f8" description="i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae" loading={loading} error={error} success={success} helpContextId="access.admin.api-keys" helpModuleId="access" actions={<><DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} /><ToggleSwitch label="i18n:govoplan-access.show_revoked.b4265807" checked={showRevoked} helpContextId="access.api-keys.field.show-revoked" helpModuleId="access" onChange={setShowRevoked} /><Button helpContextId="access.api-keys.action.reload" helpModuleId="access" onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_api_key.725d9988" icon={<Plus />} variant="primary" helpContextId="access.api-keys.action.create" helpModuleId="access" onClick={openCreate} disabled={!canCreate || !users.length} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : !users.length ? ACCESS_INTERFACE_I18N.selectUserAndScopes : undefined} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-api-keys-v3" rows={keys} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_api_keys_found.1f377128" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={creating} title="Create API key" onClose={() => !busy && setCreating(false)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setCreating(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.userId || !draft.scopes.length}>{busy ? "Creating…" : "Create key"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Owner"><select value={draft.userId} onChange={(event) => { const userId = event.target.value; const user = users.find((item) => item.id === userId); const allowed = new Set(permissions.filter((permission) => user?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope))).map((permission) => permission.scope)); setDraft({ ...draft, userId, scopes: draft.scopes.filter((scope) => allowed.has(scope)) }); }}><option value="">Select user</option>{users.map((user) => <option key={user.id} value={user.id}>{user.display_name || user.email} — {user.email}</option>)}</select></FormField>
|
||||
<FormField label="Expiry"><input type="datetime-local" value={draft.expiresAt} onChange={(event) => setDraft({ ...draft, expiresAt: event.target.value })} /></FormField>
|
||||
</div>
|
||||
<div className="form-field"><span className="form-label">Allowed scopes</span><AdminSelectionList options={allowedPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: `${permission.scope} — ${permission.description}` }))} selected={draft.scopes} onChange={(scopes) => setDraft({ ...draft, scopes })} emptyText="The selected user has no tenant permissions available to an API key." /></div>
|
||||
<Dialog variant="administration" size="wide" open={creating} title="i18n:govoplan-access.create_api_key.d7b30388" helpContextId="access.api-keys.action.create" helpModuleId="access" onClose={() => !busy && setCreating(false)} className="" footer={<><Button onClick={() => setCreating(false)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" helpContextId="access.api-keys.action.create" helpModuleId="access" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canCreate, complete: Boolean(draft.name.trim() && draft.userId && draft.scopes.length) })}>{busy ? "i18n:govoplan-access.creating.94d7d8ee" : "i18n:govoplan-access.create_key.e028cb09"}</Button></>}>
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="i18n:govoplan-access.name.709a2322" helpContextId="access.api-keys.field.name" helpModuleId="access"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.owner.89ff3122" helpContextId="access.api-keys.field.owner" helpModuleId="access"><select value={draft.userId} onChange={(event) => {const userId = event.target.value;const user = users.find((item) => item.id === userId);const allowed = new Set(permissions.filter((permission) => user?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope))).map((permission) => permission.scope));setDraft({ ...draft, userId, scopes: draft.scopes.filter((scope) => allowed.has(scope)) });}}><option value="">i18n:govoplan-access.select_user.b8a1d9de</option>{users.map((user) => <option key={user.id} value={user.id}>{user.display_name || user.email} — {user.email}</option>)}</select></FormField>
|
||||
<FormField label="i18n:govoplan-access.expiry.ba8f571e" helpContextId="access.api-keys.field.expiry" helpModuleId="access"><DateTimeField value={draft.expiresAt} onChange={(value) => setDraft({ ...draft, expiresAt: value })} /></FormField>
|
||||
</FormGrid>
|
||||
<div className="form-field" data-help-context-id="access.api-keys.field.scopes" data-help-module-id="access"><span className="form-label">i18n:govoplan-access.allowed_scopes.d94515ff</span><AdminSelectionList options={allowedPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: i18nMessage("i18n:govoplan-access.value_value.0e2772ed", { value0: permission.scope, value1: permission.description }) }))} selected={draft.scopes} onChange={(scopes) => setDraft({ ...draft, scopes })} emptyText="i18n:govoplan-access.the_selected_user_has_no_tenant_permissions_avai.96985ec7" /></div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="API key details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
{viewing && <><dl className="admin-details-grid">
|
||||
<div><dt>Name</dt><dd>{viewing.name}</dd></div><div><dt>Prefix</dt><dd>{viewing.prefix}…</dd></div>
|
||||
<div><dt>Owner</dt><dd>{viewing.user_email}</dd></div><div><dt>Status</dt><dd>{viewing.revoked_at ? "Revoked" : "Active"}</dd></div>
|
||||
<div><dt>Created</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>Last used</dt><dd>{formatDateTime(viewing.last_used_at)}</dd></div>
|
||||
<div><dt>Expires</dt><dd>{viewing.expires_at ? formatDateTime(viewing.expires_at) : "No expiry"}</dd></div><div><dt>Revoked</dt><dd>{formatDateTime(viewing.revoked_at)}</dd></div>
|
||||
</dl><h3>Scopes</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
||||
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.api_key_details.f70c16be" helpContextId="access.api-keys.action.inspect" helpModuleId="access" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <><DescriptionList>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.name.709a2322</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.prefix.90eceb01</>}>{viewing.prefix}…</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.owner.89ff3122</>}>{viewing.user_email}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.status.bae7d5be</>}>{viewing.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : "i18n:govoplan-access.active.a733b809"}</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.created.accf40c8</>}>{formatDateTime(viewing.created_at)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.last_used.f1109d3d</>}>{formatDateTime(viewing.last_used_at)}</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.expires.a99be3da</>}>{viewing.expires_at ? formatDateTime(viewing.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.revoked.85f17ac0</>}>{formatDateTime(viewing.revoked_at)}</DescriptionItem>
|
||||
</DescriptionList><h3>i18n:govoplan-access.scopes.c23540e5</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(secret)} title="API key secret" onClose={() => setSecret(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setSecret(null)}>I have recorded it</Button>}>
|
||||
{secret && <><p>The secret for <strong>{secret.name}</strong> is shown once.</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.</p></>}
|
||||
<Dialog variant="administration" size="large" open={Boolean(secret)} title="i18n:govoplan-access.api_key_secret.00b16050" helpContextId="access.api-keys.secret" helpModuleId="access" onClose={() => setSecret(null)} className="" footer={<Button variant="primary" helpContextId="access.api-keys.secret" helpModuleId="access" onClick={() => setSecret(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
||||
{secret && <><p>i18n:govoplan-access.the_secret_for.c60737ef <strong>{secret.name}</strong> i18n:govoplan-access.is_shown_once.af2b1235</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588</p></>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(revoking)} title="Revoke API key" message={`Revoke ${revoking?.name}? Existing clients will immediately lose access.`} confirmLabel="Revoke key" tone="danger" busy={busy} onCancel={() => setRevoking(null)} onConfirm={() => void revoke()} />
|
||||
</>
|
||||
);
|
||||
<ConfirmDialog open={Boolean(revoking)} title="i18n:govoplan-access.revoke_api_key.3160aa7e" message={i18nMessage("i18n:govoplan-access.revoke_value_existing_clients_will_immediately_l.c70f07fd", { value0: revoking?.name })} confirmLabel="i18n:govoplan-access.revoke_key.acb203e7" tone="danger" busy={busy} helpContextId="access.api-keys.confirm-revoke" helpModuleId="access" onCancel={() => setRevoking(null)} onConfirm={() => void revoke()} />
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function draftKey(draft: ReturnType<typeof defaultDraft>): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
function sortApiKeys(left: ApiKeyAdminItem, right: ApiKeyAdminItem): number {
|
||||
return left.name.localeCompare(right.name) || left.prefix.localeCompare(right.prefix);
|
||||
}
|
||||
|
||||
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
|
||||
return left.email.localeCompare(right.email);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AdminPageLayout,
|
||||
CredentialEnvelopeManager,
|
||||
DocumentationHelpLink,
|
||||
adminErrorMessage,
|
||||
useDeltaWatermarks,
|
||||
type ApiSettings,
|
||||
type CredentialEnvelopeTargetOption,
|
||||
type MailProfileScope
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
fetchGroupsDelta,
|
||||
fetchUsersDelta,
|
||||
type GroupSummary,
|
||||
type UserAdminItem
|
||||
} from "../../api/admin";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
import {
|
||||
ACCESS_INTERFACE_I18N,
|
||||
CREDENTIAL_DOCUMENTATION
|
||||
} from "./interfacePatterns";
|
||||
|
||||
type ScopeType = Extract<MailProfileScope, "system" | "tenant" | "user" | "group">;
|
||||
|
||||
export default function CredentialEnvelopesPanel({
|
||||
settings,
|
||||
scopeType,
|
||||
canWrite
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
scopeType: ScopeType;
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [targets, setTargets] = useState<CredentialEnvelopeTargetOption[]>([]);
|
||||
const [loadingTargets, setLoadingTargets] = useState(
|
||||
scopeType === "user" || scopeType === "group"
|
||||
);
|
||||
const [targetError, setTargetError] = useState("");
|
||||
const usersRef = useRef<UserAdminItem[]>([]);
|
||||
const groupsRef = useRef<GroupSummary[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } =
|
||||
useDeltaWatermarks();
|
||||
|
||||
useEffect(() => {
|
||||
usersRef.current = [];
|
||||
groupsRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void loadTargets();
|
||||
}, [
|
||||
resetDeltaWatermark,
|
||||
scopeType,
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey
|
||||
]);
|
||||
|
||||
async function loadTargets() {
|
||||
if (scopeType !== "user" && scopeType !== "group") {
|
||||
setTargets([]);
|
||||
setLoadingTargets(false);
|
||||
setTargetError("");
|
||||
return;
|
||||
}
|
||||
setLoadingTargets(true);
|
||||
setTargetError("");
|
||||
try {
|
||||
if (scopeType === "user") {
|
||||
const users = await loadDeltaRows(
|
||||
usersRef.current,
|
||||
"access:credential-users",
|
||||
getDeltaWatermark,
|
||||
setDeltaWatermark,
|
||||
(since) => fetchUsersDelta(settings, { since }),
|
||||
(response) => response.users,
|
||||
(user) => user.id,
|
||||
"access_user",
|
||||
(left, right) => left.email.localeCompare(right.email)
|
||||
);
|
||||
usersRef.current = users;
|
||||
setTargets(
|
||||
users.map((user) => ({
|
||||
id: user.id,
|
||||
label: user.display_name || user.email,
|
||||
secondary: user.display_name ? user.email : null
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
const groups = await loadDeltaRows(
|
||||
groupsRef.current,
|
||||
"access:credential-groups",
|
||||
getDeltaWatermark,
|
||||
setDeltaWatermark,
|
||||
(since) => fetchGroupsDelta(settings, { since }),
|
||||
(response) => response.groups,
|
||||
(group) => group.id,
|
||||
"access_group",
|
||||
(left, right) =>
|
||||
left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug)
|
||||
);
|
||||
groupsRef.current = groups;
|
||||
setTargets(
|
||||
groups.map((group) => ({
|
||||
id: group.id,
|
||||
label: group.name,
|
||||
secondary: group.slug
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
setTargets([]);
|
||||
setTargetError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoadingTargets(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageLayout
|
||||
title={scopeTitle(scopeType)}
|
||||
description={scopeDescription(scopeType)}
|
||||
loading={loadingTargets}
|
||||
error={targetError}
|
||||
actions={<DocumentationHelpLink reference={CREDENTIAL_DOCUMENTATION} />}
|
||||
>
|
||||
<CredentialEnvelopeManager
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
targetOptions={targets}
|
||||
targetLabel={scopeType === "group" ? "i18n:govoplan-access.group.171a0606" : "i18n:govoplan-access.user.9f8a2389"}
|
||||
title={ACCESS_INTERFACE_I18N.reusableCredentials}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
</AdminPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function scopeTitle(scopeType: ScopeType): string {
|
||||
if (scopeType === "system") return ACCESS_INTERFACE_I18N.systemCredentials;
|
||||
if (scopeType === "tenant") return ACCESS_INTERFACE_I18N.tenantCredentials;
|
||||
if (scopeType === "group") return ACCESS_INTERFACE_I18N.groupCredentials;
|
||||
return ACCESS_INTERFACE_I18N.userCredentials;
|
||||
}
|
||||
|
||||
function scopeDescription(scopeType: ScopeType): string {
|
||||
if (scopeType === "system") {
|
||||
return ACCESS_INTERFACE_I18N.systemCredentialDescription;
|
||||
}
|
||||
if (scopeType === "tenant") {
|
||||
return ACCESS_INTERFACE_I18N.tenantCredentialDescription;
|
||||
}
|
||||
return scopeType === "group"
|
||||
? ACCESS_INTERFACE_I18N.groupCredentialDescription
|
||||
: ACCESS_INTERFACE_I18N.userCredentialDescription;
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo, OrganizationFunctionPickerUiCapability, OrganizationFunctionSelection } from "@govoplan/core-webui";
|
||||
import {
|
||||
createExternalFunctionRoleMapping,
|
||||
deleteExternalFunctionRoleMapping,
|
||||
fetchExternalFunctionRoleMappingsDelta,
|
||||
fetchRolesDelta,
|
||||
updateExternalFunctionRoleMapping,
|
||||
type ExternalFunctionRoleMappingItem,
|
||||
type RoleSummary
|
||||
} from "../../api/admin";
|
||||
import { Button, FormGrid } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
|
||||
import { i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
import { ACCESS_INTERFACE_I18N, FUNCTION_MAPPING_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||
|
||||
const emptyDraft = {
|
||||
sourceModule: "organizations",
|
||||
functionId: "",
|
||||
functionLabel: "",
|
||||
organizationUnitLabel: "",
|
||||
roleId: ""
|
||||
};
|
||||
|
||||
export default function ExternalFunctionRoleMappingsPanel({
|
||||
settings,
|
||||
auth,
|
||||
functionPicker,
|
||||
canWrite,
|
||||
onAuthRefresh
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
functionPicker: OrganizationFunctionPickerUiCapability;
|
||||
canWrite: boolean;
|
||||
onAuthRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const [mappings, setMappings] = useState<ExternalFunctionRoleMappingItem[]>([]);
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const mappingsRef = useRef<ExternalFunctionRoleMappingItem[]>([]);
|
||||
const rolesRef = useRef<RoleSummary[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [editing, setEditing] = useState<ExternalFunctionRoleMappingItem | "new" | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
const [deleting, setDeleting] = useState<ExternalFunctionRoleMappingItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const tenantId = (auth.active_tenant ?? auth.tenant).id;
|
||||
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: closeEditor
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextMappings, nextRoles] = await Promise.all([
|
||||
loadDeltaRows(
|
||||
mappingsRef.current,
|
||||
"access:external-function-role-mappings",
|
||||
getDeltaWatermark,
|
||||
setDeltaWatermark,
|
||||
(since) => fetchExternalFunctionRoleMappingsDelta(settings, { since }),
|
||||
(response) => response.mappings,
|
||||
(mapping) => mapping.id,
|
||||
"access_external_function_role_mapping",
|
||||
sortMappings
|
||||
),
|
||||
loadDeltaRows(
|
||||
rolesRef.current,
|
||||
"access:roles-for-external-function-mappings",
|
||||
getDeltaWatermark,
|
||||
setDeltaWatermark,
|
||||
(since) => fetchRolesDelta(settings, { since }),
|
||||
(response) => response.roles,
|
||||
(role) => role.id,
|
||||
"access_role",
|
||||
sortRoles
|
||||
)
|
||||
]);
|
||||
mappingsRef.current = nextMappings;
|
||||
rolesRef.current = nextRoles;
|
||||
setMappings(nextMappings);
|
||||
setRoles(nextRoles.filter((role) => role.level === "tenant"));
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
mappingsRef.current = [];
|
||||
rolesRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, tenantId, resetDeltaWatermark]);
|
||||
|
||||
const roleById = useMemo(() => new Map(roles.map((role) => [role.id, role])), [roles]);
|
||||
const assignableRoles = useMemo(() => roles.filter((role) => role.is_assignable), [roles]);
|
||||
|
||||
function openCreate() {
|
||||
const initial = { ...emptyDraft, roleId: assignableRoles[0]?.id ?? "" };
|
||||
setDraft(initial);
|
||||
setSavedDraftKey(draftKey(initial));
|
||||
setEditing("new");
|
||||
setError("");
|
||||
}
|
||||
|
||||
function openEdit(mapping: ExternalFunctionRoleMappingItem) {
|
||||
const nextDraft = {
|
||||
sourceModule: mapping.source_module,
|
||||
functionId: mapping.function_id,
|
||||
functionLabel: "",
|
||||
organizationUnitLabel: "",
|
||||
roleId: mapping.role_id
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
setEditing(mapping);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
setEditing(null);
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
await createExternalFunctionRoleMapping(settings, {
|
||||
source_module: functionPicker.sourceModule,
|
||||
function_id: draft.functionId.trim(),
|
||||
role_id: draft.roleId
|
||||
});
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.function_role_mapping_created.7a25eb5a"));
|
||||
} else if (editing) {
|
||||
await updateExternalFunctionRoleMapping(settings, editing.id, { role_id: draft.roleId });
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.function_role_mapping_updated.76020443"));
|
||||
}
|
||||
closeEditor();
|
||||
await onAuthRefresh();
|
||||
mappingsRef.current = [];
|
||||
resetDeltaWatermark("access:external-function-role-mappings");
|
||||
await load();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!deleting) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await deleteExternalFunctionRoleMapping(settings, deleting.id);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.function_role_mapping_deleted.fb180786"));
|
||||
setDeleting(null);
|
||||
await onAuthRefresh();
|
||||
mappingsRef.current = [];
|
||||
resetDeltaWatermark("access:external-function-role-mappings");
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<ExternalFunctionRoleMappingItem>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "function",
|
||||
header: "i18n:govoplan-access.function_fact.4f7435e4",
|
||||
width: "minmax(260px, 1fr)",
|
||||
minWidth: 220,
|
||||
resizable: true,
|
||||
sticky: "start",
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => `${row.source_module} ${row.function_id}`,
|
||||
render: (row) => (
|
||||
<>
|
||||
{functionPicker.renderLabel?.({
|
||||
settings,
|
||||
auth,
|
||||
sourceModule: row.source_module,
|
||||
functionId: row.function_id,
|
||||
fallback: (
|
||||
<div>
|
||||
<strong>{row.source_module}</strong>
|
||||
<div className="muted small-note">{row.function_id}</div>
|
||||
</div>
|
||||
)
|
||||
}) ?? (
|
||||
<div>
|
||||
<strong>{row.source_module}</strong>
|
||||
<div className="muted small-note">{row.function_id}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
header: "i18n:govoplan-access.role.c3f104d1",
|
||||
width: "minmax(220px, 1fr)",
|
||||
minWidth: 190,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => roleById.get(row.role_id)?.name ?? row.role_id,
|
||||
render: (row) => {
|
||||
const role = roleById.get(row.role_id);
|
||||
return (
|
||||
<div>
|
||||
<strong>{role?.name ?? row.role_id}</strong>
|
||||
{role && <div className="muted small-note">{role.slug}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
header: "i18n:govoplan-access.updated.f2f8570d",
|
||||
width: 180,
|
||||
minWidth: 150,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
value: (row) => row.updated_at,
|
||||
render: (row) => formatDateTime(row.updated_at)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-access.actions.c3cd636a",
|
||||
width: 130,
|
||||
sticky: "end",
|
||||
resizable: false,
|
||||
align: "right",
|
||||
render: (row) => <TableActionGroup actions={[
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id }), icon: <Pencil />, disabled: !canWrite, disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.function_id }), icon: <Trash2 />, variant: "danger", disabled: !canWrite, disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => setDeleting(row) }
|
||||
]} />
|
||||
}
|
||||
],
|
||||
[auth, canWrite, functionPicker, roleById, settings]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="i18n:govoplan-access.function_role_mappings.2b64e9c3"
|
||||
description="i18n:govoplan-access.map_accepted_function_facts_to_tenant_roles_.7581e5cf"
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={
|
||||
<>
|
||||
<DocumentationHelpLink reference={FUNCTION_MAPPING_DOCUMENTATION} />
|
||||
<Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button>
|
||||
<AdminIconButton label="i18n:govoplan-access.add_function_role_mapping.1bc376ac" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite || !assignableRoles.length} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.createPermissionRequired : !assignableRoles.length ? ACCESS_INTERFACE_I18N.selectAssignableRole : undefined} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="admin-table-surface">
|
||||
<DataGrid
|
||||
id="admin-external-function-role-mappings-v1"
|
||||
rows={mappings}
|
||||
columns={columns}
|
||||
initialFit="container"
|
||||
getRowKey={(row) => row.id}
|
||||
emptyText="i18n:govoplan-access.no_function_role_mappings_found.f735ff54"
|
||||
/>
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog variant="administration" size="large"
|
||||
open={editing !== null}
|
||||
title={editing === "new" ? "i18n:govoplan-access.create_function_role_mapping.3718168d" : "i18n:govoplan-access.edit_function_role_mapping.91ee75af"}
|
||||
onClose={() => !busy && closeEditor()}
|
||||
className=""
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={closeEditor} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button>
|
||||
<Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canWrite, complete: Boolean(draft.functionId.trim() && draft.roleId) })}>
|
||||
{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_mapping.a4ac90e9"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FormGrid columns={1} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="i18n:govoplan-access.function_id.e5e08937">
|
||||
{functionPicker.renderPicker({
|
||||
settings,
|
||||
auth,
|
||||
disabled: editing !== "new",
|
||||
value: draft.functionId ? draftSelection(draft) : null,
|
||||
onChange: (selection) => setDraft({ ...draft, ...selectionDraft(selection) })
|
||||
})}
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-access.role.c3f104d1">
|
||||
<select value={draft.roleId} onChange={(event) => setDraft({ ...draft, roleId: event.target.value })}>
|
||||
<option value="">i18n:govoplan-access.select_role.c543f191</option>
|
||||
{assignableRoles.map((role) => (
|
||||
<option key={role.id} value={role.id}>{role.name} ({role.slug})</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
<p className="muted small-note">i18n:govoplan-access.function_role_mapping_help.0cf9996a</p>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleting)}
|
||||
title="i18n:govoplan-access.delete_function_role_mapping.0c0eec6e"
|
||||
message={i18nMessage("i18n:govoplan-access.delete_function_role_mapping_value.419da2aa", { value0: deleting?.function_id })}
|
||||
confirmLabel="i18n:govoplan-access.delete_mapping.0d27d92a"
|
||||
tone="danger"
|
||||
busy={busy}
|
||||
onCancel={() => setDeleting(null)}
|
||||
onConfirm={() => void remove()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function draftKey(draft: typeof emptyDraft): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
function draftSelection(draft: typeof emptyDraft): OrganizationFunctionSelection {
|
||||
return {
|
||||
sourceModule: "organizations",
|
||||
functionId: draft.functionId,
|
||||
label: draft.functionLabel || null,
|
||||
organizationUnitLabel: draft.organizationUnitLabel || null
|
||||
};
|
||||
}
|
||||
|
||||
function selectionDraft(selection: OrganizationFunctionSelection | null): Pick<typeof emptyDraft, "sourceModule" | "functionId" | "functionLabel" | "organizationUnitLabel"> {
|
||||
return {
|
||||
sourceModule: selection?.sourceModule ?? "organizations",
|
||||
functionId: selection?.functionId ?? "",
|
||||
functionLabel: selection?.label ?? "",
|
||||
organizationUnitLabel: selection?.organizationUnitLabel ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
function sortMappings(left: ExternalFunctionRoleMappingItem, right: ExternalFunctionRoleMappingItem): number {
|
||||
const sourceDelta = left.source_module.localeCompare(right.source_module);
|
||||
return sourceDelta !== 0 ? sourceDelta : left.function_id.localeCompare(right.function_id);
|
||||
}
|
||||
|
||||
function sortRoles(left: RoleSummary, right: RoleSummary): number {
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ApiSettings, FileConnectorScope, FileConnectorTargetOption, FilesConnectorsUiCapability } from "@govoplan/core-webui";
|
||||
import { ActionBlockerHint, AdminPageLayout, adminErrorMessage, useDeltaWatermarks, usePlatformUiCapability } from "@govoplan/core-webui";
|
||||
import { fetchGroupsDelta, fetchUsersDelta, type GroupSummary, type UserAdminItem } from "../../api/admin";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
import {
|
||||
ACCESS_INTERFACE_I18N,
|
||||
FILE_CONNECTOR_DOCUMENTATION
|
||||
} from "./interfacePatterns";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
scopeType: Extract<FileConnectorScope, "user" | "group">;
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
const copy: Record<Props["scopeType"], {title: string;description: string;targetLabel: string;panelTitle: string;}> = {
|
||||
user: {
|
||||
title: "i18n:govoplan-access.user_file_connections.996444a0",
|
||||
description: "i18n:govoplan-access.user_scoped_file_server_connections_and_credenti.ae7a4be2",
|
||||
targetLabel: "i18n:govoplan-access.user.9f8a2389",
|
||||
panelTitle: "i18n:govoplan-access.user_file_connections.996444a0"
|
||||
},
|
||||
group: {
|
||||
title: "i18n:govoplan-access.group_file_connections.73985bda",
|
||||
description: "i18n:govoplan-access.group_scoped_file_server_connections_and_credent.f32a51bc",
|
||||
targetLabel: "i18n:govoplan-access.group.171a0606",
|
||||
panelTitle: "i18n:govoplan-access.group_file_connections.73985bda"
|
||||
}
|
||||
};
|
||||
|
||||
export default function FileConnectorsPanel({ settings, scopeType, canWrite }: Props) {
|
||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
|
||||
const [targets, setTargets] = useState<FileConnectorTargetOption[]>([]);
|
||||
const usersRef = useRef<UserAdminItem[]>([]);
|
||||
const groupsRef = useRef<GroupSummary[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [loadingTargets, setLoadingTargets] = useState(Boolean(FileConnectorScopeManager));
|
||||
const [targetError, setTargetError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!FileConnectorScopeManager) {
|
||||
setTargets([]);
|
||||
setLoadingTargets(false);
|
||||
setTargetError("");
|
||||
return;
|
||||
}
|
||||
usersRef.current = [];
|
||||
groupsRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void loadTargets();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, FileConnectorScopeManager, resetDeltaWatermark]);
|
||||
|
||||
async function loadTargets() {
|
||||
setLoadingTargets(true);
|
||||
setTargetError("");
|
||||
try {
|
||||
if (scopeType === "user") {
|
||||
const users = await loadDeltaRows(usersRef.current, "access:file-connector-users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers);
|
||||
usersRef.current = users;
|
||||
setTargets(users.map((user) => ({
|
||||
id: user.id,
|
||||
label: user.display_name || user.email,
|
||||
secondary: user.display_name ? user.email : null
|
||||
})));
|
||||
} else {
|
||||
const groups = await loadDeltaRows(groupsRef.current, "access:file-connector-groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups);
|
||||
groupsRef.current = groups;
|
||||
setTargets(groups.map((group) => ({
|
||||
id: group.id,
|
||||
label: group.name,
|
||||
secondary: group.slug
|
||||
})));
|
||||
}
|
||||
} catch (err) {
|
||||
setTargets([]);
|
||||
setTargetError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoadingTargets(false);
|
||||
}
|
||||
}
|
||||
|
||||
const labels = copy[scopeType];
|
||||
|
||||
if (!FileConnectorScopeManager) {
|
||||
return (
|
||||
<AdminPageLayout title={labels.title} description={labels.description}>
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: "i18n:govoplan-access.files_module_unavailable.0ee90db1",
|
||||
details: "i18n:govoplan-access.install_and_enable_the_files_module_to_manage_fi.f842c153",
|
||||
requiredAction: ACCESS_INTERFACE_I18N.installFiles,
|
||||
actor: ACCESS_INTERFACE_I18N.systemModuleAdministrator,
|
||||
target: ACCESS_INTERFACE_I18N.moduleManagement
|
||||
}}
|
||||
labels={{
|
||||
requiredAction: ACCESS_INTERFACE_I18N.requiredAction,
|
||||
actor: ACCESS_INTERFACE_I18N.actor,
|
||||
target: ACCESS_INTERFACE_I18N.destinationLabel
|
||||
}}
|
||||
documentation={FILE_CONNECTOR_DOCUMENTATION}
|
||||
/>
|
||||
</AdminPageLayout>);
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError}>
|
||||
<FileConnectorScopeManager
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
targetOptions={targets}
|
||||
targetLabel={labels.targetLabel}
|
||||
title={labels.panelTitle}
|
||||
canWrite={canWrite} />
|
||||
|
||||
</AdminPageLayout>);
|
||||
|
||||
}
|
||||
|
||||
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
|
||||
return left.email.localeCompare(right.email);
|
||||
}
|
||||
|
||||
function sortGroups(left: GroupSummary, right: GroupSummary): number {
|
||||
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
|
||||
}
|
||||
@@ -1,65 +1,105 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ContentGrid, DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createGroup, fetchGroups, fetchRoles, fetchUsers, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
|
||||
import { createGroup, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
import {
|
||||
ACCESS_INTERFACE_I18N,
|
||||
ACCESS_WORKFLOW_DOCUMENTATION,
|
||||
saveDisabledReason
|
||||
} from "./interfacePatterns";
|
||||
|
||||
const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] };
|
||||
|
||||
export default function GroupsPanel({ settings, auth, canDefine, canManageMembers, canAssignRoles, onAuthRefresh }: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
canDefine: boolean;
|
||||
canManageMembers: boolean;
|
||||
canAssignRoles: boolean;
|
||||
onAuthRefresh: () => Promise<void>;
|
||||
}) {
|
||||
export default function GroupsPanel({ settings, auth, canDefine, canManageMembers, canAssignRoles, onAuthRefresh
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;auth: AuthInfo;canDefine: boolean;canManageMembers: boolean;canAssignRoles: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
const [groups, setGroups] = useState<GroupSummary[]>([]);
|
||||
const [users, setUsers] = useState<UserAdminItem[]>([]);
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const groupsRef = useRef<GroupSummary[]>([]);
|
||||
const usersRef = useRef<UserAdminItem[]>([]);
|
||||
const rolesRef = useRef<RoleSummary[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [editing, setEditing] = useState<GroupSummary | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<GroupSummary | null>(null);
|
||||
const [deactivating, setDeactivating] = useState<GroupSummary | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: closeEditor
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextGroups, nextUsers, nextRoles] = await Promise.all([fetchGroups(settings), fetchUsers(settings), fetchRoles(settings)]);
|
||||
const [nextGroups, nextUsers, nextRoles] = await Promise.all([
|
||||
loadDeltaRows(groupsRef.current, "access:groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups),
|
||||
loadDeltaRows(usersRef.current, "access:users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers),
|
||||
loadDeltaRows(rolesRef.current, "access:roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_role", sortTenantRoles)]
|
||||
);
|
||||
groupsRef.current = nextGroups;
|
||||
usersRef.current = nextUsers;
|
||||
rolesRef.current = nextRoles;
|
||||
setGroups(nextGroups);
|
||||
setUsers(nextUsers);
|
||||
setRoles(nextRoles.filter((role) => role.is_assignable));
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setLoading(false); }
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setLoading(false);}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]);
|
||||
useEffect(() => {
|
||||
groupsRef.current = [];
|
||||
usersRef.current = [];
|
||||
rolesRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, resetDeltaWatermark]);
|
||||
|
||||
function openCreate() { setDraft(emptyDraft); setEditing("new"); setError(""); }
|
||||
function openCreate() {setDraft(emptyDraft);setSavedDraftKey(draftKey(emptyDraft));setEditing("new");setError("");}
|
||||
function openEdit(group: GroupSummary) {
|
||||
setDraft({ slug: group.slug, name: group.name, description: group.description || "", isActive: group.is_active, memberIds: group.member_ids, roleIds: group.roles.map((role) => role.id) });
|
||||
const nextDraft = { slug: group.slug, name: group.name, description: group.description || "", isActive: group.is_active, memberIds: group.member_ids, roleIds: group.roles.map((role) => role.id) };
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
setEditing(group);
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
function closeEditor() {
|
||||
setEditing(null);
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
await createGroup(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, is_active: draft.isActive, member_ids: canManageMembers ? draft.memberIds : [], role_ids: canAssignRoles ? draft.roleIds : [] });
|
||||
setSuccess(`Group ${draft.name} created.`);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.group_value_created.5a39a341", { value0: draft.name }));
|
||||
} else if (editing) {
|
||||
const managed = Boolean(editing.system_template_id);
|
||||
await updateGroup(settings, editing.id, {
|
||||
@@ -68,13 +108,14 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember
|
||||
...(canManageMembers ? { member_ids: draft.memberIds } : {}),
|
||||
...(canAssignRoles ? { role_ids: draft.roleIds } : {})
|
||||
});
|
||||
setSuccess(`Group ${draft.name} updated.`);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.group_value_updated.3d97d5b2", { value0: draft.name }));
|
||||
}
|
||||
setEditing(null);
|
||||
await onAuthRefresh();
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
return true;
|
||||
} catch (err) {setError(adminErrorMessage(err));return false;} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
@@ -83,57 +124,75 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember
|
||||
setError("");
|
||||
try {
|
||||
await updateGroup(settings, deactivating.id, { is_active: false });
|
||||
setSuccess(`Group ${deactivating.name} deactivated.`);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.group_value_deactivated.8512bf9c", { value0: deactivating.name }));
|
||||
setDeactivating(null);
|
||||
if (deactivating.member_ids.includes(auth.user.id)) await onAuthRefresh();
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<GroupSummary>[]>(() => [
|
||||
{ id: "group", header: "Group", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">System{row.system_required ? " · required" : ""}</span>}</div></div> },
|
||||
{ id: "members", header: "Members", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count },
|
||||
{ id: "roles", header: "Inherited roles", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canDefine || canManageMembers || canAssignRoles)} />
|
||||
<AdminIconButton label={`Deactivate ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canDefine || !row.is_active || Boolean(row.system_required)} />
|
||||
</div> }
|
||||
], [canAssignRoles, canDefine, canManageMembers]);
|
||||
{ id: "group", header: "i18n:govoplan-access.group.171a0606", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <StatusBadge status={row.system_required ? "warning" : "inactive"} label={`i18n:govoplan-access.system.bc0792d8${row.system_required ? " · i18n:govoplan-access.required.7c65879a" : ""}`} />}</div></div> },
|
||||
{ id: "members", header: "i18n:govoplan-access.members.1cb449c1", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count },
|
||||
{ id: "roles", header: "i18n:govoplan-access.inherited_roles.8def9f05", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !(canDefine || canManageMembers || canAssignRoles), disabledReason: !(canDefine || canManageMembers || canAssignRoles) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canDefine || Boolean(row.system_required), disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canDefine ? ACCESS_INTERFACE_I18N.writePermissionRequired : row.system_required ? ACCESS_INTERFACE_I18N.systemManagedObject : undefined, onClick: () => setDeactivating(row) }
|
||||
]} /> }],
|
||||
[canAssignRoles, canDefine, canManageMembers]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="Tenant groups" description="Groups provide shared file spaces and inherited roles. System-managed definitions are controlled centrally; tenant administrators still assign their local members and roles." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add group" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-groups-v3" rows={groups} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No groups found." /></div>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_groups.47e6cc05" description="i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309" loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_group.2fca464f" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} disabledReason={!canDefine ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-groups-v3" rows={groups} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_groups_found.627ca913" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "Create group" : "Edit group"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim() || (editing === "new" ? !canDefine : !(canDefine || canManageMembers || canAssignRoles))}>{busy ? "Saving…" : "Save group"}</Button></>}>
|
||||
{editing !== "new" && editing?.system_template_id && <p className="admin-managed-notice">This group definition is managed by the system. Name, description and required availability are read-only here; membership and inherited roles remain tenant-specific.</p>}
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Name"><input value={draft.name} disabled={!canDefine || (editing !== "new" && Boolean(editing?.system_template_id))} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new" || !canDefine} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="Status"><select value={draft.isActive ? "active" : "inactive"} disabled={!canDefine || (editing !== "new" && Boolean(editing?.system_required))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
|
||||
<FormField label="Description"><textarea rows={3} value={draft.description} disabled={!canDefine || (editing !== "new" && Boolean(editing?.system_template_id))} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</div>
|
||||
<div className="admin-assignment-grid">
|
||||
<div><span className="form-label">Members</span><AdminSelectionList options={users.map((user) => ({ id: user.id, label: user.display_name || user.email, description: user.email, disabled: !canManageMembers || !user.is_active || !user.account_is_active }))} selected={draft.memberIds} onChange={(memberIds) => setDraft({ ...draft, memberIds })} emptyText="No tenant users exist." /></div>
|
||||
<div><span className="form-label">Inherited roles</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="No assignable roles exist." /></div>
|
||||
</div>
|
||||
<p className="muted small-note">The backend evaluates the resulting access graph and refuses changes that remove the tenant's final operational owner.</p>
|
||||
<Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_group.5a0b1c17" : "i18n:govoplan-access.edit_group.edb57d8e"} onClose={() => !busy && setEditing(null)} className="" footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: editing === "new" ? canDefine : canDefine || canManageMembers || canAssignRoles, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_group.36ca6865"}</Button></>}>
|
||||
{editing !== "new" && editing?.system_template_id && <p className="admin-managed-notice">i18n:govoplan-access.this_group_definition_is_managed_by_the_system_n.640b235e</p>}
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new" || !canDefine} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_required)} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</FormGrid>
|
||||
<ContentGrid columns={2} spacing="block" collapseAt="wide">
|
||||
<div><span className="form-label">i18n:govoplan-access.members.1cb449c1</span><AdminSelectionList options={users.map((user) => ({ id: user.id, label: user.display_name || user.email, description: user.email, disabled: !canManageMembers || !user.is_active || !user.account_is_active }))} selected={draft.memberIds} onChange={(memberIds) => setDraft({ ...draft, memberIds })} emptyText="i18n:govoplan-access.no_tenant_users_exist.96b4a88a" /></div>
|
||||
<div><span className="form-label">i18n:govoplan-access.inherited_roles.8def9f05</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div>
|
||||
</ContentGrid>
|
||||
<p className="muted small-note">i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="Group details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
{viewing && <dl className="admin-details-grid">
|
||||
<div><dt>Group</dt><dd>{viewing.name}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div>
|
||||
<div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Management</dt><dd>{viewing.system_template_id ? `System managed${viewing.system_required ? ", required" : ", available"}` : "Tenant managed"}</dd></div>
|
||||
<div><dt>Members</dt><dd>{viewing.member_count}</dd></div><div><dt>Roles</dt><dd>{joinLabels(viewing.roles)}</dd></div>
|
||||
<div><dt>Created</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>Updated</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
|
||||
</dl>}
|
||||
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.group_details.df844ae3" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <DescriptionList>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.group.171a0606</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.slug.094da9b9</>}>{viewing.slug}</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.status.bae7d5be</>}>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.management.63cecca6</>}>{viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_managed.843eed93"}</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.members.1cb449c1</>}>{viewing.member_count}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.roles.47dcc27d</>}>{joinLabels(viewing.roles)}</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.created.accf40c8</>}>{formatDateTime(viewing.created_at)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.updated.f2f8570d</>}>{formatDateTime(viewing.updated_at)}</DescriptionItem>
|
||||
</DescriptionList>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deactivating)} title="Deactivate group" message={`Deactivate ${deactivating?.name}? Memberships remain stored, but role inheritance stops.`} confirmLabel="Deactivate group" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
||||
</>
|
||||
);
|
||||
<ConfirmDialog open={Boolean(deactivating)} title="i18n:govoplan-access.deactivate_group.f1b8ceea" message={i18nMessage("i18n:govoplan-access.deactivate_value_memberships_remain_stored_but_r.4693e4fd", { value0: deactivating?.name })} confirmLabel="i18n:govoplan-access.deactivate_group.f1b8ceea" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function draftKey(draft: typeof emptyDraft): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
function sortGroups(left: GroupSummary, right: GroupSummary): number {
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
|
||||
return (left.display_name || left.email).localeCompare(right.display_name || right.email) || left.email.localeCompare(right.email);
|
||||
}
|
||||
|
||||
function sortTenantRoles(left: RoleSummary, right: RoleSummary): number {
|
||||
const builtinDelta = Number(right.is_builtin) - Number(left.is_builtin);
|
||||
if (builtinDelta !== 0) return builtinDelta;
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ApiSettings, MailProfileScope, MailProfilesUiCapability, MailProfileTargetOption } from "@govoplan/core-webui";
|
||||
import { fetchGroups, fetchUsers } from "../../api/admin";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
|
||||
import { fetchGroupsDelta, fetchUsersDelta, type GroupSummary, type UserAdminItem } from "../../api/admin";
|
||||
import { ActionBlockerHint, AdminPageLayout, adminErrorMessage, useDeltaWatermarks } from "@govoplan/core-webui";
|
||||
import { usePlatformUiCapability } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
import {
|
||||
ACCESS_INTERFACE_I18N,
|
||||
MAIL_PROFILE_DOCUMENTATION
|
||||
} from "./interfacePatterns";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
@@ -13,32 +17,32 @@ type Props = {
|
||||
canWritePolicy: boolean;
|
||||
};
|
||||
|
||||
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; profileTitle: string; policyTitle: string }> = {
|
||||
const copy: Record<Props["scopeType"], {title: string;description: string;targetLabel?: string;profileTitle: string;policyTitle: string;}> = {
|
||||
system: {
|
||||
title: "System mail profiles",
|
||||
description: "Instance-level mail server profiles and policy limits inherited by every tenant.",
|
||||
profileTitle: "System profiles",
|
||||
policyTitle: "System mail profile policy"
|
||||
title: "i18n:govoplan-access.system_mail_profiles.5af3eb64",
|
||||
description: "i18n:govoplan-access.instance_level_mail_server_profiles_and_policy_l.b807bda7",
|
||||
profileTitle: "i18n:govoplan-access.system_profiles.98adae54",
|
||||
policyTitle: "i18n:govoplan-access.system_mail_profile_policy.7edd7fcf"
|
||||
},
|
||||
tenant: {
|
||||
title: "Tenant mail profiles",
|
||||
description: "Tenant-level mail server profiles and policy limits for the active tenant.",
|
||||
profileTitle: "Tenant profiles",
|
||||
policyTitle: "Tenant mail profile policy"
|
||||
title: "i18n:govoplan-access.tenant_mail_profiles.5132a623",
|
||||
description: "i18n:govoplan-access.tenant_level_mail_server_profiles_and_policy_lim.d829507f",
|
||||
profileTitle: "i18n:govoplan-access.tenant_profiles.4d7281ce",
|
||||
policyTitle: "i18n:govoplan-access.tenant_mail_profile_policy.239298a1"
|
||||
},
|
||||
user: {
|
||||
title: "User mail profiles",
|
||||
description: "User-scoped profiles and policy limits for campaign owners in the active tenant.",
|
||||
targetLabel: "User",
|
||||
profileTitle: "User profiles",
|
||||
policyTitle: "User mail profile policy"
|
||||
title: "i18n:govoplan-access.user_mail_profiles.f54a845a",
|
||||
description: "i18n:govoplan-access.user_scoped_profiles_and_policy_limits_for_campa.bff6eccf",
|
||||
targetLabel: "i18n:govoplan-access.user.9f8a2389",
|
||||
profileTitle: "i18n:govoplan-access.user_profiles.57730285",
|
||||
policyTitle: "i18n:govoplan-access.user_mail_profile_policy.529e035b"
|
||||
},
|
||||
group: {
|
||||
title: "Group mail profiles",
|
||||
description: "Group-scoped profiles and policy limits for group-owned campaigns in the active tenant.",
|
||||
targetLabel: "Group",
|
||||
profileTitle: "Group profiles",
|
||||
policyTitle: "Group mail profile policy"
|
||||
title: "i18n:govoplan-access.group_mail_profiles.ebf1b5ba",
|
||||
description: "i18n:govoplan-access.group_scoped_profiles_and_policy_limits_for_grou.a314ba66",
|
||||
targetLabel: "i18n:govoplan-access.group.171a0606",
|
||||
profileTitle: "i18n:govoplan-access.group_profiles.74568838",
|
||||
policyTitle: "i18n:govoplan-access.group_mail_profile_policy.d98ef5a2"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -46,6 +50,9 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
||||
const [targets, setTargets] = useState<MailProfileTargetOption[]>([]);
|
||||
const usersRef = useRef<UserAdminItem[]>([]);
|
||||
const groupsRef = useRef<GroupSummary[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [loadingTargets, setLoadingTargets] = useState(Boolean(MailProfileScopeManager) && (scopeType === "user" || scopeType === "group"));
|
||||
const [targetError, setTargetError] = useState("");
|
||||
|
||||
@@ -56,8 +63,11 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
|
||||
setTargetError("");
|
||||
return;
|
||||
}
|
||||
usersRef.current = [];
|
||||
groupsRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void loadTargets();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, MailProfileScopeManager]);
|
||||
}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, MailProfileScopeManager, resetDeltaWatermark]);
|
||||
|
||||
async function loadTargets() {
|
||||
if (scopeType !== "user" && scopeType !== "group") {
|
||||
@@ -70,14 +80,16 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
|
||||
setTargetError("");
|
||||
try {
|
||||
if (scopeType === "user") {
|
||||
const users = await fetchUsers(settings);
|
||||
const users = await loadDeltaRows(usersRef.current, "access:mail-profile-users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers);
|
||||
usersRef.current = users;
|
||||
setTargets(users.map((user) => ({
|
||||
id: user.id,
|
||||
label: user.display_name || user.email,
|
||||
secondary: user.display_name ? user.email : null
|
||||
})));
|
||||
} else {
|
||||
const groups = await fetchGroups(settings);
|
||||
const groups = await loadDeltaRows(groupsRef.current, "access:mail-profile-groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups);
|
||||
groupsRef.current = groups;
|
||||
setTargets(groups.map((group) => ({
|
||||
id: group.id,
|
||||
label: group.name,
|
||||
@@ -97,11 +109,23 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
|
||||
if (!MailProfileScopeManager) {
|
||||
return (
|
||||
<AdminPageLayout title={labels.title} description={labels.description}>
|
||||
<Card title="Mail module unavailable">
|
||||
<p className="muted">Install and enable the Mail module to manage mail server profiles and profile policies.</p>
|
||||
</Card>
|
||||
</AdminPageLayout>
|
||||
);
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: "i18n:govoplan-access.mail_module_unavailable.b4e95104",
|
||||
details: "i18n:govoplan-access.install_and_enable_the_mail_module_to_manage_mai.a8ad5b3a",
|
||||
requiredAction: ACCESS_INTERFACE_I18N.installMail,
|
||||
actor: ACCESS_INTERFACE_I18N.systemModuleAdministrator,
|
||||
target: ACCESS_INTERFACE_I18N.moduleManagement
|
||||
}}
|
||||
labels={{
|
||||
requiredAction: ACCESS_INTERFACE_I18N.requiredAction,
|
||||
actor: ACCESS_INTERFACE_I18N.actor,
|
||||
target: ACCESS_INTERFACE_I18N.destinationLabel
|
||||
}}
|
||||
documentation={MAIL_PROFILE_DOCUMENTATION}
|
||||
/>
|
||||
</AdminPageLayout>);
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -115,8 +139,16 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
|
||||
policyTitle={labels.policyTitle}
|
||||
canWriteProfiles={canWriteProfiles}
|
||||
canManageCredentials={canManageCredentials}
|
||||
canWritePolicy={canWritePolicy}
|
||||
/>
|
||||
</AdminPageLayout>
|
||||
);
|
||||
canWritePolicy={canWritePolicy} />
|
||||
|
||||
</AdminPageLayout>);
|
||||
|
||||
}
|
||||
|
||||
function sortUsers(left: UserAdminItem, right: UserAdminItem): number {
|
||||
return left.email.localeCompare(right.email);
|
||||
}
|
||||
|
||||
function sortGroups(left: GroupSummary, right: GroupSummary): number {
|
||||
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { fetchGroups, fetchUsers, runRetentionPolicy, type PrivacyRetentionPolicyScope, type RetentionRunResponse } from "../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { RetentionPolicyScopeManager, type RetentionPolicyTargetOption } from "@govoplan/core-webui";
|
||||
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
scopeType: Extract<PrivacyRetentionPolicyScope, "system" | "tenant" | "user" | "group">;
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
const copy: Record<Props["scopeType"], { title: string; description: string; targetLabel?: string; policyTitle: string; policyDescription: string }> = {
|
||||
system: {
|
||||
title: "System retention",
|
||||
description: "Instance-wide privacy retention policy and lower-level override permissions.",
|
||||
policyTitle: "System retention policy",
|
||||
policyDescription: "Set concrete system retention values. The Allow override toggles decide which fields tenants, owners and campaigns may override."
|
||||
},
|
||||
tenant: {
|
||||
title: "Tenant retention",
|
||||
description: "Tenant-level privacy and retention limits for the active tenant.",
|
||||
policyTitle: "Tenant retention policy",
|
||||
policyDescription: "Tenant limits may only narrow the system policy. The Allow override toggles decide which fields users, groups and campaigns may override."
|
||||
},
|
||||
user: {
|
||||
title: "User retention",
|
||||
description: "User-scoped retention limits for campaigns owned by users in the active tenant.",
|
||||
targetLabel: "User",
|
||||
policyTitle: "User retention policy",
|
||||
policyDescription: "User limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields user-owned campaigns may override."
|
||||
},
|
||||
group: {
|
||||
title: "Group retention",
|
||||
description: "Group-scoped retention limits for group-owned campaigns in the active tenant.",
|
||||
targetLabel: "Group",
|
||||
policyTitle: "Group retention policy",
|
||||
policyDescription: "Group limits may only narrow inherited system and tenant policy. The Allow override toggles decide which fields group-owned campaigns may override."
|
||||
}
|
||||
};
|
||||
|
||||
export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }: Props) {
|
||||
const [targets, setTargets] = useState<RetentionPolicyTargetOption[]>([]);
|
||||
const [loadingTargets, setLoadingTargets] = useState(scopeType === "user" || scopeType === "group");
|
||||
const [targetError, setTargetError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [success, setSuccess] = useState("");
|
||||
const [runError, setRunError] = useState("");
|
||||
const [confirmRetentionRun, setConfirmRetentionRun] = useState(false);
|
||||
const [retentionResult, setRetentionResult] = useState<RetentionRunResponse | null>(null);
|
||||
|
||||
useEffect(() => { void loadTargets(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType]);
|
||||
|
||||
async function loadTargets() {
|
||||
if (scopeType !== "user" && scopeType !== "group") {
|
||||
setTargets([]);
|
||||
setLoadingTargets(false);
|
||||
setTargetError("");
|
||||
return;
|
||||
}
|
||||
setLoadingTargets(true);
|
||||
setTargetError("");
|
||||
try {
|
||||
if (scopeType === "user") {
|
||||
const users = await fetchUsers(settings);
|
||||
setTargets(users.map((user) => ({
|
||||
id: user.id,
|
||||
label: user.display_name || user.email,
|
||||
secondary: user.display_name ? user.email : null
|
||||
})));
|
||||
} else {
|
||||
const groups = await fetchGroups(settings);
|
||||
setTargets(groups.map((group) => ({
|
||||
id: group.id,
|
||||
label: group.name,
|
||||
secondary: group.slug
|
||||
})));
|
||||
}
|
||||
} catch (err) {
|
||||
setTargets([]);
|
||||
setTargetError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoadingTargets(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runRetention(dryRun: boolean) {
|
||||
setBusy(true);
|
||||
setRunError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const response = await runRetentionPolicy(settings, dryRun);
|
||||
setRetentionResult(response);
|
||||
setSuccess(dryRun ? "Retention dry run completed." : "Retention policy applied.");
|
||||
setConfirmRetentionRun(false);
|
||||
} catch (err) { setRunError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const labels = copy[scopeType];
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title={labels.title} description={labels.description} loading={loadingTargets} error={targetError || runError} success={success}>
|
||||
<RetentionPolicyScopeManager
|
||||
settings={settings}
|
||||
scopeType={scopeType}
|
||||
targetOptions={targets}
|
||||
targetLabel={labels.targetLabel}
|
||||
title={labels.policyTitle}
|
||||
description={labels.policyDescription}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
{scopeType === "system" && (
|
||||
<div className="retention-run-card">
|
||||
<Card title="Retention execution">
|
||||
<p className="muted small-note">Run the saved effective retention policy against stored raw JSON, generated EML, report detail, mock mailbox content and audit detail.</p>
|
||||
<div className="button-row compact-actions subsection-bottom-actions">
|
||||
<Button onClick={() => void runRetention(true)} disabled={!canWrite || busy}>Dry run</Button>
|
||||
<Button variant="danger" onClick={() => setConfirmRetentionRun(true)} disabled={!canWrite || busy}>Apply retention</Button>
|
||||
</div>
|
||||
{retentionResult && <pre className="admin-json-preview">{JSON.stringify(retentionResult.result, null, 2)}</pre>}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</AdminPageLayout>
|
||||
<ConfirmDialog
|
||||
open={confirmRetentionRun}
|
||||
title="Apply retention policy"
|
||||
message="This will redact or delete eligible retained data according to the saved policy. Run a dry run first if the counts have not been reviewed."
|
||||
confirmLabel="Apply retention"
|
||||
tone="danger"
|
||||
busy={busy}
|
||||
onCancel={() => setConfirmRetentionRun(false)}
|
||||
onConfirm={() => void runRetention(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,63 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createRole, deleteRole, fetchPermissionCatalog, fetchRoles, updateRole, type PermissionItem, type RoleSummary } from "../../api/admin";
|
||||
import { createRole, deleteRole, fetchPermissionCatalog, fetchRolesDelta, updateRole, type PermissionItem, type RoleSummary } from "../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
|
||||
import { hasTenantWildcard } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage } from "@govoplan/core-webui";
|
||||
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
import { ACCESS_INTERFACE_I18N, ACCESS_WORKFLOW_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||
|
||||
const emptyDraft = { slug: "", name: "", description: "", permissions: [] as string[], isAssignable: true };
|
||||
|
||||
export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }: { settings: ApiSettings; auth: AuthInfo; canDefine: boolean; onAuthRefresh: () => Promise<void> }) {
|
||||
export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }: {settings: ApiSettings;auth: AuthInfo;canDefine: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
||||
const rolesRef = useRef<RoleSummary[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [editing, setEditing] = useState<RoleSummary | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<RoleSummary | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
const [deleting, setDeleting] = useState<RoleSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: closeEditor
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextRoles, nextPermissions] = await Promise.all([fetchRoles(settings), fetchPermissionCatalog(settings)]);
|
||||
const [nextRoles, nextPermissions] = await Promise.all([
|
||||
loadDeltaRows(rolesRef.current, "access:roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_role", sortTenantRoles),
|
||||
fetchPermissionCatalog(settings)]
|
||||
);
|
||||
rolesRef.current = nextRoles;
|
||||
setRoles(nextRoles);
|
||||
setPermissions(nextPermissions.filter((permission) => permission.level === "tenant"));
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setLoading(false); }
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setLoading(false);}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]);
|
||||
useEffect(() => {
|
||||
rolesRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id, resetDeltaWatermark]);
|
||||
|
||||
const permissionGroups = useMemo(() => {
|
||||
const groups = new Map<string, PermissionItem[]>();
|
||||
@@ -44,35 +65,46 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
|
||||
return Array.from(groups.entries());
|
||||
}, [permissions]);
|
||||
|
||||
function openCreate() { setDraft(emptyDraft); setEditing("new"); setError(""); }
|
||||
function openCreate() {setDraft(emptyDraft);setSavedDraftKey(draftKey(emptyDraft));setEditing("new");setError("");}
|
||||
function openEdit(role: RoleSummary) {
|
||||
if (role.is_builtin || role.system_template_id) return;
|
||||
setDraft({ slug: role.slug, name: role.name, description: role.description || "", permissions: hasTenantWildcard(role.permissions) ? permissions.map((permission) => permission.scope) : role.permissions, isAssignable: role.is_assignable });
|
||||
const nextDraft = { slug: role.slug, name: role.name, description: role.description || "", permissions: hasTenantWildcard(role.permissions) ? permissions.map((permission) => permission.scope) : role.permissions, isAssignable: role.is_assignable };
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
setEditing(role);
|
||||
setError("");
|
||||
}
|
||||
function togglePermission(scope: string, checked: boolean) {
|
||||
const next = new Set(draft.permissions);
|
||||
if (checked) next.add(scope); else next.delete(scope);
|
||||
setDraft({ ...draft, permissions: Array.from(next) });
|
||||
|
||||
function closeEditor() {
|
||||
setEditing(null);
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
}
|
||||
function setPermissionGroup(scopes: string[], selected: string[]) {
|
||||
const groupScopes = new Set(scopes);
|
||||
setDraft({
|
||||
...draft,
|
||||
permissions: [...draft.permissions.filter((scope) => !groupScopes.has(scope)), ...selected]
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editing === "new") {
|
||||
await createRole(settings, { slug: draft.slug, name: draft.name, description: draft.description || null, permissions: draft.permissions });
|
||||
setSuccess(`Role ${draft.name} created.`);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.role_value_created.2a964899", { value0: draft.name }));
|
||||
} else if (editing) {
|
||||
await updateRole(settings, editing.id, { name: draft.name, description: draft.description || null, permissions: draft.permissions, is_assignable: draft.isAssignable });
|
||||
setSuccess(`Role ${draft.name} updated.`);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.role_value_updated.ec386eb0", { value0: draft.name }));
|
||||
}
|
||||
setEditing(null);
|
||||
await onAuthRefresh();
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
return true;
|
||||
} catch (err) {setError(adminErrorMessage(err));return false;} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
@@ -81,51 +113,64 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
|
||||
setError("");
|
||||
try {
|
||||
await deleteRole(settings, deleting.id);
|
||||
setSuccess(`Role ${deleting.name} deleted.`);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.role_value_deleted.3bd819cf", { value0: deleting.name }));
|
||||
setDeleting(null);
|
||||
await onAuthRefresh();
|
||||
await load();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
|
||||
{ id: "role", header: "Role", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">System{row.system_required ? " · required" : ""}</span>}</div></div> },
|
||||
{ id: "permissions", header: "Permissions", width: 170, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.effective_permission_count, render: (row) => hasTenantWildcard(row.permissions) ? `${row.effective_permission_count} (tenant:*)` : String(row.effective_permission_count) },
|
||||
{ id: "assignments", header: "Assignments", width: 220, minWidth: 170, maxWidth: 420, resizable: true, fill: true, sortable: true, value: (row) => row.user_assignments + row.group_assignments, render: (row) => `${row.user_assignments} users / ${row.group_assignments} groups` },
|
||||
{ id: "type", header: "Type", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "Built-in" : row.system_template_id ? "System" : "Custom"} /> },
|
||||
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id)} />
|
||||
<AdminIconButton label={`Delete ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id) || row.user_assignments + row.group_assignments > 0} />
|
||||
</div> }
|
||||
], [canDefine, permissions]);
|
||||
{ id: "role", header: "i18n:govoplan-access.role.c3f104d1", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <StatusBadge status={row.system_required ? "warning" : "inactive"} label={`i18n:govoplan-access.system.bc0792d8${row.system_required ? " · i18n:govoplan-access.required.7c65879a" : ""}`} />}</div></div> },
|
||||
{ id: "permissions", header: "i18n:govoplan-access.permissions.d06d5557", width: 170, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.effective_permission_count, render: (row) => hasTenantWildcard(row.permissions) ? `${row.effective_permission_count} (tenant:*)` : String(row.effective_permission_count) },
|
||||
{ id: "assignments", header: "i18n:govoplan-access.assignments.057d58c7", width: 220, minWidth: 170, maxWidth: 420, resizable: true, fill: true, sortable: true, value: (row) => row.user_assignments + row.group_assignments, render: (row) => `${row.user_assignments} users / ${row.group_assignments} groups` },
|
||||
{ id: "type", header: "i18n:govoplan-access.type.3deb7456", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : row.system_template_id ? "i18n:govoplan-access.system.bc0792d8" : "i18n:govoplan-access.custom.081ae3fd"} /> },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine, disabledReason: row.is_builtin || row.system_template_id ? ACCESS_INTERFACE_I18N.systemManagedObject : !canDefine ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine || row.user_assignments + row.group_assignments > 0, disabledReason: row.is_builtin || row.system_template_id ? ACCESS_INTERFACE_I18N.systemManagedObject : !canDefine ? ACCESS_INTERFACE_I18N.writePermissionRequired : row.user_assignments + row.group_assignments > 0 ? ACCESS_INTERFACE_I18N.assignedObjectCannotBeDeleted : undefined, onClick: () => setDeleting(row) }
|
||||
]} /> }],
|
||||
[canDefine, permissions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="Tenant roles" description="Roles are explicit tenant permission bundles. Built-in and system-managed definitions are inspected here but changed only by their authoritative source." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add role" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-roles-v3" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No roles found." /></div>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_roles.51aca82d" description="i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa" loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_role.d8d5d55c" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} disabledReason={!canDefine ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-roles-v3" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_roles_found.70f7c0c9" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "Create role" : "Edit role"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={!canDefine || busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "Saving…" : "Save role"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
{editing !== "new" && <FormField label="Assignable"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">Yes</option><option value="no">No</option></select></FormField>}
|
||||
</div>
|
||||
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => <fieldset key={category} className="admin-permission-group"><legend>{category}</legend>{items.map((permission) => <label key={permission.scope} className="admin-selection-item"><input type="checkbox" checked={draft.permissions.includes(permission.scope)} onChange={(event) => togglePermission(permission.scope, event.target.checked)} /><span><strong>{permission.label}</strong><small>{permission.description}<code>{permission.scope}</code></small></span></label>)}</fieldset>)}</div>
|
||||
<Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_role.db859bad" : "i18n:govoplan-access.edit_role.61dd63e9"} onClose={() => !busy && setEditing(null)} className="" footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canDefine, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
{editing !== "new" && <FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>}
|
||||
</FormGrid>
|
||||
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => {
|
||||
const scopes = items.map((permission) => permission.scope);
|
||||
return <fieldset key={category} className="admin-permission-group"><legend>{category}</legend><AdminSelectionList options={items.map((permission) => ({ id: permission.scope, label: permission.label, description: <>{permission.description}<code>{permission.scope}</code></> }))} selected={draft.permissions.filter((scope) => scopes.includes(scope))} onChange={(selected) => setPermissionGroup(scopes, selected)} /></fieldset>;
|
||||
})}</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="Role details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
{viewing && <><dl className="admin-details-grid">
|
||||
<div><dt>Role</dt><dd>{viewing.name}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div>
|
||||
<div><dt>Type</dt><dd>{viewing.is_builtin ? "Built-in" : viewing.system_template_id ? `System managed${viewing.system_required ? ", required" : ", available"}` : "Tenant custom"}</dd></div><div><dt>Assignable</dt><dd>{viewing.is_assignable ? "Yes" : "No"}</dd></div>
|
||||
<div><dt>User assignments</dt><dd>{viewing.user_assignments}</dd></div><div><dt>Group assignments</dt><dd>{viewing.group_assignments}</dd></div>
|
||||
</dl><h3>Permissions</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
||||
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.role_details.a16b5d9f" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <><DescriptionList>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.role.c3f104d1</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.slug.094da9b9</>}>{viewing.slug}</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.type.3deb7456</>}>{viewing.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_custom.4307081e"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.assignable.a88debc5</>}>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.user_assignments.bc7cc801</>}>{viewing.user_assignments}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.group_assignments.e534bb56</>}>{viewing.group_assignments}</DescriptionItem>
|
||||
</DescriptionList><h3>i18n:govoplan-access.permissions.d06d5557</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deleting)} title="Delete role" message={`Delete ${deleting?.name}? Only unassigned tenant-defined roles can be deleted.`} confirmLabel="Delete role" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
||||
</>
|
||||
);
|
||||
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_role.fbf0667e" message={i18nMessage("i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function draftKey(draft: typeof emptyDraft): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
function sortTenantRoles(left: RoleSummary, right: RoleSummary): number {
|
||||
const builtinDelta = Number(right.is_builtin) - Number(left.is_builtin);
|
||||
if (builtinDelta !== 0) return builtinDelta;
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
import { MetricGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldOff,
|
||||
Trash2
|
||||
} from "lucide-react";
|
||||
import { FormGrid, ActionToolbar,
|
||||
AdminIconButton,
|
||||
AdminPageLayout,
|
||||
AdminSelectionList,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
DataGrid,
|
||||
DateTimeField,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
MetricCard,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
adminErrorMessage,
|
||||
formatAdminDateTime as formatDateTime,
|
||||
hasScope,
|
||||
scopeGrants,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn,
|
||||
type PermissionItem
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createServiceAccount,
|
||||
createServiceAccountCredential,
|
||||
fetchPermissionCatalog,
|
||||
fetchServiceAccountCredentials,
|
||||
fetchServiceAccounts,
|
||||
retireServiceAccount,
|
||||
revokeServiceAccountCredential,
|
||||
rotateServiceAccountCredential,
|
||||
updateServiceAccount,
|
||||
type ServiceAccountCredentialItem,
|
||||
type ServiceAccountItem
|
||||
} from "../../api/admin";
|
||||
import {
|
||||
ACCESS_INTERFACE_I18N,
|
||||
ACCESS_REFERENCE_DOCUMENTATION
|
||||
} from "./interfacePatterns";
|
||||
|
||||
type AccountDraft = {
|
||||
name: string;
|
||||
description: string;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
type CredentialDraft = {
|
||||
name: string;
|
||||
scopes: string[];
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
type CredentialEditor = {
|
||||
mode: "create" | "rotate";
|
||||
credential?: ServiceAccountCredentialItem;
|
||||
};
|
||||
|
||||
export default function ServiceAccountsPanel({
|
||||
settings,
|
||||
auth,
|
||||
canWrite
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const [accounts, setAccounts] = useState<ServiceAccountItem[]>([]);
|
||||
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
||||
const [managing, setManaging] = useState<ServiceAccountItem | null>(null);
|
||||
const [credentials, setCredentials] = useState<ServiceAccountCredentialItem[]>([]);
|
||||
const [showRevoked, setShowRevoked] = useState(true);
|
||||
const [accountEditor, setAccountEditor] = useState<"create" | "edit" | null>(null);
|
||||
const [accountDraft, setAccountDraft] = useState<AccountDraft>(emptyAccountDraft());
|
||||
const [credentialEditor, setCredentialEditor] = useState<CredentialEditor | null>(null);
|
||||
const [credentialDraft, setCredentialDraft] = useState<CredentialDraft>(emptyCredentialDraft());
|
||||
const [secret, setSecret] = useState<{ name: string; value: string } | null>(null);
|
||||
const [revoking, setRevoking] = useState<ServiceAccountCredentialItem | null>(null);
|
||||
const [retiring, setRetiring] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
const grantablePermissions = useMemo(
|
||||
() => permissions.filter((permission) => permission.level === "tenant" && hasScope(auth, permission.scope)),
|
||||
[auth, permissions]
|
||||
);
|
||||
const credentialPermissions = useMemo(
|
||||
() => grantablePermissions.filter((permission) => managing?.scope_ceiling.some((scope) => scopeGrants(scope, permission.scope))),
|
||||
[grantablePermissions, managing]
|
||||
);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextAccounts, nextPermissions] = await Promise.all([
|
||||
fetchServiceAccounts(settings),
|
||||
fetchPermissionCatalog(settings)
|
||||
]);
|
||||
setAccounts(nextAccounts);
|
||||
setPermissions(nextPermissions);
|
||||
if (managing) {
|
||||
const refreshed = nextAccounts.find((item) => item.id === managing.id) ?? null;
|
||||
setManaging(refreshed);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openManager(account: ServiceAccountItem) {
|
||||
setManaging(account);
|
||||
setCredentials([]);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetchServiceAccountCredentials(settings, account.id, true);
|
||||
setCredentials(response.items);
|
||||
setManaging({ ...account, revision: response.service_account_revision });
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshManaged(serviceAccountId: string) {
|
||||
const [nextAccounts, response] = await Promise.all([
|
||||
fetchServiceAccounts(settings),
|
||||
fetchServiceAccountCredentials(settings, serviceAccountId, true)
|
||||
]);
|
||||
const selected = nextAccounts.find((item) => item.id === serviceAccountId) ?? null;
|
||||
setAccounts(nextAccounts);
|
||||
setCredentials(response.items);
|
||||
setManaging(selected ? { ...selected, revision: response.service_account_revision } : null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]);
|
||||
|
||||
const accountColumns = useMemo<DataGridColumn<ServiceAccountItem>[]>(() => [
|
||||
{
|
||||
id: "name",
|
||||
header: "Name",
|
||||
width: "minmax(220px, 1fr)",
|
||||
minWidth: 190,
|
||||
resizable: true,
|
||||
fill: true,
|
||||
sticky: "start",
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.name,
|
||||
render: (row) => <div><strong>{row.name}</strong>{row.description && <div className="muted small-note">{row.description}</div>}</div>
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
width: 120,
|
||||
resizable: false,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.is_active ? "active" : "inactive",
|
||||
render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} />
|
||||
},
|
||||
{
|
||||
id: "scope_ceiling",
|
||||
header: "Scope ceiling",
|
||||
width: 140,
|
||||
resizable: false,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: "integer",
|
||||
value: (row) => row.scope_ceiling.length,
|
||||
render: (row) => String(row.scope_ceiling.length)
|
||||
},
|
||||
{
|
||||
id: "credentials",
|
||||
header: "Credentials",
|
||||
width: 150,
|
||||
resizable: false,
|
||||
sortable: true,
|
||||
value: (row) => row.active_credential_count,
|
||||
render: (row) => `${row.active_credential_count} active / ${row.credential_count}`
|
||||
},
|
||||
{
|
||||
id: "last_used",
|
||||
header: "Last used",
|
||||
width: 180,
|
||||
minWidth: 150,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
value: (row) => row.last_credential_used_at || "",
|
||||
render: (row) => formatDateTime(row.last_credential_used_at)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 108,
|
||||
sticky: "end",
|
||||
resizable: false,
|
||||
align: "right",
|
||||
render: (row) => <TableActionGroup actions={[
|
||||
{ id: "manage", label: `Manage ${row.name}`, icon: <Search />, helpContextId: "access.service-accounts.action.manage", helpModuleId: "access", onClick: () => void openManager(row) }
|
||||
]} />
|
||||
}
|
||||
], []);
|
||||
|
||||
const credentialColumns = useMemo<DataGridColumn<ServiceAccountCredentialItem>[]>(() => [
|
||||
{
|
||||
id: "name",
|
||||
header: "Name",
|
||||
width: "minmax(190px, 1fr)",
|
||||
minWidth: 170,
|
||||
fill: true,
|
||||
resizable: true,
|
||||
value: (row) => row.name,
|
||||
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.prefix}...</div></div>
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
width: 120,
|
||||
resizable: false,
|
||||
value: credentialStatus,
|
||||
render: (row) => <StatusBadge status={credentialStatus(row)} />
|
||||
},
|
||||
{
|
||||
id: "scopes",
|
||||
header: "Scopes",
|
||||
width: 100,
|
||||
resizable: false,
|
||||
value: (row) => row.scopes.length,
|
||||
render: (row) => String(row.scopes.length)
|
||||
},
|
||||
{
|
||||
id: "last_used",
|
||||
header: "Last used",
|
||||
width: 170,
|
||||
resizable: true,
|
||||
value: (row) => row.last_used_at || "",
|
||||
render: (row) => formatDateTime(row.last_used_at)
|
||||
},
|
||||
{
|
||||
id: "expires",
|
||||
header: "Expires",
|
||||
width: 170,
|
||||
resizable: true,
|
||||
value: (row) => row.expires_at || "",
|
||||
render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "No expiry"
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 108,
|
||||
sticky: "end",
|
||||
resizable: false,
|
||||
align: "right",
|
||||
render: (row) => <TableActionGroup actions={[
|
||||
{
|
||||
id: "rotate",
|
||||
label: `Rotate ${row.name}`,
|
||||
icon: <RefreshCw />,
|
||||
helpContextId: "access.service-accounts.action.rotate-credential",
|
||||
helpModuleId: "access",
|
||||
applicable: !row.revoked_at,
|
||||
disabled: !canWrite || !managing?.is_active,
|
||||
disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : !managing?.is_active ? "Activate the service account first." : undefined,
|
||||
onClick: () => openCredentialEditor("rotate", row)
|
||||
},
|
||||
{
|
||||
id: "revoke",
|
||||
label: `Revoke ${row.name}`,
|
||||
icon: <Trash2 />,
|
||||
variant: "danger",
|
||||
helpContextId: "access.service-accounts.action.revoke-credential",
|
||||
helpModuleId: "access",
|
||||
applicable: !row.revoked_at,
|
||||
disabled: !canWrite,
|
||||
disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined,
|
||||
onClick: () => setRevoking(row)
|
||||
}
|
||||
]} />
|
||||
}
|
||||
], [canWrite, managing]);
|
||||
|
||||
function openCreateAccount() {
|
||||
setAccountDraft(emptyAccountDraft());
|
||||
setAccountEditor("create");
|
||||
}
|
||||
|
||||
function openEditAccount() {
|
||||
if (!managing) return;
|
||||
setAccountDraft({
|
||||
name: managing.name,
|
||||
description: managing.description ?? "",
|
||||
scopes: [...managing.scope_ceiling]
|
||||
});
|
||||
setAccountEditor("edit");
|
||||
}
|
||||
|
||||
async function saveAccount() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (accountEditor === "create") {
|
||||
const created = await createServiceAccount(settings, {
|
||||
name: accountDraft.name,
|
||||
description: accountDraft.description || null,
|
||||
scope_ceiling: accountDraft.scopes
|
||||
});
|
||||
setSuccess(`Service account ${created.name} created.`);
|
||||
} else if (managing) {
|
||||
await updateServiceAccount(settings, managing.id, {
|
||||
expected_revision: managing.revision,
|
||||
name: accountDraft.name,
|
||||
description: accountDraft.description || null,
|
||||
scope_ceiling: accountDraft.scopes
|
||||
});
|
||||
setSuccess(`Service account ${accountDraft.name} updated.`);
|
||||
await refreshManaged(managing.id);
|
||||
}
|
||||
setAccountEditor(null);
|
||||
if (accountEditor === "create") await load();
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
if (managing) await refreshAfterConflict(managing.id);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function setActive(active: boolean) {
|
||||
if (!managing) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateServiceAccount(settings, managing.id, {
|
||||
expected_revision: managing.revision,
|
||||
is_active: active
|
||||
});
|
||||
setSuccess(`${managing.name} ${active ? "activated" : "deactivated"}.`);
|
||||
await refreshManaged(managing.id);
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
await refreshAfterConflict(managing.id);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function retire() {
|
||||
if (!managing) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await retireServiceAccount(settings, managing.id, managing.revision);
|
||||
setSuccess(`${managing.name} retired and its credentials revoked.`);
|
||||
setRetiring(false);
|
||||
await refreshManaged(managing.id);
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
await refreshAfterConflict(managing.id);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openCredentialEditor(mode: "create" | "rotate", credential?: ServiceAccountCredentialItem) {
|
||||
setCredentialDraft(credential ? {
|
||||
name: credential.name,
|
||||
scopes: [...credential.scopes],
|
||||
expiresAt: ""
|
||||
} : emptyCredentialDraft());
|
||||
setCredentialEditor({ mode, credential });
|
||||
}
|
||||
|
||||
async function saveCredential() {
|
||||
if (!managing || !credentialEditor) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const payload = {
|
||||
expected_revision: managing.revision,
|
||||
name: credentialDraft.name,
|
||||
scopes: credentialDraft.scopes,
|
||||
expires_at: credentialDraft.expiresAt ? new Date(credentialDraft.expiresAt).toISOString() : null
|
||||
};
|
||||
const response = credentialEditor.mode === "create"
|
||||
? await createServiceAccountCredential(settings, managing.id, payload)
|
||||
: await rotateServiceAccountCredential(settings, managing.id, credentialEditor.credential!.id, payload);
|
||||
setSecret({ name: response.credential.name, value: response.secret });
|
||||
setSuccess(credentialEditor.mode === "create" ? "Credential created." : "Credential rotated; the previous credential is revoked.");
|
||||
setCredentialEditor(null);
|
||||
await refreshManaged(managing.id);
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
await refreshAfterConflict(managing.id);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeCredential() {
|
||||
if (!managing || !revoking) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await revokeServiceAccountCredential(settings, managing.id, revoking.id, managing.revision);
|
||||
setSuccess(`Credential ${revoking.name} revoked.`);
|
||||
setRevoking(null);
|
||||
await refreshManaged(managing.id);
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
await refreshAfterConflict(managing.id);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAfterConflict(serviceAccountId: string) {
|
||||
try {
|
||||
await refreshManaged(serviceAccountId);
|
||||
} catch {
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
const visibleCredentials = showRevoked ? credentials : credentials.filter((item) => !item.revoked_at);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="Service accounts"
|
||||
description="Manage non-login automation principals and their independently rotatable, scope-bounded credentials."
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
helpContextId="access.admin.service-accounts"
|
||||
helpModuleId="access"
|
||||
actions={<>
|
||||
<DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} />
|
||||
<Button helpContextId="access.service-accounts.action.reload" helpModuleId="access" onClick={() => void load()} disabled={loading}>Reload</Button>
|
||||
<AdminIconButton label="Add service account" icon={<Plus />} variant="primary" helpContextId="access.service-accounts.action.create" helpModuleId="access" onClick={openCreateAccount} disabled={!canWrite} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined} />
|
||||
</>}
|
||||
>
|
||||
<div className="admin-table-surface">
|
||||
<DataGrid id="admin-service-accounts-v1" rows={accounts} columns={accountColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="No service accounts found." />
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog variant="administration" size="wide"
|
||||
open={Boolean(accountEditor)}
|
||||
title={accountEditor === "create" ? "Create service account" : "Edit service account"}
|
||||
helpContextId="access.service-accounts.account-editor"
|
||||
helpModuleId="access"
|
||||
onClose={() => !busy && setAccountEditor(null)}
|
||||
className=""
|
||||
footer={<><Button onClick={() => setAccountEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" helpContextId="access.service-accounts.action.save" helpModuleId="access" onClick={() => void saveAccount()} disabled={!canWrite || busy || !accountDraft.name.trim()}>{busy ? "Saving..." : "Save"}</Button></>}
|
||||
>
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="Name" helpContextId="access.service-accounts.field.name" helpModuleId="access"><input value={accountDraft.name} onChange={(event) => setAccountDraft({ ...accountDraft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Description" helpContextId="access.service-accounts.field.description" helpModuleId="access"><input value={accountDraft.description} onChange={(event) => setAccountDraft({ ...accountDraft, description: event.target.value })} /></FormField>
|
||||
</FormGrid>
|
||||
<div className="form-field" data-help-context-id="access.service-accounts.field.scope-ceiling" data-help-module-id="access">
|
||||
<span className="form-label">Scope ceiling</span>
|
||||
<AdminSelectionList options={grantablePermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: `${permission.scope} - ${permission.description}` }))} selected={accountDraft.scopes} onChange={(scopes) => setAccountDraft({ ...accountDraft, scopes })} emptyText="No tenant scopes can be delegated by your current account." />
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog variant="administration" size="wide"
|
||||
open={Boolean(managing)}
|
||||
title={managing?.name ?? "Service account"}
|
||||
helpContextId="access.service-accounts.action.manage"
|
||||
helpModuleId="access"
|
||||
onClose={() => !busy && setManaging(null)}
|
||||
className=""
|
||||
footer={<Button onClick={() => setManaging(null)} disabled={busy}>Close</Button>}
|
||||
>
|
||||
{managing && <>
|
||||
<MetricGrid density="compact">
|
||||
<MetricCard label="Status" value={managing.is_active ? "Active" : "Inactive"} tone={managing.is_active ? "good" : "warning"} />
|
||||
<MetricCard label="Active credentials" value={managing.active_credential_count} />
|
||||
<MetricCard label="Scope ceiling" value={managing.scope_ceiling.length} />
|
||||
<MetricCard label="Revision" value={managing.revision} />
|
||||
</MetricGrid>
|
||||
<ActionToolbar className="admin-toolbar-row">
|
||||
<Button helpContextId="access.service-accounts.action.edit" helpModuleId="access" onClick={openEditAccount} disabled={!canWrite || busy}><Pencil aria-hidden="true" /> Edit</Button>
|
||||
<Button helpContextId="access.service-accounts.action.activation" helpModuleId="access" onClick={() => void setActive(!managing.is_active)} disabled={!canWrite || busy}>{managing.is_active ? <ShieldOff aria-hidden="true" /> : <RefreshCw aria-hidden="true" />} {managing.is_active ? "Deactivate" : "Activate"}</Button>
|
||||
<Button variant="danger" helpContextId="access.service-accounts.action.retire" helpModuleId="access" onClick={() => setRetiring(true)} disabled={!canWrite || busy || !managing.is_active}><Trash2 aria-hidden="true" /> Retire</Button>
|
||||
<AdminIconButton label="Create credential" icon={<KeyRound />} variant="primary" helpContextId="access.service-accounts.action.create-credential" helpModuleId="access" onClick={() => openCredentialEditor("create")} disabled={!canWrite || !managing.is_active} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : !managing.is_active ? "Activate the service account first." : undefined} />
|
||||
</ActionToolbar>
|
||||
<ActionToolbar className="admin-toolbar-row">
|
||||
<ToggleSwitch label="Show revoked credentials" checked={showRevoked} helpContextId="access.service-accounts.field.show-revoked" helpModuleId="access" onChange={setShowRevoked} />
|
||||
</ActionToolbar>
|
||||
<div className="admin-table-surface">
|
||||
<DataGrid id="admin-service-account-credentials-v1" rows={visibleCredentials} columns={credentialColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="No credentials found." />
|
||||
</div>
|
||||
<p className="muted small-note">Secrets are shown once. Authentication always intersects a credential grant with this account's current scope ceiling, so reducing the ceiling takes effect immediately.</p>
|
||||
</>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog variant="administration" size="wide"
|
||||
open={Boolean(credentialEditor)}
|
||||
title={credentialEditor?.mode === "rotate" ? "Rotate credential" : "Create credential"}
|
||||
helpContextId="access.service-accounts.credential-editor"
|
||||
helpModuleId="access"
|
||||
onClose={() => !busy && setCredentialEditor(null)}
|
||||
className=""
|
||||
footer={<><Button onClick={() => setCredentialEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" helpContextId="access.service-accounts.action.save-credential" helpModuleId="access" onClick={() => void saveCredential()} disabled={!canWrite || busy || !credentialDraft.name.trim() || credentialDraft.scopes.length === 0}>{busy ? "Saving..." : credentialEditor?.mode === "rotate" ? "Rotate" : "Create"}</Button></>}
|
||||
>
|
||||
{credentialEditor?.mode === "rotate" && <p className="muted small-note">Rotation creates a new secret and revokes the previous credential in the same transaction.</p>}
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="Name" helpContextId="access.service-accounts.field.credential-name" helpModuleId="access"><input value={credentialDraft.name} onChange={(event) => setCredentialDraft({ ...credentialDraft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Expiry" helpContextId="access.service-accounts.field.credential-expiry" helpModuleId="access"><DateTimeField value={credentialDraft.expiresAt} onChange={(value) => setCredentialDraft({ ...credentialDraft, expiresAt: value })} /></FormField>
|
||||
</FormGrid>
|
||||
<div className="form-field" data-help-context-id="access.service-accounts.field.credential-scopes" data-help-module-id="access">
|
||||
<span className="form-label">Credential scopes</span>
|
||||
<AdminSelectionList options={credentialPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: `${permission.scope} - ${permission.description}` }))} selected={credentialDraft.scopes} onChange={(scopes) => setCredentialDraft({ ...credentialDraft, scopes })} emptyText="The service account has no credential scopes available." />
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog variant="administration" size="large" open={Boolean(secret)} title="Service-account secret" helpContextId="access.service-accounts.secret" helpModuleId="access" onClose={() => setSecret(null)} className="" footer={<Button variant="primary" helpContextId="access.service-accounts.secret" helpModuleId="access" onClick={() => setSecret(null)}>I have recorded it</Button>}>
|
||||
{secret && <><p>The secret for <strong>{secret.name}</strong> is shown once.</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">Store it in a secret manager. GovOPlaN retains only a one-way hash and the visible prefix.</p></>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(revoking)} title="Revoke credential" message={`Revoke ${revoking?.name ?? "this credential"}? Existing clients using it will immediately lose access.`} confirmLabel="Revoke credential" tone="danger" busy={busy} helpContextId="access.service-accounts.confirm-revoke-credential" helpModuleId="access" onCancel={() => setRevoking(null)} onConfirm={() => void revokeCredential()} />
|
||||
<ConfirmDialog open={retiring} title="Retire service account" message={`Retire ${managing?.name ?? "this service account"} and revoke all ${managing?.active_credential_count ?? 0} active credentials?`} confirmLabel="Retire and revoke" tone="danger" busy={busy} helpContextId="access.service-accounts.confirm-retire" helpModuleId="access" onCancel={() => setRetiring(false)} onConfirm={() => void retire()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function emptyAccountDraft(): AccountDraft {
|
||||
return { name: "", description: "", scopes: [] };
|
||||
}
|
||||
|
||||
function emptyCredentialDraft(): CredentialDraft {
|
||||
return { name: "", scopes: [], expiresAt: "" };
|
||||
}
|
||||
|
||||
function credentialStatus(item: ServiceAccountCredentialItem): string {
|
||||
if (item.revoked_at) return "revoked";
|
||||
if (item.expires_at && new Date(item.expires_at).getTime() <= Date.now()) return "expired";
|
||||
return "active";
|
||||
}
|
||||
@@ -1,22 +1,25 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import {
|
||||
createSystemRole,
|
||||
deleteSystemRole,
|
||||
fetchPermissionCatalog,
|
||||
fetchSystemRoles,
|
||||
fetchSystemRolesDelta,
|
||||
updateSystemRole,
|
||||
type PermissionItem,
|
||||
type RoleSummary
|
||||
} from "../../api/admin";
|
||||
type RoleSummary } from
|
||||
"../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
import { ACCESS_INTERFACE_I18N, ACCESS_REFERENCE_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||
|
||||
const emptyDraft = {
|
||||
slug: "",
|
||||
@@ -30,30 +33,41 @@ export default function SystemRolesPanel({
|
||||
settings,
|
||||
canWrite,
|
||||
onAuthRefresh
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
canWrite: boolean;
|
||||
onAuthRefresh: () => Promise<void>;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;canWrite: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
||||
const rolesRef = useRef<RoleSummary[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [editing, setEditing] = useState<RoleSummary | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<RoleSummary | null>(null);
|
||||
const [deleting, setDeleting] = useState<RoleSummary | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: closeEditor
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextRoles, catalogue] = await Promise.all([
|
||||
fetchSystemRoles(settings),
|
||||
fetchPermissionCatalog(settings)
|
||||
]);
|
||||
loadDeltaRows(rolesRef.current, "access:system-roles", getDeltaWatermark, setDeltaWatermark, (since) => fetchSystemRolesDelta(settings, { since }), (response) => response.roles, (role) => role.id, "access_system_role", sortSystemRoles),
|
||||
fetchPermissionCatalog(settings)]
|
||||
);
|
||||
rolesRef.current = nextRoles;
|
||||
setRoles(nextRoles);
|
||||
setPermissions(catalogue.filter((item) => item.level === "system"));
|
||||
} catch (err) {
|
||||
@@ -63,25 +77,38 @@ export default function SystemRolesPanel({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
|
||||
useEffect(() => {
|
||||
rolesRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
|
||||
|
||||
function openCreate() {
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
setEditing("new");
|
||||
}
|
||||
|
||||
function openEdit(role: RoleSummary) {
|
||||
setDraft({
|
||||
const nextDraft = {
|
||||
slug: role.slug,
|
||||
name: role.name,
|
||||
description: role.description || "",
|
||||
permissions: role.permissions,
|
||||
isAssignable: role.is_assignable
|
||||
});
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
setEditing(role);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
function closeEditor() {
|
||||
setEditing(null);
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -92,7 +119,7 @@ export default function SystemRolesPanel({
|
||||
description: draft.description || null,
|
||||
permissions: draft.permissions
|
||||
});
|
||||
setSuccess(`System role ${draft.name} created.`);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_created.9c1a6bc8", { value0: draft.name }));
|
||||
} else if (editing) {
|
||||
await updateSystemRole(settings, editing.id, {
|
||||
name: draft.name,
|
||||
@@ -100,13 +127,15 @@ export default function SystemRolesPanel({
|
||||
permissions: draft.permissions,
|
||||
is_assignable: draft.isAssignable
|
||||
});
|
||||
setSuccess(`System role ${draft.name} updated.`);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_updated.3a3f8862", { value0: draft.name }));
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -118,7 +147,7 @@ export default function SystemRolesPanel({
|
||||
setError("");
|
||||
try {
|
||||
await deleteSystemRole(settings, deleting.id);
|
||||
setSuccess(`System role ${deleting.name} deleted.`);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.system_role_value_deleted.7b18a6f8", { value0: deleting.name }));
|
||||
setDeleting(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
@@ -130,125 +159,133 @@ export default function SystemRolesPanel({
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
|
||||
{
|
||||
id: "role",
|
||||
header: "System role",
|
||||
width: 240,
|
||||
minWidth: 180,
|
||||
maxWidth: 380,
|
||||
resizable: true,
|
||||
sticky: "start",
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => `${row.name} ${row.slug}`,
|
||||
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div>
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
header: "Description",
|
||||
width: 360,
|
||||
fill: true,
|
||||
minWidth: 220,
|
||||
maxWidth: 640,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.description || "",
|
||||
render: (row) => row.description || "—"
|
||||
},
|
||||
{
|
||||
id: "permissions",
|
||||
header: "Permissions",
|
||||
width: 120,
|
||||
resizable: false,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: "integer",
|
||||
value: (row) => row.effective_permission_count,
|
||||
render: (row) => String(row.effective_permission_count)
|
||||
},
|
||||
{
|
||||
id: "assignable",
|
||||
header: "Assignable",
|
||||
width: 120,
|
||||
resizable: false,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.is_assignable ? "yes" : "no",
|
||||
render: (row) => <StatusBadge status={row.is_assignable ? "active" : "inactive"} label={row.is_assignable ? "Yes" : "No"} />
|
||||
},
|
||||
{
|
||||
id: "assignments",
|
||||
header: "Accounts",
|
||||
width: 110,
|
||||
resizable: false,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: "integer",
|
||||
value: (row) => row.user_assignments
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 150,
|
||||
sticky: "end",
|
||||
resizable: false,
|
||||
align: "right",
|
||||
render: (row) => {
|
||||
const protectedOwner = row.slug === "system_owner";
|
||||
return <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite || protectedOwner} />
|
||||
<AdminIconButton label={`Delete ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite || protectedOwner || row.user_assignments > 0} />
|
||||
</div>;
|
||||
}
|
||||
{
|
||||
id: "role",
|
||||
header: "i18n:govoplan-access.system_role.91762640",
|
||||
width: 240,
|
||||
minWidth: 180,
|
||||
maxWidth: 380,
|
||||
resizable: true,
|
||||
sticky: "start",
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => `${row.name} ${row.slug}`,
|
||||
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div>
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
header: "i18n:govoplan-access.description.55f8ebc8",
|
||||
width: 360,
|
||||
fill: true,
|
||||
minWidth: 220,
|
||||
maxWidth: 640,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.description || "",
|
||||
render: (row) => row.description || "—"
|
||||
},
|
||||
{
|
||||
id: "permissions",
|
||||
header: "i18n:govoplan-access.permissions.d06d5557",
|
||||
width: 120,
|
||||
resizable: false,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: "integer",
|
||||
value: (row) => row.effective_permission_count,
|
||||
render: (row) => String(row.effective_permission_count)
|
||||
},
|
||||
{
|
||||
id: "assignable",
|
||||
header: "i18n:govoplan-access.assignable.a88debc5",
|
||||
width: 120,
|
||||
resizable: false,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.is_assignable ? "yes" : "no",
|
||||
render: (row) => <StatusBadge status={row.is_assignable ? "active" : "inactive"} label={row.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"} />
|
||||
},
|
||||
{
|
||||
id: "assignments",
|
||||
header: "i18n:govoplan-access.accounts.36bae316",
|
||||
width: 110,
|
||||
resizable: false,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: "integer",
|
||||
value: (row) => row.user_assignments
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-access.actions.c3cd636a",
|
||||
width: 150,
|
||||
sticky: "end",
|
||||
resizable: false,
|
||||
align: "right",
|
||||
render: (row) => {
|
||||
const protectedOwner = row.slug === "system_owner";
|
||||
return <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !protectedOwner, disabled: !canWrite, disabledReason: protectedOwner ? ACCESS_INTERFACE_I18N.systemManagedObject : !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !protectedOwner, disabled: !canWrite || row.user_assignments > 0, disabledReason: protectedOwner ? ACCESS_INTERFACE_I18N.systemManagedObject : !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : row.user_assignments > 0 ? ACCESS_INTERFACE_I18N.assignedObjectCannotBeDeleted : undefined, onClick: () => setDeleting(row) }
|
||||
]} />;
|
||||
}
|
||||
], [canWrite]);
|
||||
}],
|
||||
[canWrite]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="System roles"
|
||||
description="Instance-wide role definitions. System owner is protected and indispensable; other system roles are configurable and assigned from System → Users."
|
||||
title="i18n:govoplan-access.system_roles.a9461aa6"
|
||||
description="i18n:govoplan-access.instance_wide_role_definitions_system_owner_is_p.a888778d"
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add system role" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}
|
||||
>
|
||||
actions={<><DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_system_role.f9ef262b" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||
|
||||
<div className="admin-table-surface">
|
||||
<DataGrid id="admin-system-role-definitions-v4" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No system roles found." />
|
||||
<DataGrid id="admin-system-role-definitions-v4" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_system_roles_found.051cf727" />
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog
|
||||
<Dialog variant="administration" size="wide"
|
||||
open={editing !== null}
|
||||
title={editing === "new" ? "Create system role" : "Edit system role"}
|
||||
title={editing === "new" ? "i18n:govoplan-access.create_system_role.a1e40b25" : "i18n:govoplan-access.edit_system_role.6ebb7cb0"}
|
||||
onClose={() => !busy && setEditing(null)}
|
||||
className="admin-dialog admin-dialog-wide"
|
||||
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "Saving…" : "Save role"}</Button></>}
|
||||
>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Name"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="Assignable"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">Yes</option><option value="no">No</option></select></FormField>
|
||||
<FormField label="Description"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</div>
|
||||
className=""
|
||||
footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canWrite, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
|
||||
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>
|
||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</FormGrid>
|
||||
<div className="form-field">
|
||||
<span className="form-label">System permissions</span>
|
||||
<span className="form-label">i18n:govoplan-access.system_permissions.53ff0ab2</span>
|
||||
<AdminSelectionList
|
||||
options={permissions.filter((permission) => permission.scope !== "system:*").map((permission) => ({ id: permission.scope, label: permission.label, description: permission.description }))}
|
||||
selected={draft.permissions}
|
||||
onChange={(next) => setDraft({ ...draft, permissions: next })}
|
||||
/>
|
||||
<p className="muted small-note">A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.</p>
|
||||
onChange={(next) => setDraft({ ...draft, permissions: next })} />
|
||||
|
||||
<p className="muted small-note">i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title={viewing?.name || "System role details"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
{viewing && <dl className="admin-details-grid"><div><dt>Slug</dt><dd>{viewing.slug}</dd></div><div><dt>Protected</dt><dd>{viewing.slug === "system_owner" ? "Yes" : "No"}</dd></div><div><dt>Assignable</dt><dd>{viewing.is_assignable ? "Yes" : "No"}</dd></div><div><dt>Account assignments</dt><dd>{viewing.user_assignments}</dd></div><div><dt>Description</dt><dd>{viewing.description || "—"}</dd></div><div><dt>Effective permissions</dt><dd>{viewing.effective_permission_count}</dd></div><div><dt>Assigned scopes</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
|
||||
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title={viewing?.name || "i18n:govoplan-access.system_role_details.3d6a8f15"} onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <DescriptionList><DescriptionItem term={<>i18n:govoplan-access.slug.094da9b9</>}>{viewing.slug}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.protected.28531336</>}>{viewing.slug === "system_owner" ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.assignable.a88debc5</>}>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.account_assignments.f5a91f2a</>}>{viewing.user_assignments}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.description.55f8ebc8</>}>{viewing.description || "—"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.effective_permissions.17c0fe8a</>}>{viewing.effective_permission_count}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.assigned_scopes.c7b09b12</>}>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</DescriptionItem></DescriptionList>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deleting)} title="Delete system role" message={`Delete ${deleting?.name}? The role must have no account assignments.`} confirmLabel="Delete role" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
||||
</>
|
||||
);
|
||||
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_system_role.e2d84a56" message={i18nMessage("i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function draftKey(draft: typeof emptyDraft): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
function sortSystemRoles(left: RoleSummary, right: RoleSummary): number {
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ContentGrid, DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
@@ -10,7 +11,7 @@ import { PasswordField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import {
|
||||
createSystemAccount,
|
||||
fetchSystemAccounts,
|
||||
fetchSystemAccountsDelta,
|
||||
fetchTenants,
|
||||
updateSystemAccount,
|
||||
updateSystemAccountRoles,
|
||||
@@ -20,7 +21,8 @@ import {
|
||||
type SystemMembershipDraft,
|
||||
type TenantAdminItem
|
||||
} from "../../api/admin";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { ACCESS_INTERFACE_I18N, ACCESS_REFERENCE_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||
|
||||
const emptyDraft = {
|
||||
email: "",
|
||||
@@ -40,49 +42,89 @@ export default function SystemUsersPanel({
|
||||
canAssignRoles,
|
||||
canManageMemberships,
|
||||
onAuthRefresh
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canSuspend: boolean;
|
||||
canAssignRoles: boolean;
|
||||
canManageMemberships: boolean;
|
||||
onAuthRefresh: () => Promise<void>;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canAssignRoles: boolean;canManageMemberships: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
const [accounts, setAccounts] = useState<SystemAccountItem[]>([]);
|
||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||
const accountsRef = useRef<SystemAccountItem[]>([]);
|
||||
const rolesRef = useRef<RoleSummary[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [editing, setEditing] = useState<SystemAccountItem | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<SystemAccountItem | null>(null);
|
||||
const [deactivating, setDeactivating] = useState<SystemAccountItem | null>(null);
|
||||
const [temporaryPassword, setTemporaryPassword] = useState<{ email: string; value: string } | null>(null);
|
||||
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;value: string;} | null>(null);
|
||||
const [draft, setDraft] = useState(emptyDraft);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: closeEditor
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [access, nextTenants] = await Promise.all([fetchSystemAccounts(settings), fetchTenants(settings)]);
|
||||
setAccounts(access.accounts);
|
||||
setRoles(access.roles);
|
||||
let nextWatermark = getDeltaWatermark("access:system-accounts");
|
||||
let nextAccounts = accountsRef.current;
|
||||
let nextRoles = rolesRef.current;
|
||||
let hasMore = false;
|
||||
do {
|
||||
const response = await fetchSystemAccountsDelta(settings, { since: nextWatermark });
|
||||
const continuingFullSnapshot = response.full && nextWatermark?.startsWith("full:");
|
||||
nextAccounts = response.full
|
||||
? continuingFullSnapshot
|
||||
? mergeDeltaRows(nextAccounts, response.accounts, [], (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts })
|
||||
: response.accounts
|
||||
: mergeDeltaRows(nextAccounts, response.accounts, response.deleted, (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts });
|
||||
nextRoles = response.full
|
||||
? continuingFullSnapshot
|
||||
? mergeDeltaRows(nextRoles, response.roles, [], (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles })
|
||||
: response.roles
|
||||
: mergeDeltaRows(nextRoles, response.roles, response.deleted, (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles });
|
||||
nextWatermark = response.watermark ?? null;
|
||||
hasMore = response.has_more;
|
||||
} while (hasMore);
|
||||
setDeltaWatermark("access:system-accounts", nextWatermark);
|
||||
const nextTenants = await fetchTenants(settings);
|
||||
accountsRef.current = nextAccounts;
|
||||
rolesRef.current = nextRoles;
|
||||
setAccounts(nextAccounts);
|
||||
setRoles(nextRoles);
|
||||
setTenants(nextTenants);
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setLoading(false); }
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setLoading(false);}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
|
||||
useEffect(() => {
|
||||
accountsRef.current = [];
|
||||
rolesRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
|
||||
|
||||
function openCreate() {
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
setEditing("new");
|
||||
}
|
||||
|
||||
function openEdit(item: SystemAccountItem) {
|
||||
setDraft({
|
||||
const nextDraft = {
|
||||
email: item.email,
|
||||
displayName: item.display_name || "",
|
||||
password: "",
|
||||
@@ -97,24 +139,29 @@ export default function SystemUsersPanel({
|
||||
is_owner: membership.is_owner,
|
||||
is_last_active_owner: membership.is_last_active_owner
|
||||
}))
|
||||
});
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
setEditing(item);
|
||||
}
|
||||
|
||||
function membership(tenantId: string) {
|
||||
return draft.memberships.find((item) => item.tenant_id === tenantId);
|
||||
function closeEditor() {
|
||||
setEditing(null);
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
}
|
||||
|
||||
function setMembership(tenantId: string, enabled: boolean) {
|
||||
function setMembershipSelection(tenantIds: string[]) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
memberships: enabled
|
||||
? [...current.memberships.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }]
|
||||
: current.memberships.filter((item) => item.tenant_id !== tenantId)
|
||||
memberships: tenantIds.map((tenantId) =>
|
||||
current.memberships.find((item) => item.tenant_id === tenantId) ??
|
||||
{ tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -129,21 +176,22 @@ export default function SystemUsersPanel({
|
||||
memberships: canManageMemberships ? draft.memberships.map(({ tenant_id, is_active, role_ids, group_ids }) => ({ tenant_id, is_active, role_ids, group_ids })) : []
|
||||
});
|
||||
if (response.temporary_password) setTemporaryPassword({ email: response.account.email, value: response.temporary_password });
|
||||
setSuccess(`Global account ${response.account.email} created.`);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.global_account_value_created.5100e467", { value0: response.account.email }));
|
||||
} else if (editing) {
|
||||
const accountChanges: { display_name?: string | null; is_active?: boolean } = {};
|
||||
const accountChanges: {display_name?: string | null;is_active?: boolean;} = {};
|
||||
if (canUpdate) accountChanges.display_name = draft.displayName || null;
|
||||
if (canSuspend) accountChanges.is_active = draft.isActive;
|
||||
if (Object.keys(accountChanges).length) await updateSystemAccount(settings, editing.account_id, accountChanges);
|
||||
if (canAssignRoles) await updateSystemAccountRoles(settings, editing.account_id, draft.roleIds);
|
||||
if (canManageMemberships) await updateSystemMemberships(settings, editing.account_id, draft.memberships.map(({ tenant_id, is_active, role_ids, group_ids }) => ({ tenant_id, is_active, role_ids, group_ids })));
|
||||
setSuccess(`Global account ${editing.email} updated.`);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.global_account_value_updated.0de76b0e", { value0: editing.email }));
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
return true;
|
||||
} catch (err) {setError(adminErrorMessage(err));return false;} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
@@ -152,73 +200,87 @@ export default function SystemUsersPanel({
|
||||
setError("");
|
||||
try {
|
||||
await updateSystemAccount(settings, deactivating.account_id, { is_active: false });
|
||||
setSuccess(`${deactivating.email} deactivated.`);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.value_deactivated.ed3d027c", { value0: deactivating.email }));
|
||||
setDeactivating(null);
|
||||
await load();
|
||||
await onAuthRefresh();
|
||||
} catch (err) { setError(adminErrorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
} catch (err) {setError(adminErrorMessage(err));} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<SystemAccountItem>[]>(() => [
|
||||
{ id: "account", header: "Account", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
|
||||
{ id: "tenants", header: "Tenant memberships", width: 280, minWidth: 190, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.memberships.map((item) => item.tenant_name).join(", ") || "—" },
|
||||
{ id: "roles", header: "System roles", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||
{ id: "last_login", header: "Last login", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.email}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.email}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canAssignRoles || canManageMemberships)} />
|
||||
<AdminIconButton label={`Deactivate ${row.email}`} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.memberships.some((membership) => membership.is_last_active_owner)} />
|
||||
</div> }
|
||||
], [canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
||||
{ id: "account", header: "i18n:govoplan-access.account.85dfa32c", width: "minmax(240px, 1.2fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
|
||||
{ id: "tenants", header: "i18n:govoplan-access.tenant_memberships.451de736", width: 280, minWidth: 190, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.memberships.map((item) => item.tenant_name).join(", ") || "—" },
|
||||
{ id: "roles", header: "i18n:govoplan-access.system_roles.a9461aa6", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), disabledReason: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.memberships.some((membership) => membership.is_last_active_owner), disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canSuspend ? ACCESS_INTERFACE_I18N.updatePermissionRequired : row.memberships.some((membership) => membership.is_last_active_owner) ? ACCESS_INTERFACE_I18N.lastOwnerCannotBeDeactivated : undefined, onClick: () => setDeactivating(row) }
|
||||
]} /> }],
|
||||
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="Central users"
|
||||
description="Global login identities, tenant memberships and system-role assignments. Tenant memberships require the separate system access-assignment permission. Tenant-specific group and role assignments remain visible and are preserved when memberships are edited here."
|
||||
title="i18n:govoplan-access.central_users.91ac1b51"
|
||||
description="i18n:govoplan-access.global_login_identities_tenant_memberships_and_s.8f963b7f"
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add global account" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}
|
||||
>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-system-users-v3" rows={accounts} columns={columns} initialFit="container" getRowKey={(row) => row.account_id} emptyText="No global accounts found." /></div>
|
||||
actions={<><DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_global_account.18e4df22" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||
|
||||
<div className="admin-table-surface"><DataGrid id="admin-system-users-v3" rows={accounts} columns={columns} initialFit="container" getRowKey={(row) => row.account_id} emptyText="i18n:govoplan-access.no_global_accounts_found.29d96a9e" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "Create global account" : "Edit global account"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canAssignRoles || canManageMemberships))}>{busy ? "Saving…" : "Save account"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Email"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||
<FormField label="Display name"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||
{editing === "new" && (
|
||||
<FormField label="Initial password">
|
||||
<Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_global_account.e821f016" : "i18n:govoplan-access.edit_global_account.d13b8485"} onClose={() => !busy && setEditing(null)} className="" footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: editing === "new" ? canCreate : canUpdate || canSuspend || canAssignRoles || canManageMemberships, complete: Boolean(draft.email.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_account.0b761f5c"}</Button></>}>
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||
{editing === "new" &&
|
||||
<FormField label="i18n:govoplan-access.initial_password.2278be8c" helpContextId="access.admin.system-users.initial-password" helpModuleId="access">
|
||||
<PasswordField
|
||||
value={draft.password}
|
||||
placeholder="Leave empty to generate"
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => setDraft({ ...draft, password })}
|
||||
/>
|
||||
helpContextId="access.admin.system-users.initial-password"
|
||||
helpModuleId="access"
|
||||
value={draft.password}
|
||||
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => setDraft({ ...draft, password })} />
|
||||
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="Account status"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.memberships.some((membership) => membership.is_last_active_owner)))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
|
||||
</div>
|
||||
<div className="admin-assignment-grid">
|
||||
<div><span className="form-label">System roles</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
|
||||
<div><span className="form-label">Tenant memberships</span><div className="admin-selection-list">{tenants.map((tenant) => <label className="admin-selection-item" key={tenant.id}><input type="checkbox" checked={Boolean(membership(tenant.id))} disabled={!canManageMemberships || Boolean(membership(tenant.id)?.is_last_active_owner)} onChange={(event) => setMembership(tenant.id, event.target.checked)} /><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span></label>)}</div></div>
|
||||
</div>
|
||||
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">This account is the last active operational owner in at least one tenant. Those memberships and the account itself cannot be deactivated until another owner is assigned.</p>}
|
||||
<p className="muted small-note">Removing a tenant checkbox suspends that membership rather than deleting historical ownership. The backend also enforces the final-owner safeguard.</p>
|
||||
}
|
||||
<FormField label="i18n:govoplan-access.account_status.8dd86c6d"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.memberships.some((membership) => membership.is_last_active_owner)))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
||||
</FormGrid>
|
||||
<ContentGrid columns={2} spacing="block" collapseAt="wide">
|
||||
<div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
|
||||
<div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><AdminSelectionList options={tenants.map((tenant) => ({ id: tenant.id, label: tenant.name, description: tenant.slug, disabled: !canManageMemberships || Boolean(draft.memberships.find((item) => item.tenant_id === tenant.id)?.is_last_active_owner) }))} selected={draft.memberships.map((item) => item.tenant_id)} onChange={setMembershipSelection} /></div>
|
||||
</ContentGrid>
|
||||
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>}
|
||||
<p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="Global account details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
{viewing && <dl className="admin-details-grid"><div><dt>Account</dt><dd>{viewing.email}</dd></div><div><dt>Display name</dt><dd>{viewing.display_name || "—"}</dd></div><div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Inactive"}</dd></div><div><dt>Last login</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div><div><dt>System roles</dt><dd>{joinLabels(viewing.roles)}</dd></div><div><dt>Tenants</dt><dd>{viewing.memberships.map((item) => item.tenant_name).join(", ") || "—"}</dd></div></dl>}
|
||||
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.global_account_details.0a0cf240" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <DescriptionList><DescriptionItem term={<>i18n:govoplan-access.account.85dfa32c</>}>{viewing.email}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.display_name.c7874aaa</>}>{viewing.display_name || "—"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.status.bae7d5be</>}>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.last_login.43dab84f</>}>{formatDateTime(viewing.last_login_at)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.system_roles.a9461aa6</>}>{joinLabels(viewing.roles)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.tenants.1f7ae776</>}>{viewing.memberships.map((item) => item.tenant_name).join(", ") || "—"}</DescriptionItem></DescriptionList>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(temporaryPassword)} title="Temporary password" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>I have recorded it</Button>}>
|
||||
{temporaryPassword && <><p>This is shown once for <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>}
|
||||
<Dialog variant="administration" size="large" open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
||||
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deactivating)} title="Deactivate global account" message={`Deactivate ${deactivating?.email}? All sessions and tenant access will stop. Historical ownership remains intact.`} confirmLabel="Deactivate account" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
||||
</>
|
||||
);
|
||||
<ConfirmDialog open={Boolean(deactivating)} title="i18n:govoplan-access.deactivate_global_account.66d92736" message={i18nMessage("i18n:govoplan-access.deactivate_value_all_sessions_and_tenant_access_.2024856f", { value0: deactivating?.email })} confirmLabel="i18n:govoplan-access.deactivate_account.fd9fd676" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function draftKey(draft: typeof emptyDraft): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
function sortSystemAccounts(left: SystemAccountItem, right: SystemAccountItem): number {
|
||||
return left.email.localeCompare(right.email);
|
||||
}
|
||||
|
||||
function sortSystemRoles(left: RoleSummary, right: RoleSummary): number {
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { fetchTenantSettings, updateTenantSettings, type TenantSettingsItem } from "../../api/admin";
|
||||
import { AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
|
||||
|
||||
const fallback: TenantSettingsItem = {
|
||||
id: "",
|
||||
slug: "",
|
||||
name: "",
|
||||
default_locale: "en",
|
||||
settings: {}
|
||||
};
|
||||
|
||||
export default function TenantSettingsPanel({
|
||||
settings,
|
||||
canWrite,
|
||||
onAuthRefresh
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
canWrite: boolean;
|
||||
onAuthRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<TenantSettingsItem>(fallback);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
setDraft(await fetchTenantSettings(settings));
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const saved = await updateTenantSettings(settings, { default_locale: draft.default_locale });
|
||||
setDraft(saved);
|
||||
setSuccess("Tenant general settings saved.");
|
||||
await onAuthRefresh();
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageLayout
|
||||
title="Tenant general settings"
|
||||
description="Settings for the active tenant context."
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.default_locale.trim()}>{busy ? "Saving..." : "Save general settings"}</Button></>}
|
||||
>
|
||||
<div className="admin-settings-form">
|
||||
<Card title="Locale">
|
||||
<FormField label="Tenant locale" help="Used as this tenant's locale default for tenant-aware views and future formatting defaults.">
|
||||
<input value={draft.default_locale} disabled={!canWrite || busy} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })} />
|
||||
</FormField>
|
||||
<dl className="detail-list">
|
||||
<div><dt>Tenant</dt><dd>{draft.name || "-"}</dd></div>
|
||||
<div><dt>Slug</dt><dd>{draft.slug || "-"}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createTenant, fetchSystemSettings, fetchTenantOwnerCandidates, fetchTenants, updateTenant, type SystemSettingsItem, type TenantAdminItem, type TenantOwnerCandidate } from "../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
|
||||
|
||||
type OverrideValue = "inherit" | "allow" | "deny";
|
||||
type TenantDraft = {
|
||||
slug: string;
|
||||
name: string;
|
||||
ownerAccountId: string;
|
||||
description: string;
|
||||
defaultLocale: string;
|
||||
isActive: boolean;
|
||||
customGroups: OverrideValue;
|
||||
customRoles: OverrideValue;
|
||||
apiKeys: OverrideValue;
|
||||
};
|
||||
|
||||
const emptyDraft: TenantDraft = {
|
||||
slug: "",
|
||||
name: "",
|
||||
ownerAccountId: "",
|
||||
description: "",
|
||||
defaultLocale: "en",
|
||||
isActive: true,
|
||||
customGroups: "inherit",
|
||||
customRoles: "inherit",
|
||||
apiKeys: "inherit"
|
||||
};
|
||||
|
||||
function fromOverride(value?: boolean | null): OverrideValue {
|
||||
if (value === true) return "allow";
|
||||
if (value === false) return "deny";
|
||||
return "inherit";
|
||||
}
|
||||
|
||||
function toOverride(value: OverrideValue): boolean | null {
|
||||
if (value === "allow") return true;
|
||||
if (value === "deny") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function TenantsPanel({
|
||||
settings,
|
||||
auth,
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canSuspend,
|
||||
onAuthRefresh
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canSuspend: boolean;
|
||||
onAuthRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettingsItem | null>(null);
|
||||
const [ownerCandidates, setOwnerCandidates] = useState<TenantOwnerCandidate[]>([]);
|
||||
const [editing, setEditing] = useState<TenantAdminItem | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<TenantAdminItem | null>(null);
|
||||
const [draft, setDraft] = useState<TenantDraft>(emptyDraft);
|
||||
const [confirmSuspend, setConfirmSuspend] = useState<TenantAdminItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextTenants, nextOwnerCandidates, nextSystemSettings] = await Promise.all([
|
||||
fetchTenants(settings),
|
||||
canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([]),
|
||||
fetchSystemSettings(settings).catch(() => null)
|
||||
]);
|
||||
setTenants(nextTenants);
|
||||
setOwnerCandidates(nextOwnerCandidates);
|
||||
setSystemSettings(nextSystemSettings);
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void load(); }, [settings.accessToken, settings.apiBaseUrl]);
|
||||
|
||||
function openCreate() {
|
||||
setDraft({ ...emptyDraft, ownerAccountId: auth.user.account_id });
|
||||
setEditing("new");
|
||||
setError("");
|
||||
}
|
||||
|
||||
function openEdit(tenant: TenantAdminItem) {
|
||||
setDraft({
|
||||
slug: tenant.slug,
|
||||
name: tenant.name,
|
||||
ownerAccountId: "",
|
||||
description: tenant.description || "",
|
||||
defaultLocale: tenant.default_locale || "en",
|
||||
isActive: tenant.is_active,
|
||||
customGroups: fromOverride(tenant.allow_custom_groups),
|
||||
customRoles: fromOverride(tenant.allow_custom_roles),
|
||||
apiKeys: fromOverride(tenant.allow_api_keys)
|
||||
});
|
||||
setEditing(tenant);
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const governance = {
|
||||
allow_custom_groups: toOverride(draft.customGroups),
|
||||
allow_custom_roles: toOverride(draft.customRoles),
|
||||
allow_api_keys: toOverride(draft.apiKeys)
|
||||
};
|
||||
if (editing === "new") {
|
||||
const created = await createTenant(settings, {
|
||||
slug: draft.slug,
|
||||
name: draft.name,
|
||||
owner_account_id: draft.ownerAccountId || null,
|
||||
description: draft.description || null,
|
||||
default_locale: draft.defaultLocale,
|
||||
settings: {},
|
||||
...governance
|
||||
});
|
||||
const selectedOwner = ownerCandidates.find((candidate) => candidate.account_id === draft.ownerAccountId);
|
||||
setSuccess(`Tenant ${created.name} created with ${selectedOwner?.display_name || selectedOwner?.email || "the selected account"} as Owner.`);
|
||||
await onAuthRefresh();
|
||||
} else if (editing) {
|
||||
const payload: Parameters<typeof updateTenant>[2] = {};
|
||||
if (canUpdate) {
|
||||
payload.name = draft.name;
|
||||
payload.description = draft.description || null;
|
||||
payload.default_locale = draft.defaultLocale;
|
||||
payload.allow_custom_groups = governance.allow_custom_groups;
|
||||
payload.allow_custom_roles = governance.allow_custom_roles;
|
||||
payload.allow_api_keys = governance.allow_api_keys;
|
||||
}
|
||||
if (canSuspend) payload.is_active = draft.isActive;
|
||||
await updateTenant(settings, editing.id, payload);
|
||||
setSuccess(`Tenant ${draft.name} updated.`);
|
||||
await onAuthRefresh();
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function suspend() {
|
||||
if (!confirmSuspend) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateTenant(settings, confirmSuspend.id, { is_active: false });
|
||||
setSuccess(`${confirmSuspend.name} suspended.`);
|
||||
setConfirmSuspend(null);
|
||||
await onAuthRefresh();
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const activeTenantId = (auth.active_tenant ?? auth.tenant).id;
|
||||
const systemAllowsCustomGroups = systemSettings?.allow_tenant_custom_groups !== false;
|
||||
const systemAllowsCustomRoles = systemSettings?.allow_tenant_custom_roles !== false;
|
||||
const systemAllowsApiKeys = systemSettings?.allow_tenant_api_keys !== false;
|
||||
const systemDeniedGovernance = [
|
||||
systemAllowsCustomGroups ? "" : "custom groups",
|
||||
systemAllowsCustomRoles ? "" : "custom roles",
|
||||
systemAllowsApiKeys ? "" : "API keys"
|
||||
].filter(Boolean).join(", ");
|
||||
const columns = useMemo<DataGridColumn<TenantAdminItem>[]>(() => [
|
||||
{ id: "name", header: "Tenant", width: "minmax(210px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div> },
|
||||
{ id: "users", header: "Users", width: 100, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.users ?? 0, render: (row) => `${row.counts.active_users ?? 0}/${row.counts.users ?? 0}` },
|
||||
{ id: "groups", header: "Groups", width: 95, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.groups ?? 0 },
|
||||
{ id: "campaigns", header: "Campaigns", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.campaigns ?? 0 },
|
||||
{ id: "files", header: "Files", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 0 },
|
||||
{ id: "locale", header: "Locale", width: 120, minWidth: 90, maxWidth: 220, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.default_locale },
|
||||
{ id: "status", header: "Status", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||
{ id: "actions", header: "Actions", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={`Inspect ${row.name}`} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={`Edit ${row.name}`} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canUpdate} />
|
||||
<AdminIconButton label={`Suspend ${row.name}`} icon={<Trash2 />} variant="danger" onClick={() => setConfirmSuspend(row)} disabled={!canSuspend || !row.is_active || row.id === activeTenantId} />
|
||||
</div> }
|
||||
], [activeTenantId, canSuspend, canUpdate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="Tenants"
|
||||
description="Create and govern tenant spaces. Suspension retains campaigns, files and audit evidence; the tenant backing the current session cannot be suspended."
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Add tenant" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}
|
||||
>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-tenants-v3" rows={tenants} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No tenants found." /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "Create tenant" : "Edit tenant"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={!(editing === "new" ? canCreate : canUpdate) || busy || !draft.name.trim() || !draft.slug.trim() || (editing === "new" && !draft.ownerAccountId)}>{busy ? "Saving…" : "Save tenant"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Name"><input value={draft.name} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Slug"><input value={draft.slug} disabled={editing !== "new" || !canCreate} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
{editing === "new" && <FormField label="Initial tenant owner"><select value={draft.ownerAccountId} onChange={(event) => setDraft({ ...draft, ownerAccountId: event.target.value })}>{ownerCandidates.map((candidate) => <option key={candidate.account_id} value={candidate.account_id}>{candidate.display_name ? `${candidate.display_name} (${candidate.email})` : candidate.email}</option>)}</select></FormField>}
|
||||
<FormField label="Default locale"><input value={draft.defaultLocale} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, defaultLocale: event.target.value })} /></FormField>
|
||||
{editing !== "new" && <FormField label="Status"><select value={draft.isActive ? "active" : "inactive"} disabled={!canSuspend} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Suspended</option></select></FormField>}
|
||||
<FormField label="Description"><textarea rows={4} value={draft.description} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</div>
|
||||
<h3>System governance overrides</h3>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomGroups} label="Custom tenant groups" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomRoles} label="Custom tenant roles" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsApiKeys} label="Tenant API keys" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
|
||||
</div>
|
||||
<p className="muted small-note">Inherit follows the current system setting. Explicit deny narrows access; explicit allow is valid only while the system setting allows it.</p>
|
||||
{systemDeniedGovernance && <p className="muted small-note">Explicit allow is unavailable for {systemDeniedGovernance} because the current system setting denies it.</p>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="Tenant details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
{viewing && <><dl className="admin-details-grid">
|
||||
<div><dt>Tenant</dt><dd>{viewing.name}</dd></div><div><dt>Slug</dt><dd>{viewing.slug}</dd></div>
|
||||
<div><dt>Status</dt><dd>{viewing.is_active ? "Active" : "Suspended"}</dd></div><div><dt>Default locale</dt><dd>{viewing.default_locale}</dd></div>
|
||||
<div><dt>Created</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>Updated</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
|
||||
<div><dt>Custom groups</dt><dd>{viewing.effective_governance.allow_custom_groups ? "Allowed" : "Denied"} ({fromOverride(viewing.allow_custom_groups)})</dd></div>
|
||||
<div><dt>Custom roles</dt><dd>{viewing.effective_governance.allow_custom_roles ? "Allowed" : "Denied"} ({fromOverride(viewing.allow_custom_roles)})</dd></div>
|
||||
<div><dt>API keys</dt><dd>{viewing.effective_governance.allow_api_keys ? "Allowed" : "Denied"} ({fromOverride(viewing.allow_api_keys)})</dd></div>
|
||||
<div><dt>Objects</dt><dd>{viewing.counts.users ?? 0} users, {viewing.counts.groups ?? 0} groups, {viewing.counts.campaigns ?? 0} campaigns, {viewing.counts.files ?? 0} files</dd></div>
|
||||
</dl>{viewing.description && <p>{viewing.description}</p>}</>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(confirmSuspend)} title="Suspend tenant" message={`Suspend ${confirmSuspend?.name}? Existing data remains retained, but its members cannot use the tenant.`} confirmLabel="Suspend tenant" tone="danger" busy={busy} onCancel={() => setConfirmSuspend(null)} onConfirm={() => void suspend()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GovernanceSelect({ label, value, onChange, disabled = false, allowDisabled = false }: { label: string; value: OverrideValue; onChange: (value: OverrideValue) => void; disabled?: boolean; allowDisabled?: boolean }) {
|
||||
return <FormField label={label}><select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as OverrideValue)}><option value="inherit">Inherit system setting</option><option value="allow" disabled={allowDisabled}>Allow when system allows</option><option value="deny">Explicitly deny</option></select></FormField>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user