Compare commits
120
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
982ef636b8 | ||
|
|
9aad49f16d | ||
|
|
2b5c14385d | ||
|
|
bca3e46293 | ||
|
|
f09d2bf9df | ||
|
|
702421be48 | ||
|
|
bb471df21c | ||
|
|
bfb0d7d7c9 | ||
|
|
1974bf1a2b | ||
|
|
0c9bf6758c | ||
|
|
25da7d49a9 | ||
|
|
40c10089ab | ||
|
|
d6e7c8b0b1 | ||
|
|
5bc7d748f8 | ||
|
|
7117673ecc | ||
|
|
14351b0c94 | ||
|
|
ad57fad1ea | ||
|
|
fa32cca03f | ||
|
|
2d0551a845 | ||
|
|
bb84122061 | ||
|
|
b823a22b9b | ||
|
|
70fc6da811 | ||
|
|
729b84d3af | ||
|
|
b962f6756e | ||
|
|
79d00b84e3 | ||
|
|
842be5edb5 | ||
|
|
7e59a7f2b3 | ||
|
|
5bfbe9a887 | ||
|
|
01f91154e0 | ||
|
|
6c2940aebc | ||
|
|
670693bde8 | ||
|
|
bca6a7c8aa | ||
|
|
21c1fa49b6 | ||
|
|
435b924fd9 | ||
|
|
af5c6af0e7 | ||
|
|
c6ef644842 | ||
|
|
5783d43547 | ||
|
|
e4d2d10c7e | ||
|
|
fe62fd4644 | ||
|
|
9ecdc6d713 | ||
|
|
ca35aad286 | ||
|
|
d6255f9f8f | ||
|
|
b58c9c55cf | ||
|
|
3c4bcc28f1 | ||
|
|
972c681650 | ||
|
|
2b4eb0151f | ||
|
|
7192d32e65 | ||
|
|
b65b48832b | ||
|
|
4cb334c912 | ||
|
|
6ebb299d6c | ||
|
|
42b5019464 | ||
|
|
4c0e6435ab | ||
|
|
e37d8fee94 | ||
|
|
50a8d459e7 | ||
|
|
5ee85d07d6 | ||
|
|
cf01545806 | ||
|
|
1884274f8d | ||
|
|
5211e07d0b | ||
|
|
7b8072d049 | ||
|
|
5b55f59a92 | ||
|
|
f0898fcdee | ||
|
|
9b88ae388b | ||
|
|
6970bf7457 | ||
|
|
47e106684d | ||
|
|
9e219bc4d3 | ||
|
|
ea436a513f | ||
|
|
e7c84e3227 | ||
|
|
cf7afe9dda | ||
|
|
ca8a8c5111 | ||
|
|
f3b388fe7e | ||
|
|
af3e0a055d | ||
|
|
51d4032b86 | ||
|
|
0beb9ffea9 | ||
|
|
9e6a6b5fdc | ||
|
|
48fb953b93 | ||
|
|
4bde0495f7 | ||
|
|
a80caf7933 | ||
|
|
790790ab37 | ||
|
|
920e3c9834 | ||
|
|
13893c80cd | ||
|
|
a192a2215f | ||
|
|
e8fed6d25a | ||
|
|
d9b5708df0 | ||
|
|
68328f3d8e | ||
|
|
53e947935a | ||
|
|
324c26da78 | ||
|
|
389f98e349 | ||
|
|
ce9ef8d88f | ||
|
|
13bc3d3b4e | ||
|
|
3f5870281a | ||
|
|
a46df85479 | ||
|
|
c31581b1b9 | ||
|
|
26ae034153 | ||
|
|
baa2143a26 | ||
|
|
8b1910b5b7 | ||
|
|
d36bb94335 | ||
|
|
74034947c6 | ||
|
|
c7183fe7f1 | ||
|
|
139a352c80 | ||
|
|
336c94137f | ||
|
|
93225b6487 | ||
|
|
e11ea81008 | ||
|
|
bc8afeb139 | ||
|
|
f876345656 | ||
|
|
d487726f4d | ||
|
|
e6fc07da37 | ||
|
|
e6d589eb07 | ||
|
|
59610e21d2 | ||
|
|
cece71d945 | ||
|
|
22e8183846 | ||
|
|
aa111a5fe1 | ||
|
|
e6062fe9e4 | ||
|
|
987ca894ed | ||
|
|
4caa326878 | ||
|
|
8c4c4456c6 | ||
|
|
6abe292ac8 | ||
|
|
fea2807754 | ||
|
|
22646c614c | ||
|
|
17376332a2 | ||
|
|
0946bc84a9 |
@@ -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
|
||||
@@ -45,6 +45,8 @@ tools/checks/check-focused.sh
|
||||
- Prefer `rg`, `sed`, and targeted tests over broad recursive scans or full builds.
|
||||
- Avoid DataGrid changes unless explicitly requested; it is intentionally brittle and has known deferred work.
|
||||
- Do not add module-to-module imports for optional integrations. Use core registry/capability/module metadata paths.
|
||||
- Treat documentation as part of every behavior change. Update the owning module's manifest-driven `DocumentationTopic` contributions for each affected user and administrator workflow, setting, permission, limitation, and operational consequence. Feature modules own this content; `govoplan-docs` projects it and must not import feature internals.
|
||||
- Keep a static user and administrator documentation baseline in every module manifest, even when richer configured-state topics come from `documentation_providers`. Run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after changing a manifest or module behavior.
|
||||
- Treat Gitea issues as the canonical backlog and state log. Treat Gitea wiki pages as durable project context mirrored from repository and product docs. Use `docs/GITEA_ISSUES.md` for labels, templates, TODO import, wiki sync, and Codex issue updates.
|
||||
- Do not keep generated WebUI test folders in git status; they should be ignored and removable.
|
||||
- Do not start persistent dev servers unless the user asks.
|
||||
|
||||
@@ -4,13 +4,6 @@
|
||||
**Repository type:** system (kernel).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=module-matrix.yml&actor=0&status=0)
|
||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=release-integration.yml&actor=0&status=0)
|
||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=dependency-audit.yml&actor=0&status=0)
|
||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=security-audit.yml&actor=0&status=0)
|
||||
|
||||
# govoplan-core
|
||||
|
||||
GovOPlaN core is the platform runner and shared foundation. It owns the server entry point, database/session primitives, module discovery, migration orchestration, capability contracts, install/uninstall orchestration, and the shared WebUI shell. Platform and feature behavior is supplied by installed modules.
|
||||
|
||||
## Repository ownership
|
||||
@@ -23,6 +16,9 @@ Core owns:
|
||||
- kernel APIs for platform metadata, module lifecycle, health, and development diagnostics
|
||||
- `@govoplan/core-webui`, including login, CSRF/API helpers, shell layout, generic UI components, IconRail, DataGrid, access boundaries, and module route/nav contracts
|
||||
|
||||
The shared DataGrid sizing and resize invariants are specified in
|
||||
[`docs/DATAGRID_SIZING_CONTRACT.md`](docs/DATAGRID_SIZING_CONTRACT.md).
|
||||
|
||||
Platform and feature modules own their backend routers, models, migrations,
|
||||
permissions, frontend packages, nav items, and route contributions. Access,
|
||||
tenancy, policy, audit, and admin behavior live in their owning platform
|
||||
@@ -54,7 +50,7 @@ python3 -m venv .venv
|
||||
./.venv/bin/python -m pip install -r requirements-dev.txt
|
||||
```
|
||||
|
||||
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to `tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops`; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
|
||||
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to the modules listed by `govoplan_core.settings.Settings.enabled_modules`, including Views when its package is installed; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
@@ -74,6 +70,21 @@ ENABLED_MODULES=access,campaigns /mnt/DATA/git/govoplan/.venv/bin/python -m govo
|
||||
|
||||
The runner loads the same `GovoplanServerConfig` as `govoplan_core.server.app:app`, builds the platform registry, and passes core plus enabled module source roots to uvicorn as reload directories. After reinstalling the editable package, the same command is also available as `govoplan-devserver`.
|
||||
|
||||
For focused backend work, keep the complete module graph active while watching
|
||||
only the module being edited. Core/config sources and explicit `--reload-dir`
|
||||
paths remain watched:
|
||||
|
||||
```bash
|
||||
/mnt/DATA/git/govoplan/.venv/bin/python -m govoplan_core.devserver \
|
||||
--reload-module calendar \
|
||||
--reload-module campaign
|
||||
```
|
||||
|
||||
Use `--reload-core-only` when no optional module source tree should trigger a
|
||||
restart. Omitting both options preserves the broad default and watches every
|
||||
enabled module. Startup, migration, and compatibility checks still run against
|
||||
the complete enabled graph whenever the backend restarts.
|
||||
|
||||
The default development database is PostgreSQL at `postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev`. Store the password in `~/.pgpass`. To force the disposable SQLite fallback, run with `GOVOPLAN_DEV_DATABASE_BACKEND=sqlite`; that database lives below `runtime/`.
|
||||
|
||||
Local devserver runs do not require Redis. `CELERY_ENABLED` defaults to `false`, so campaign queue actions update database state without publishing Celery tasks. Use the synchronous send flow for local send tests, or set `CELERY_ENABLED=true` only when a Redis broker and worker are running.
|
||||
@@ -106,7 +117,7 @@ CI runs the `ci` profile in report-only mode and uploads `audit-reports/` as an
|
||||
artifact. Once the baseline is clean, set `SECURITY_AUDIT_FAIL_ON_FINDINGS=1`
|
||||
or pass `--strict` locally to turn findings into a failing gate.
|
||||
|
||||
`govoplan_core.devserver` enables the development bootstrap before loading settings. In dev, startup migrations create or upgrade the schema and the bootstrap creates the default development login if needed. Explicitly setting `DEV_BOOTSTRAP_ENABLED=false` disables this convenience. Production deployments should use migrations and managed database provisioning instead.
|
||||
`govoplan_core.devserver` enables the development bootstrap before loading settings. In dev, startup migrations create or upgrade the schema and the bootstrap creates the default development login if needed. Explicitly setting `DEV_BOOTSTRAP_ENABLED=false` disables this convenience. Production deployments use the separate, expiring single-use flow exposed by `python -m govoplan_core.commands.first_admin`; it cannot enable or consume development bootstrap credentials.
|
||||
|
||||
To verify the effective runtime paths and bootstrap behavior without starting uvicorn, run the smoke mode:
|
||||
|
||||
@@ -143,6 +154,10 @@ PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versio
|
||||
|
||||
The local host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. Release builds should use `webui/package.release.json`, which points WebUI module packages at tagged git refs. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md).
|
||||
|
||||
Production builds lazy-load enabled module descriptors and enforce initial and
|
||||
asynchronous JavaScript budgets. See
|
||||
[WEBUI_BUNDLE_BUDGETS.md](docs/WEBUI_BUNDLE_BUDGETS.md).
|
||||
|
||||
## Module contract
|
||||
|
||||
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""add reusable core credential envelopes
|
||||
|
||||
Revision ID: c91f0a72be34
|
||||
Revises: 0f1e2d3c4b5a
|
||||
Create Date: 2026-07-23 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c91f0a72be34"
|
||||
down_revision = "0f1e2d3c4b5a"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if "core_credential_envelopes" in inspector.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
"core_credential_envelopes",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("scope_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("credential_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("public_data", sa.JSON(), nullable=False),
|
||||
sa.Column("secret_data_encrypted", sa.Text(), nullable=True),
|
||||
sa.Column("secret_keys", sa.JSON(), nullable=False),
|
||||
sa.Column("allowed_modules", sa.JSON(), nullable=False),
|
||||
sa.Column("allowed_server_refs", sa.JSON(), nullable=False),
|
||||
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("revision", sa.String(length=36), nullable=False),
|
||||
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_credential_envelopes_scope",
|
||||
"core_credential_envelopes",
|
||||
["tenant_id", "scope_type", "scope_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_credential_envelopes_active",
|
||||
"core_credential_envelopes",
|
||||
["tenant_id", "is_active", "deleted_at"],
|
||||
unique=False,
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"credential_kind",
|
||||
"is_active",
|
||||
"created_by_user_id",
|
||||
"updated_by_user_id",
|
||||
"deleted_at",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_core_credential_envelopes_{column}"),
|
||||
"core_credential_envelopes",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if "core_credential_envelopes" in inspector.get_table_names():
|
||||
op.drop_table("core_credential_envelopes")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""development-track wrapper for generic ownership transfers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "versions"
|
||||
/ "d03a7b9c1e5f_core_ownership_transfers.py"
|
||||
)
|
||||
_spec = spec_from_file_location("govoplan_core_ownership_transfer_migration", _path)
|
||||
if _spec is None or _spec.loader is None:
|
||||
raise RuntimeError(f"Unable to load ownership migration from {_path}")
|
||||
_migration = module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_migration)
|
||||
|
||||
revision = _migration.revision
|
||||
down_revision = _migration.down_revision
|
||||
branch_labels = _migration.branch_labels
|
||||
depends_on = _migration.depends_on
|
||||
upgrade = _migration.upgrade
|
||||
downgrade = _migration.downgrade
|
||||
@@ -0,0 +1,24 @@
|
||||
"""development-track wrapper for runtime coordination and recovery."""
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "versions"
|
||||
/ "e14b8c2d6f90_runtime_coordination_recovery.py"
|
||||
)
|
||||
_spec = spec_from_file_location("govoplan_runtime_coordination_migration", _path)
|
||||
if _spec is None or _spec.loader is None:
|
||||
raise RuntimeError(f"Unable to load runtime coordination migration from {_path}")
|
||||
_migration = module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_migration)
|
||||
|
||||
revision = _migration.revision
|
||||
down_revision = _migration.down_revision
|
||||
branch_labels = _migration.branch_labels
|
||||
depends_on = _migration.depends_on
|
||||
upgrade = _migration.upgrade
|
||||
downgrade = _migration.downgrade
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "versions"
|
||||
/ "f25c9d3e7a01_first_admin_enrollment.py"
|
||||
)
|
||||
_spec = spec_from_file_location("govoplan_first_admin_enrollment_migration", _path)
|
||||
if _spec is None or _spec.loader is None:
|
||||
raise RuntimeError(f"Unable to load migration implementation from {_path}")
|
||||
_module = module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_module)
|
||||
|
||||
revision = _module.revision
|
||||
down_revision = _module.down_revision
|
||||
branch_labels = _module.branch_labels
|
||||
depends_on = _module.depends_on
|
||||
upgrade = _module.upgrade
|
||||
downgrade = _module.downgrade
|
||||
+10
-1
@@ -5,9 +5,18 @@ from logging.config import fileConfig
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
||||
try:
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate optional access metadata
|
||||
except ModuleNotFoundError as exc:
|
||||
if exc.name != "govoplan_access":
|
||||
raise
|
||||
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
|
||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core import first_admin as core_first_admin_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core import ownership as core_ownership_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core import recovery as core_recovery_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core import runtime_coordination as core_runtime_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core.migrations import migration_metadata_plan
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.server.default_config import get_server_config
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""add reusable core credential envelopes
|
||||
|
||||
Revision ID: c91f0a72be34
|
||||
Revises: 4f2a9c8e7b6d
|
||||
Create Date: 2026-07-23 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c91f0a72be34"
|
||||
down_revision = "4f2a9c8e7b6d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if "core_credential_envelopes" in inspector.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
"core_credential_envelopes",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("scope_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("credential_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("public_data", sa.JSON(), nullable=False),
|
||||
sa.Column("secret_data_encrypted", sa.Text(), nullable=True),
|
||||
sa.Column("secret_keys", sa.JSON(), nullable=False),
|
||||
sa.Column("allowed_modules", sa.JSON(), nullable=False),
|
||||
sa.Column("allowed_server_refs", sa.JSON(), nullable=False),
|
||||
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("revision", sa.String(length=36), nullable=False),
|
||||
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_credential_envelopes_scope",
|
||||
"core_credential_envelopes",
|
||||
["tenant_id", "scope_type", "scope_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_credential_envelopes_active",
|
||||
"core_credential_envelopes",
|
||||
["tenant_id", "is_active", "deleted_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_credential_envelopes_tenant_id"),
|
||||
"core_credential_envelopes",
|
||||
["tenant_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_credential_envelopes_scope_type"),
|
||||
"core_credential_envelopes",
|
||||
["scope_type"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_credential_envelopes_scope_id"),
|
||||
"core_credential_envelopes",
|
||||
["scope_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_credential_envelopes_credential_kind"),
|
||||
"core_credential_envelopes",
|
||||
["credential_kind"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_credential_envelopes_is_active"),
|
||||
"core_credential_envelopes",
|
||||
["is_active"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_credential_envelopes_created_by_user_id"),
|
||||
"core_credential_envelopes",
|
||||
["created_by_user_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_credential_envelopes_updated_by_user_id"),
|
||||
"core_credential_envelopes",
|
||||
["updated_by_user_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_credential_envelopes_deleted_at"),
|
||||
"core_credential_envelopes",
|
||||
["deleted_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if "core_credential_envelopes" in inspector.get_table_names():
|
||||
op.drop_table("core_credential_envelopes")
|
||||
@@ -0,0 +1,129 @@
|
||||
"""add generic resource ownership transfer state
|
||||
|
||||
Revision ID: d03a7b9c1e5f
|
||||
Revises: c91f0a72be34
|
||||
Create Date: 2026-07-30 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d03a7b9c1e5f"
|
||||
down_revision = "c91f0a72be34"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if "core_ownership_transfers" in inspector.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
"core_ownership_transfers",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("resource_module", sa.String(length=100), nullable=False),
|
||||
sa.Column("resource_type", sa.String(length=100), nullable=False),
|
||||
sa.Column("resource_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("status", sa.String(length=50), nullable=False),
|
||||
sa.Column("current_owner_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("current_owner_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("target_owner_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("target_owner_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("initiated_by_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("initiated_by_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("owner_approved_by_type", sa.String(length=40), nullable=True),
|
||||
sa.Column("owner_approved_by_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("target_accepted_by_type", sa.String(length=40), nullable=True),
|
||||
sa.Column("target_accepted_by_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column("assurance_profile", sa.String(length=80), nullable=True),
|
||||
sa.Column("required_approvals", sa.Integer(), nullable=False),
|
||||
sa.Column("approvals", sa.JSON(), nullable=False),
|
||||
sa.Column("decisions", sa.JSON(), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=200), nullable=False),
|
||||
sa.Column("canonical_request_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("execute_after", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("declined_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("expired_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_ownership_transfers")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"resource_module",
|
||||
"idempotency_key",
|
||||
name="uq_core_ownership_transfer_idempotency",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_ownership_transfers_tenant_id"),
|
||||
"core_ownership_transfers",
|
||||
["tenant_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_ownership_transfers_kind"),
|
||||
"core_ownership_transfers",
|
||||
["kind"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_ownership_transfers_status"),
|
||||
"core_ownership_transfers",
|
||||
["status"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_ownership_transfer_resource",
|
||||
"core_ownership_transfers",
|
||||
[
|
||||
"tenant_id",
|
||||
"resource_module",
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
"status",
|
||||
],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_ownership_transfer_expiry",
|
||||
"core_ownership_transfers",
|
||||
["status", "expires_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if "core_ownership_transfers" not in inspector.get_table_names():
|
||||
return
|
||||
op.drop_index(
|
||||
"ix_core_ownership_transfer_expiry",
|
||||
table_name="core_ownership_transfers",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_core_ownership_transfer_resource",
|
||||
table_name="core_ownership_transfers",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_core_ownership_transfers_status"),
|
||||
table_name="core_ownership_transfers",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_core_ownership_transfers_kind"),
|
||||
table_name="core_ownership_transfers",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_core_ownership_transfers_tenant_id"),
|
||||
table_name="core_ownership_transfers",
|
||||
)
|
||||
op.drop_table("core_ownership_transfers")
|
||||
@@ -0,0 +1,232 @@
|
||||
"""add runtime coordination and recovery evidence
|
||||
|
||||
Revision ID: e14b8c2d6f90
|
||||
Revises: d03a7b9c1e5f
|
||||
Create Date: 2026-08-01 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "e14b8c2d6f90"
|
||||
down_revision = "d03a7b9c1e5f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
if "core_runtime_nodes" not in tables:
|
||||
op.create_table(
|
||||
"core_runtime_nodes",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("installation_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("node_id", sa.String(length=200), nullable=False),
|
||||
sa.Column("incarnation", sa.String(length=36), nullable=False),
|
||||
sa.Column("role", sa.String(length=40), nullable=False),
|
||||
sa.Column("software_version", sa.String(length=80), nullable=False),
|
||||
sa.Column("composition_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("queues", sa.JSON(), nullable=False),
|
||||
sa.Column("state", sa.String(length=30), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_heartbeat_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("drain_requested_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("drain_reason", sa.String(length=500), nullable=True),
|
||||
sa.Column("stopped_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_runtime_nodes")),
|
||||
sa.UniqueConstraint(
|
||||
"installation_id",
|
||||
"node_id",
|
||||
name="uq_core_runtime_node_installation_node",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"installation_id",
|
||||
"node_id",
|
||||
"incarnation",
|
||||
"role",
|
||||
"composition_hash",
|
||||
"state",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_core_runtime_nodes_{column}"),
|
||||
"core_runtime_nodes",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_runtime_nodes_installation_state_heartbeat",
|
||||
"core_runtime_nodes",
|
||||
["installation_id", "state", "last_heartbeat_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
if "core_distributed_leases" not in tables:
|
||||
op.create_table(
|
||||
"core_distributed_leases",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("installation_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("resource_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("holder_node_id", sa.String(length=200), nullable=True),
|
||||
sa.Column("holder_incarnation", sa.String(length=36), nullable=True),
|
||||
sa.Column("fencing_token", sa.BigInteger(), nullable=False),
|
||||
sa.Column("acquired_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("renewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_distributed_leases")),
|
||||
sa.UniqueConstraint(
|
||||
"installation_id",
|
||||
"resource_key",
|
||||
name="uq_core_distributed_lease_resource",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"installation_id",
|
||||
"resource_key",
|
||||
"holder_node_id",
|
||||
"holder_incarnation",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_core_distributed_leases_{column}"),
|
||||
"core_distributed_leases",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_distributed_leases_expiry",
|
||||
"core_distributed_leases",
|
||||
["installation_id", "expires_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
if "core_recovery_operations" not in tables:
|
||||
op.create_table(
|
||||
"core_recovery_operations",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("installation_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("module_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("operation_type", sa.String(length=100), nullable=False),
|
||||
sa.Column("resource_type", sa.String(length=100), nullable=True),
|
||||
sa.Column("resource_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("mode", sa.String(length=40), nullable=False),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=200), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("plan", sa.JSON(), nullable=False),
|
||||
sa.Column("backup_reference", sa.String(length=1000), nullable=True),
|
||||
sa.Column("approval_reference", sa.String(length=1000), nullable=True),
|
||||
sa.Column("lease_resource_key", sa.String(length=255), nullable=True),
|
||||
sa.Column("holder_node_id", sa.String(length=200), nullable=True),
|
||||
sa.Column("holder_incarnation", sa.String(length=36), nullable=True),
|
||||
sa.Column("fencing_token", sa.BigInteger(), nullable=True),
|
||||
sa.Column("checkpoint_count", sa.Integer(), nullable=False),
|
||||
sa.Column("evidence_head_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("failure_summary", sa.Text(), nullable=True),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("recovery_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("recovered_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("metadata", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_recovery_operations")),
|
||||
sa.UniqueConstraint(
|
||||
"installation_id",
|
||||
"module_id",
|
||||
"idempotency_key",
|
||||
name="uq_core_recovery_operation_idempotency",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"installation_id",
|
||||
"module_id",
|
||||
"operation_type",
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
"mode",
|
||||
"status",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_core_recovery_operations_{column}"),
|
||||
"core_recovery_operations",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_recovery_operations_status_updated",
|
||||
"core_recovery_operations",
|
||||
["installation_id", "status", "updated_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_recovery_operations_resource",
|
||||
"core_recovery_operations",
|
||||
["module_id", "resource_type", "resource_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
if "core_recovery_checkpoints" not in tables:
|
||||
op.create_table(
|
||||
"core_recovery_checkpoints",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("operation_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("kind", sa.String(length=80), nullable=False),
|
||||
sa.Column("summary", sa.Text(), nullable=False),
|
||||
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||
sa.Column("previous_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("checkpoint_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["operation_id"],
|
||||
["core_recovery_operations.id"],
|
||||
name=op.f(
|
||||
"fk_core_recovery_checkpoints_operation_id_core_recovery_operations"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_recovery_checkpoints")),
|
||||
sa.UniqueConstraint(
|
||||
"operation_id",
|
||||
"sequence",
|
||||
name="uq_core_recovery_checkpoint_sequence",
|
||||
),
|
||||
)
|
||||
for column in ("operation_id", "status", "checkpoint_sha256"):
|
||||
op.create_index(
|
||||
op.f(f"ix_core_recovery_checkpoints_{column}"),
|
||||
"core_recovery_checkpoints",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_core_recovery_checkpoints_operation_created",
|
||||
"core_recovery_checkpoints",
|
||||
["operation_id", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
for table in (
|
||||
"core_recovery_checkpoints",
|
||||
"core_recovery_operations",
|
||||
"core_distributed_leases",
|
||||
"core_runtime_nodes",
|
||||
):
|
||||
if table in tables:
|
||||
op.drop_table(table)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""add controlled first-administrator enrollment evidence
|
||||
|
||||
Revision ID: f25c9d3e7a01
|
||||
Revises: e14b8c2d6f90
|
||||
Create Date: 2026-08-04 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "f25c9d3e7a01"
|
||||
down_revision = "e14b8c2d6f90"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
if "core_first_admin_enrollments" not in tables:
|
||||
op.create_table(
|
||||
"core_first_admin_enrollments",
|
||||
sa.Column("installation_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("state", sa.String(length=24), nullable=False),
|
||||
sa.Column("generation", sa.Integer(), nullable=False),
|
||||
sa.Column("token_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("token_fingerprint", sa.String(length=16), nullable=True),
|
||||
sa.Column("issued_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("consumed_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("consumed_membership_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("consumed_tenant_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("consumed_email", sa.String(length=320), nullable=True),
|
||||
sa.Column("consumed_display_name", sa.String(length=255), nullable=True),
|
||||
sa.Column("consumed_request_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("issue_reason", sa.String(length=500), nullable=True),
|
||||
sa.Column("event_count", sa.Integer(), nullable=False),
|
||||
sa.Column("evidence_head_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"installation_id",
|
||||
name=op.f("pk_core_first_admin_enrollments"),
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_first_admin_enrollments_state"),
|
||||
"core_first_admin_enrollments",
|
||||
["state"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_core_first_admin_enrollments_expires_at"),
|
||||
"core_first_admin_enrollments",
|
||||
["expires_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
if "core_first_admin_enrollment_events" not in tables:
|
||||
op.create_table(
|
||||
"core_first_admin_enrollment_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("installation_id", sa.String(length=100), nullable=False),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=80), nullable=False),
|
||||
sa.Column("generation", sa.Integer(), nullable=False),
|
||||
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||
sa.Column("previous_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("event_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["installation_id"],
|
||||
["core_first_admin_enrollments.installation_id"],
|
||||
name=op.f(
|
||||
"fk_core_first_admin_enrollment_events_installation_id_core_first_admin_enrollments"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_core_first_admin_enrollment_events"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"installation_id",
|
||||
"sequence",
|
||||
name="uq_core_first_admin_enrollment_event_sequence",
|
||||
),
|
||||
)
|
||||
for column in ("installation_id", "event_type", "event_sha256"):
|
||||
op.create_index(
|
||||
op.f(f"ix_core_first_admin_enrollment_events_{column}"),
|
||||
"core_first_admin_enrollment_events",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
if "core_first_admin_enrollment_events" in tables:
|
||||
op.drop_table("core_first_admin_enrollment_events")
|
||||
if "core_first_admin_enrollments" in tables:
|
||||
op.drop_table("core_first_admin_enrollments")
|
||||
@@ -5,7 +5,7 @@ only linear screen flows. Workflows, schedules, imports, connectors, policies,
|
||||
and external events all need to request governed actions without bypassing the
|
||||
same safety rules that apply to human users.
|
||||
|
||||
The first implementation should live in `govoplan-workflow` and core contracts.
|
||||
The first implementation lives in `govoplan-workflow-engine` and Core contracts.
|
||||
Create a separate `govoplan-automation` module only if action planning,
|
||||
schedulers, rule execution, or cross-module automation become too broad for
|
||||
workflow ownership.
|
||||
@@ -29,6 +29,10 @@ of module capabilities.
|
||||
## Action Definition
|
||||
|
||||
An `ActionDefinition` describes something a human or system actor can request.
|
||||
The versioned runtime DTOs and provider protocol live in
|
||||
`govoplan_core.core.automation`; domain modules implement the protocol and
|
||||
Workflow resolves providers through module capabilities rather than importing
|
||||
their implementations.
|
||||
|
||||
Recommended fields:
|
||||
|
||||
@@ -43,6 +47,9 @@ Recommended fields:
|
||||
irreversible
|
||||
- expected effects
|
||||
- idempotency key strategy
|
||||
- recovery mode: atomic, compensating, snapshot restore, forward recovery, or
|
||||
irreversible
|
||||
- concrete verification steps which prove whether the effect occurred
|
||||
- audit event names
|
||||
- preview provider
|
||||
|
||||
@@ -85,16 +92,45 @@ The runner should execute an action plan as follows:
|
||||
4. Run permission and policy checks.
|
||||
5. Generate a consequence preview.
|
||||
6. Reserve or verify the idempotency key.
|
||||
7. Execute the owning module capability.
|
||||
8. Record observed effects.
|
||||
9. Emit events and audit records.
|
||||
10. Mark the command complete, retryable, quarantined, or requiring manual
|
||||
7. Create a durable recovery operation and acquire its execution fence.
|
||||
8. Persist dispatch evidence before a non-atomic provider call.
|
||||
9. Execute the owning module capability.
|
||||
10. Verify the provider result and every announced effect using the action's
|
||||
declared recovery checks.
|
||||
11. Commit the local projection and verified recovery checkpoint together.
|
||||
12. Emit events and audit records.
|
||||
13. Mark the command complete, retryable, quarantined, or requiring manual
|
||||
intervention.
|
||||
|
||||
The runner must never advance workflow state past a required side effect unless
|
||||
the action definition explicitly allows asynchronous completion and the pending
|
||||
state is visible.
|
||||
|
||||
For external and asynchronous effects, providers must preserve the distinction
|
||||
between:
|
||||
|
||||
1. requested intent;
|
||||
2. approved intent;
|
||||
3. dispatched command;
|
||||
4. possibly executed but unconfirmed outcome;
|
||||
5. confirmed observed effect;
|
||||
6. reconciled, corrected, or compensated outcome.
|
||||
|
||||
An API timeout after dispatch is not a failed effect and must not be retried as
|
||||
an ordinary process failure or a fresh command. The runner records an unknown
|
||||
outcome, releases its execution authority, and blocks continuation until an
|
||||
operator or provider reconciliation proves either that the effect occurred or
|
||||
that it is absent.
|
||||
|
||||
`ActionDefinition.recovery_mode` and `recovery_verification` are part of the
|
||||
provider contract. The default is conservative forward recovery with explicit
|
||||
provider-result and effect verification. Atomic mode is valid only when the
|
||||
provider effect and its local projection share the same database transaction.
|
||||
The actor context should retain the real identity/account,
|
||||
represented function or party, delegation or power, and mandate/jurisdiction
|
||||
references when applicable. Domain modules remain responsible for deciding
|
||||
which of those references are required for their action.
|
||||
|
||||
## Failure States
|
||||
|
||||
Automation should use explicit failure states:
|
||||
@@ -110,10 +146,15 @@ Automation should use explicit failure states:
|
||||
|
||||
These states should be visible in workflow, task, and admin diagnostics.
|
||||
|
||||
The contract names these states explicitly as `ActionExecutionState`, alongside
|
||||
`pending`, `running`, and `completed`. A provider returns observed effects even
|
||||
for partial failures; the runner, not the provider, owns durable attempts,
|
||||
recovery decisions, and workflow advancement.
|
||||
|
||||
## Boundary
|
||||
|
||||
Core may own stable DTOs, registry contracts, and generic audit/event hooks.
|
||||
`govoplan-workflow` should own the first runner because workflow is the first
|
||||
`govoplan-workflow-engine` owns the first runner because workflow is the first
|
||||
module that coordinates cross-module process actions.
|
||||
|
||||
Domain modules own their own action providers. For example, templates own
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Automation Contracts
|
||||
|
||||
Core defines provider-neutral automation contracts. It does not own domain
|
||||
schedules, Workflow graphs, or Dataflow execution.
|
||||
|
||||
## Invocation Envelope
|
||||
|
||||
`AutomationInvocation` classifies a start as `manual`, `api`, `schedule`,
|
||||
`event`, `workflow`, `dependency`, `retry`, or `backfill`. It carries opaque
|
||||
trigger and delivery references, event identity, correlation and causation
|
||||
IDs, scheduled time, requesting actor, and bounded metadata. Domain runs store
|
||||
this envelope with their immutable definition revision.
|
||||
|
||||
## Current Authorization
|
||||
|
||||
An automated trigger must not persist a user session, bearer token, API key,
|
||||
or a snapshot of all current permissions. It stores:
|
||||
|
||||
- tenant, account, and membership IDs;
|
||||
- an opaque authorization reference;
|
||||
- the minimum scopes required by the pinned definition and output target.
|
||||
|
||||
At delivery time the optional
|
||||
`auth.automationPrincipalProvider` capability resolves current account,
|
||||
membership, role, group, function, and delegation state. It intersects current
|
||||
authorization with the stored grant. Missing, inactive, or reduced
|
||||
authorization blocks the delivery before effects occur.
|
||||
|
||||
## Definition Governance
|
||||
|
||||
The optional `policy.definitionGovernance` capability evaluates `view`,
|
||||
`edit`, `run`, `reuse`, `derive`, and `automate` for system, tenant, group, and
|
||||
user definitions. A decision contains an ordered source path and effective
|
||||
limits. Derived definitions pin their source revision and hash and retain
|
||||
ancestor ceilings. Templates are reusable definitions and cannot run on their
|
||||
own.
|
||||
|
||||
Without Policy, domain modules use a conservative tenant-local fallback:
|
||||
local definitions remain viewable/editable and active complete flows may run;
|
||||
inheritance, reuse, derivation, and automation are unavailable.
|
||||
|
||||
## Delivery Durability
|
||||
|
||||
Domain trigger implementations persist idempotent deliveries before running.
|
||||
`emit_platform_event` binds event delivery to the producer's SQLAlchemy
|
||||
transaction. When an enabled module provides `platform.eventOutbox`, the event
|
||||
is stored in that transaction and a dispatcher may retry it across restarts and
|
||||
workers. The Audit module provides the current SQL outbox implementation; the
|
||||
Celery `govoplan.events.dispatch_outbox` task drains it through the Dataflow
|
||||
event-ingestion capability and the local event bus.
|
||||
|
||||
The outbox capability remains optional so reduced module combinations can
|
||||
start. Without it, Core queues events on the SQLAlchemy transaction and
|
||||
publishes them to the process-local bus only after the outer commit. A rollback,
|
||||
including a nested savepoint rollback, discards the corresponding events. This
|
||||
fallback is suitable for local or non-critical reactions, but it is not a
|
||||
durable multi-worker automation source. Deployments that rely on event-triggered
|
||||
work must enable the outbox provider and run the `events` worker queue and
|
||||
periodic dispatcher.
|
||||
@@ -49,6 +49,15 @@ The broad writable root reduces approval churn. The explicit project trust entri
|
||||
|
||||
Each active repository has an `AGENTS.md` file. These files define ownership, module boundaries, and focused commands for Codex. Keep durable project conventions there instead of repeating them in every prompt.
|
||||
|
||||
Documentation is part of the completion criteria for every behavior change. The
|
||||
owning module must update its manifest-driven `DocumentationTopic` contributions
|
||||
for affected user and administrator workflows, settings, permissions,
|
||||
limitations, and operational consequences. Feature documentation remains in the
|
||||
feature module; the optional `govoplan-docs` module projects those contributions
|
||||
without importing feature internals. Every module manifest must retain a static
|
||||
user and administrator baseline even when runtime providers add configured-state
|
||||
details.
|
||||
|
||||
Use `~/.codex/config.toml` for personal defaults, auth/runtime settings, writable roots, and trust decisions. Avoid checking in absolute-path writable roots or model preferences unless they are intentionally team-wide.
|
||||
|
||||
Use Gitea issues as the canonical backlog and state log. See `docs/GITEA_ISSUES.md` for label setup, TODO import, and Codex issue update commands. Durable docs should describe stable behavior; changing work state belongs on the issue.
|
||||
@@ -81,5 +90,6 @@ PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versi
|
||||
- Avoid broad recursive scans and full builds unless the change warrants them.
|
||||
- Keep generated build/test folders ignored.
|
||||
- Keep optional module behavior behind core registry/capability/module metadata boundaries.
|
||||
- Run `tools/checks/check-manifest-shapes.py` after module behavior or manifest changes so user/admin documentation coverage remains complete.
|
||||
- Create or update Gitea issues for TODOs, follow-ups, blockers, and feature requests instead of keeping local backlog files.
|
||||
- Do not start persistent dev servers unless the user asks.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# GovOPlaN Compatibility Inventory
|
||||
|
||||
This inventory classifies compatibility paths covered by
|
||||
`COMPATIBILITY_POLICY.md`. It is intentionally limited to behavior that changes
|
||||
accepted data, imports, permissions, or migration state. Operational fallbacks
|
||||
such as Redis degradation and language fallback are not compatibility paths.
|
||||
|
||||
## Database Bridges
|
||||
|
||||
| Path | Purpose | Retention | Removal |
|
||||
| --- | --- | --- | --- |
|
||||
| `govoplan_core.db.migrations.reconcile_legacy_create_all_schema` | Reconciles databases created before Alembic ownership was recorded. | At least one major release after runtime aliases are removed. | Review after `1.0`; keep release-baseline tests. |
|
||||
| Migration table/column aliases in `govoplan_core.db.migrations` | Detect and reconcile pre-split table ownership and migration tracks. | All tagged `0.1.x` upgrade origins plus one major release cycle. | Remove only after the corresponding baseline leaves support. |
|
||||
| Access and module migration backfills for legacy permission names | Converts persisted role assignments without dropping authority. | Same as the database upgrade origin that contains the old role. | Keep migrations immutable; remove only runtime expansion at `0.2`. |
|
||||
|
||||
## Portable-Schema Readers
|
||||
|
||||
| Path | Purpose | Retention | Removal |
|
||||
| --- | --- | --- | --- |
|
||||
| `govoplan_core.mail.config.normalize_split_transport_credentials` | Reads pre-split SMTP/IMAP credentials and emits the split representation. | Current and previous two configuration schema versions. | Version-gate once Mail writes an explicit current schema version; reject inputs older than the two-version window. |
|
||||
| `ImapServerConfig.discard_legacy_enabled` | Reads the former nested IMAP `enabled` field without writing it. | Current and previous two configuration schema versions. | Remove with the oldest accepted Mail configuration schema. |
|
||||
| `govoplan_core.core.configuration_packages` readers | Reads explicitly versioned configuration-package manifests. | Current and previous two schema versions. | Retire individual readers as their version leaves the window. |
|
||||
|
||||
## Runtime And API Aliases
|
||||
|
||||
| Path | Purpose | Retention | Removal |
|
||||
| --- | --- | --- | --- |
|
||||
| `govoplan_core.security.scope_aliases.LEGACY_SCOPE_ALIASES` | Expands pre-granular permission names. | Tagged `0.1.x` runtime/API window. | Remove at `0.2` after role backfills and migration notes are verified. |
|
||||
| `govoplan_core.security.module_permissions.LEGACY_TO_MODULE_SCOPES` | Maps pre-module-split scopes to canonical owning-module scopes. | Tagged `0.1.x` runtime/API window. | Remove at `0.2`; keep database migration evidence for one major cycle. |
|
||||
| `govoplan_core.privacy.retention` | Stable import facade delegating policy-owned behavior through a capability. | Tagged `0.1.x` import window. | Remove at `0.2` after all in-tree callers use the policy contract and release notes name the replacement. |
|
||||
| Legacy tenant aliases in API response schemas | Preserves active-tenant response fields used by `0.1.x` clients. | Tagged `0.1.x` API window. | Remove at `0.2` with response-schema migration notes. |
|
||||
| Optional fields in Poll response references | Accepts providers built against the earlier Poll contract. | Tagged `0.1.x` runtime contract window. | Remove or require a new interface version at `0.2`. |
|
||||
| Legacy single-tenant summary providers | Allows modules without the batch provider introduced in `0.1.x`. | Tagged `0.1.x` module contract window. | Remove at `0.2` after manifests advertise the batch provider contract. |
|
||||
| WebUI `react-router-dom` build alias | Resolves tagged `0.1.x` module source imports to Core's single `react-router` runtime so one composition never loads two router contexts. | Tagged `0.1.x` WebUI source window. | Remove at `0.2` after every supported module tag imports `react-router` directly. |
|
||||
|
||||
## Removed Paths
|
||||
|
||||
| Path | Reason | Removed |
|
||||
| --- | --- | --- |
|
||||
| `govoplan_core.core.module_installer._run_restart_command_legacy` | Private wrapper had no callers and never represented a persisted or published contract. | Current development line |
|
||||
| Retired `govoplan_core.api.admin` and pre-split core model imports | In-tree callers and module packages use their owning modules; regression tests prohibit reintroduction. | Before `0.1.10` |
|
||||
|
||||
Every new compatibility path must be added here with its classification,
|
||||
diagnostic, test owner, and planned removal release.
|
||||
@@ -0,0 +1,69 @@
|
||||
# GovOPlaN Compatibility Policy
|
||||
|
||||
This document defines the compatibility window that release tooling, module
|
||||
owners, migration authors, import/export providers, and API maintainers must
|
||||
preserve. It is the source of truth for deciding whether compatibility code can
|
||||
be removed.
|
||||
|
||||
## Database Upgrades
|
||||
|
||||
- A released installation from every tagged `0.1.x` version is a supported
|
||||
database upgrade origin.
|
||||
- The recorded public release-baseline ledger starts at `v0.1.7`; earlier
|
||||
`0.1.x` tags predate production installations. If an earlier tagged database
|
||||
is encountered, the release must provide or document a compatibility bridge
|
||||
instead of silently treating the database as a fresh installation.
|
||||
- Released migration revision IDs and recorded release heads are immutable.
|
||||
- Each release must prove an upgrade from every still-supported recorded
|
||||
baseline, as well as a fresh installation, before its tag is published.
|
||||
- Migration-only reconciliation needed by an old database remains available for
|
||||
at least one subsequent major release cycle after the corresponding runtime
|
||||
compatibility path is removed.
|
||||
|
||||
The release-baseline format and commands are documented in
|
||||
`RELEASE_DEPENDENCIES.md`.
|
||||
|
||||
## Configuration And Export Schemas
|
||||
|
||||
- Writers emit only the current schema version.
|
||||
- Readers accept the current schema version and the previous two schema
|
||||
versions.
|
||||
- Older input is rejected with a diagnostic that identifies its version and the
|
||||
required staged upgrade or conversion path.
|
||||
- A module-owned configuration provider must version its input and output
|
||||
schema explicitly. It must not infer an old schema from missing fields once a
|
||||
versioned schema has shipped.
|
||||
- Round-trip and upgrade tests must cover all three readable versions before a
|
||||
schema change is released.
|
||||
|
||||
This window applies to configuration packages, module-owned exports, and other
|
||||
portable GovOPlaN configuration artifacts. Domain interchange standards with
|
||||
their own compatibility rules remain governed by the owning module.
|
||||
|
||||
## Runtime And API Aliases
|
||||
|
||||
- Compatibility aliases must emit an explicit deprecation diagnostic and point
|
||||
to the supported replacement.
|
||||
- New callers must use the canonical contract. In-tree callers may not add new
|
||||
uses of a deprecated alias.
|
||||
- Runtime imports, request fields, response fields, routes, and scope aliases
|
||||
carried for the `0.1.x` split line are retired at `0.2`, with migration notes.
|
||||
- An alias may be removed earlier only when it never shipped in a tag or when a
|
||||
security fix requires removal. The release notes must state the exception.
|
||||
- Database reconciliation code is not a runtime/API alias and follows the
|
||||
longer database window above.
|
||||
|
||||
## Removal Checklist
|
||||
|
||||
Compatibility code can be removed only when all of the following are true:
|
||||
|
||||
1. The path is inventoried as a database bridge, portable-schema reader, or
|
||||
runtime/API alias.
|
||||
2. Its minimum retention window has elapsed.
|
||||
3. In-tree callers and published module manifests use the replacement.
|
||||
4. Upgrade, import, or API regression tests cover the retained window.
|
||||
5. Diagnostics and migration notes identify any staged action operators must
|
||||
take.
|
||||
|
||||
If one condition is not met, version-gate the compatibility path and record its
|
||||
planned removal release instead of deleting it.
|
||||
@@ -48,6 +48,35 @@ interface = how configured parts connect
|
||||
data = what the operator must provide for this deployment
|
||||
```
|
||||
|
||||
## Package Classes
|
||||
|
||||
The same signed package mechanism supports several explicitly named classes:
|
||||
|
||||
| Class | Purpose |
|
||||
| --- | --- |
|
||||
| `reference` | Prove a bounded journey, degraded states, recovery, and target-environment acceptance. |
|
||||
| `product` | Provide a reusable operating capability such as governed communication, service-to-decision, procurement/contracts, or governed BI. |
|
||||
| `sector` | Specialize terminology, forms, rules, process baselines, controls, reports, and integrations for an institutional sector without forking modules. |
|
||||
| `deployment` | Declare a supported infrastructure topology, operational assumptions, health gates, and recovery evidence. |
|
||||
| `integration` | Declare external systems, provider bindings, source-authority modes, maturity, required health, and reconciliation behavior. |
|
||||
|
||||
Package class is metadata and validation context, not additional authority. A
|
||||
sector package does not become a module and cannot write another module's
|
||||
tables. Packages may extend other packages only through versioned fragments and
|
||||
must preserve provenance and parent constraints.
|
||||
|
||||
The contract enforces class-specific evidence. Reference packages require
|
||||
target, recovery, security, operations, accessibility, privacy, and
|
||||
documentation evidence. Deployment and integration packages require their
|
||||
corresponding target/recovery/operations evidence, while integration packages
|
||||
also name provider authority and minimum-maturity expectations. Preflight
|
||||
blocks a missing, incompatible, or unhealthy provider. A derived package may
|
||||
tighten parent module, capability, and provider requirements but cannot remove
|
||||
or loosen them. Every non-documentation claim made by reference, deployment, or
|
||||
integration packages carries a `sha256:<digest>` binding. Repository checks
|
||||
recompute those hashes, while signed package verification protects the declared
|
||||
manifest during transport.
|
||||
|
||||
## Package Model
|
||||
|
||||
A configuration package should be a signed, portable manifest plus module-owned
|
||||
@@ -68,6 +97,14 @@ Required package metadata:
|
||||
- preflight checks and post-import health checks
|
||||
- migration or transformation rules for older package versions
|
||||
- provenance, export source metadata, and signature metadata
|
||||
- package class and optional parent package/version constraints
|
||||
- source-authority bindings and provider-operation expectations for every
|
||||
external integration used by the package
|
||||
|
||||
Portable configuration schemas follow `COMPATIBILITY_POLICY.md`: providers
|
||||
write only their current schema version and read that version plus the previous
|
||||
two versions. Older input must produce a version-specific staged-upgrade
|
||||
diagnostic.
|
||||
|
||||
Configuration fragments are interpreted only by the module that owns them. For
|
||||
example, workflow imports workflow definitions; forms imports form schemas;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# DataGrid Sizing Contract
|
||||
|
||||
`DataGrid` turns every declared track into a deterministic pixel layout after
|
||||
its container has a measurable width. The same contract is used on initial
|
||||
layout, container resize, persisted-layout restore, and pointer resize.
|
||||
|
||||
## Column Declarations
|
||||
|
||||
- `width: number` or `Npx` is the preferred pixel width.
|
||||
- `width: N%` is a preferred share of the measured container.
|
||||
- `width: Nfr` shares residual width by fraction weight.
|
||||
- `width: minmax(Npx, preferred)` combines a hard lower bound with any
|
||||
supported preferred width.
|
||||
- An omitted width and the legacy `fill` flag are one-fraction flexible tracks.
|
||||
- `minWidth` is a hard floor. Header sort, filter, and resize controls may raise
|
||||
the effective accessible floor.
|
||||
- `maxWidth` bounds direct user growth and free/constrained compensation. In a
|
||||
cover layout it is a preferred maximum: passive tracks may exceed it when
|
||||
that is necessary to keep the table flush with its container.
|
||||
|
||||
## Layout Modes
|
||||
|
||||
| `initialFit` | `resizeBehavior` | Initial layout | Pointer resize |
|
||||
| --- | --- | --- | --- |
|
||||
| `container` | `cover` | Real columns fill the container. Hard-minimum excess scrolls horizontally. | Growth may create horizontal overflow. Shrink first consumes overflow, then grows resizable columns to the right; it stops before underflow. |
|
||||
| `content` | `cover` | After measurement, the same cover invariant applies. | Same as cover above. |
|
||||
| `container` | `constrained` | Real columns fill the container. | Peer compensation respects every hard min/max and stops the active resize when capacity is exhausted. |
|
||||
| `content` | `free` | Declared content widths are retained. | Only the active column changes, so underflow or horizontal overflow is allowed. |
|
||||
| `container` | `free` | The initial layout fills the container. | Later user resizing is free. A right-sticky column promotes this mode to cover so its edge remains stable. |
|
||||
|
||||
Sticky columns do not absorb ordinary cover residuals and are not resize
|
||||
compensation targets. A last resizable column may grow into overflow. It may
|
||||
shrink only by the current overflow, because shrinking farther would require a
|
||||
blank filler track. Dragging farther past that stop does not bank width changes:
|
||||
the column remains stopped until the pointer crosses the same boundary again.
|
||||
|
||||
## Persistence
|
||||
|
||||
Only the pixel layout resulting from an explicit user resize is persisted.
|
||||
Persisted widths are keyed by a signature containing column IDs, declared
|
||||
widths and bounds, resize affordances, sticky placement, initial fit, and resize
|
||||
behavior. A changed signature discards the old override and recomputes the
|
||||
declared layout.
|
||||
|
||||
Container reconciliation is suspended while a pointer drag is active. On
|
||||
release, the already-rendered pixel layout becomes the persisted preference.
|
||||
Reconciliation may grow it to prevent underflow, but never shrinks intentional
|
||||
user overflow, so there is no drag-end snap.
|
||||
|
||||
## Regression Matrix
|
||||
|
||||
`webui/tests/data-grid-sizing.test.ts` covers:
|
||||
|
||||
- pixel, percentage, fraction, `minmax`, omitted, and legacy-fill tracks;
|
||||
- preferred max exhaustion without a synthetic filler column;
|
||||
- hard-minimum horizontal overflow;
|
||||
- fixed-only cover grids;
|
||||
- persisted overrides under growth and viewport pressure;
|
||||
- stale layout signatures;
|
||||
- first and middle-column right-side compensation;
|
||||
- last-resizable-column overflow, underflow stop, and reverse-pointer boundary;
|
||||
- free, cover, and constrained resizing;
|
||||
- cover-expanded tracks that already exceed preferred maxima; and
|
||||
- preservation of the pointer layout across the commit fit.
|
||||
|
||||
`webui/tests/data-grid-actions.test.tsx` also verifies the rendered fixed-cover
|
||||
shape and guards against reintroducing a synthetic buffer cell.
|
||||
@@ -36,7 +36,7 @@ set +a
|
||||
| Setting | Required outside dev | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `APP_ENV` | yes | Runtime profile. Use `prod`, `staging`, or a deployment-specific value outside local development. |
|
||||
| `MASTER_KEY_B64` | yes | Fernet key or base64 encoded 32-byte key used for encrypted module secrets. Rotate through an explicit operator plan. |
|
||||
| `MASTER_KEY_B64` | yes | Fernet key or base64 encoded 32-byte deployment root used for encrypted module secrets and, when enabled, the Encryption module's local server-envelope provider. Rotate only through an explicit provider-aware migration plan. |
|
||||
| `DATABASE_URL` | yes | SQLAlchemy database URL for core and installed modules. SQLite is supported for dev/small installs; PostgreSQL is the preferred production target. |
|
||||
| `ENABLED_MODULES` | yes | Comma-separated startup module set. Keep `tenancy,access` enabled; keep `admin` enabled for operator UI. |
|
||||
|
||||
@@ -57,6 +57,8 @@ PY
|
||||
| `GOVOPLAN_MIGRATION_TRACK` | `release` | Use the release track for normal runtime and deployments. Use `dev` only for fresh/disposable databases that intentionally replay detailed development migrations. |
|
||||
| `DEV_AUTO_MIGRATE_ENABLED` | `true` | Dev convenience only. Production should run migration commands explicitly during deployment. |
|
||||
| `DEV_BOOTSTRAP_ENABLED` | `false` | Dev bootstrap only. `govoplan_core.devserver` and `govoplan/tools/launch/launch-dev.sh` default it to `true`; use controlled first-admin creation outside dev. |
|
||||
| `FIRST_ADMIN_ENROLLMENT_TTL_SECONDS` | `1800` | Lifetime of a locally issued production enrollment credential. Allowed range: 60 seconds to 24 hours. |
|
||||
| `FIRST_ADMIN_ENROLLMENT_FILE` | `/run/govoplan/first-admin-enrollment.json` | Local operator artifact. The command creates it with mode `0600` and never prints the secret. |
|
||||
|
||||
Operator rule: take a database backup before applying migrations or destructive
|
||||
module retirement. For non-SQLite databases, configure deployment-specific
|
||||
@@ -144,8 +146,12 @@ tools/checks/postgres-integration-check.py \
|
||||
```
|
||||
|
||||
The integration check runs migrations and startup smoke checks across the
|
||||
standard module permutations. `--reset-schema` is destructive and belongs only
|
||||
on throwaway databases.
|
||||
standard module permutations. It first requires the retirement atomicity proof,
|
||||
using Files' real secret-owning provider and Audit's persistent recorder. That
|
||||
proof uses only random, test-owned schemas and cleans them afterward; it does
|
||||
not reset `public`. `--reset-schema` is destructive and belongs only on
|
||||
throwaway databases. Do not pass `--skip-retirement-atomicity` when collecting
|
||||
release evidence.
|
||||
|
||||
### Broker And Workers
|
||||
|
||||
@@ -153,13 +159,16 @@ on throwaway databases.
|
||||
| --- | --- | --- |
|
||||
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
|
||||
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
|
||||
| `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,default` | Queue list expected by worker/process manager definitions. The Calendar queue drains durable external-calendar operations. |
|
||||
| `CELERY_QUEUES` | `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default` | Queue list expected by worker/process manager definitions. Keep this aligned with every enabled module task route; Ops reports missing worker queue consumers. |
|
||||
| `CELERY_VISIBILITY_TIMEOUT_SECONDS` | `3600` | Maximum time before Redis may redeliver work left unacknowledged by a lost worker. Set this above the longest supported task duration; changing it requires a worker-loss acceptance drill. |
|
||||
| `PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS` | `8` | Failed durable consumer deliveries are quarantined after this many attempts. |
|
||||
| `PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS` | `90` | Successful event envelopes older than this are removed by the daily retention task. Quarantined evidence is retained. |
|
||||
|
||||
Worker command:
|
||||
|
||||
```bash
|
||||
python -m celery -A govoplan_core.celery_app:celery worker \
|
||||
--queues send_email,append_sent,notifications,calendar,default \
|
||||
--queues send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default \
|
||||
--loglevel INFO
|
||||
```
|
||||
|
||||
@@ -171,6 +180,13 @@ crashes, and expired worker leases:
|
||||
python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
|
||||
```
|
||||
|
||||
Before promoting a worker composition, run the repository worker-runtime drill
|
||||
against the same Redis and Core build. It uses the bounded
|
||||
`govoplan.worker.acceptance` task and records publish/consume, retry, warm
|
||||
SIGTERM, and worker-loss redelivery evidence without accessing tenant data.
|
||||
Production evidence must use the deployed queue configuration and a visibility
|
||||
timeout that is longer than every supported business task.
|
||||
|
||||
### Storage
|
||||
|
||||
| Setting | Default | Notes |
|
||||
@@ -183,6 +199,11 @@ python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
|
||||
| `FILE_STORAGE_S3_ACCESS_KEY_ID` | empty | Secret; inject through deployment environment. |
|
||||
| `FILE_STORAGE_S3_SECRET_ACCESS_KEY` | empty | Secret; inject through deployment environment. |
|
||||
| `FILE_STORAGE_S3_BUCKET` | `files` | Managed-file object bucket. |
|
||||
| `FILE_STORAGE_S3_DEPLOYMENT_MANAGED` | `false` | Reserved for the installer-owned `http://garage:3900` service. It does not authorize arbitrary internal or external S3 endpoints. |
|
||||
| `FILE_ARCHIVE_MAX_ENTRIES` | `10000` | Maximum declared entries accepted during archive preview and confirmation. |
|
||||
| `FILE_ARCHIVE_MAX_EXPANDED_BYTES` | `2147483648` | Maximum actual expanded bytes across selected archive content. |
|
||||
| `FILE_ARCHIVE_MAX_EXPANSION_RATIO` | `100` | Maximum expanded-to-compressed archive ratio. |
|
||||
| `FILE_ARCHIVE_PREVIEW_TTL_SECONDS` | `1800` | Lifetime of the sealed actor/archive/destination-bound preview token. |
|
||||
|
||||
Legacy `S3_*` settings remain for older storage paths but new deployments should
|
||||
prefer `FILE_STORAGE_*`.
|
||||
@@ -205,9 +226,13 @@ prefer `FILE_STORAGE_*`.
|
||||
Interactive password login is enabled with fixed-window limits of 10 failures
|
||||
per normalized identity and 100 failures per direct client over 900 seconds.
|
||||
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
|
||||
when Redis is reachable so replicas share state; a bounded process-local
|
||||
fallback keeps development and Redis outages functional, with per-process
|
||||
enforcement until Redis recovers.
|
||||
when Redis is reachable so replicas share state. Production-like startup fails
|
||||
when throttling is enabled without `REDIS_URL`. Set
|
||||
`GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` only as an explicit
|
||||
single-process risk acceptance. A bounded process-local fallback keeps
|
||||
development and temporary Redis outages functional, with per-process
|
||||
enforcement until Redis recovers; monitor Redis because protection is weaker
|
||||
during that fallback.
|
||||
|
||||
### Outbound Connector Egress
|
||||
|
||||
@@ -277,8 +302,34 @@ configuration, not the core runtime contract. Store them in a local ignored
|
||||
3. Build the WebUI from `webui/package.release.json` or deploy a prebuilt
|
||||
artifact from the same release tag.
|
||||
4. Run database migrations with the target `DATABASE_URL`.
|
||||
5. Create the first tenant and system owner through the controlled bootstrap or
|
||||
one-time admin command for the deployment.
|
||||
5. Create the first tenant and system owner through the controlled bootstrap:
|
||||
|
||||
```bash
|
||||
python -m govoplan_core.commands.first_admin status
|
||||
python -m govoplan_core.commands.first_admin issue \
|
||||
--reason "initial production installation"
|
||||
```
|
||||
|
||||
The issue command fails when an active system administrator already exists,
|
||||
writes the random credential only to `FIRST_ADMIN_ENROLLMENT_FILE`, and does
|
||||
not print it. Check `GET /api/v1/bootstrap/status`, then submit the account
|
||||
and initial tenant fields to `POST /api/v1/bootstrap/first-admin` with the
|
||||
secret in `X-GovOPlaN-Enrollment-Token`. The operation creates the protected
|
||||
system owner and initial tenant-owner membership in one transaction and
|
||||
retires the credential. A repeated identical request returns the same result
|
||||
without creating another owner.
|
||||
|
||||
If the artifact is lost or expires before use, a local operator may rotate
|
||||
it only while no durable system administrator exists:
|
||||
|
||||
```bash
|
||||
python -m govoplan_core.commands.first_admin recover \
|
||||
--reason "expired installation handoff"
|
||||
```
|
||||
|
||||
Issue and recovery write hash-chained Core evidence and an audit event. They
|
||||
never enable or reuse `DEV_BOOTSTRAP_ENABLED`, `DEV_BOOTSTRAP_PASSWORD`, or
|
||||
`DEV_BOOTSTRAP_API_KEY`.
|
||||
6. Start the API service with `govoplan_core.server.app:app`.
|
||||
7. Start workers when `CELERY_ENABLED=true`.
|
||||
8. Start the WebUI/reverse proxy and verify CORS/cookie settings.
|
||||
@@ -328,7 +379,7 @@ the checked in `.env.example`. It runs:
|
||||
- explicit `ENABLED_MODULES`
|
||||
- explicit migrations and `--with-dev-data` bootstrap
|
||||
- API via the module-aware devserver
|
||||
- a Celery worker for `send_email,append_sent,notifications,calendar,default`
|
||||
- a Celery worker for `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default`
|
||||
- WebUI through the Vite dev server
|
||||
- durable local files under `runtime/production-like/files`
|
||||
|
||||
@@ -408,6 +459,14 @@ SQLite's backup API; non-SQLite databases require
|
||||
`--database-backup-command`, `--database-restore-check-command`, and
|
||||
`--database-restore-command`.
|
||||
|
||||
Every non-dry run also owns the database-fenced
|
||||
`core:module-lifecycle:deployment` recovery operation. The run record includes
|
||||
its operation id and status. A supervised run reaches durable `succeeded` only
|
||||
after restart and health verification. `recovery_required` or `outcome_unknown`
|
||||
blocks another lifecycle mutation until the recorded operation is reconciled;
|
||||
do not bypass this by deleting `install.lock`. See
|
||||
[`MODULE_LIFECYCLE_RECOVERY.md`](MODULE_LIFECYCLE_RECOVERY.md).
|
||||
|
||||
Database hook commands receive:
|
||||
|
||||
- `GOVOPLAN_INSTALLER_RUN_DIR`
|
||||
@@ -447,7 +506,9 @@ Run the rollback drill before relying on installer automation in a new
|
||||
environment:
|
||||
|
||||
```bash
|
||||
/mnt/DATA/git/govoplan/tools/checks/module-installer-rollback-drill.py --format json
|
||||
/mnt/DATA/git/govoplan/tools/checks/module-installer-rollback-drill.py \
|
||||
--format json \
|
||||
--evidence-path runtime/module-installer/restore-drill-evidence.json
|
||||
```
|
||||
|
||||
The drill uses temporary SQLite databases and simulated package commands. It
|
||||
@@ -468,7 +529,7 @@ checks, catalog trust, signing, keyring, replay, and license operation.
|
||||
## Operator Checklist
|
||||
|
||||
- Runtime secrets are injected outside git.
|
||||
- `MASTER_KEY_B64` is set and backed up securely.
|
||||
- `MASTER_KEY_B64` is set and backed up securely; restores of locally encrypted content fail closed without the exact matching key.
|
||||
- Database backup and restore commands are tested.
|
||||
- File/object storage is durable and backed up.
|
||||
- `CORS_ORIGINS` and cookie settings match the deployed WebUI origin.
|
||||
|
||||
@@ -9,12 +9,17 @@ operator, and roadmap pages.
|
||||
| Topic | Canonical document | Notes |
|
||||
| --- | --- | --- |
|
||||
| Module architecture and kernel contracts | `MODULE_ARCHITECTURE.md` | Stable module contracts, API efficiency contracts, durable boundary decisions, lifecycle, and WebUI contribution rules. |
|
||||
| Compatibility policy | `COMPATIBILITY_POLICY.md` | Supported database upgrade origins, portable-schema read/write windows, runtime/API alias retirement, and compatibility-code removal criteria. |
|
||||
| RBAC and resource access | `ACCESS_RBAC_MODEL.md` | Current permission, role, API-key, and resource-access model. |
|
||||
| Governance hierarchy | `GOVERNANCE_MODEL.md` | System, tenant, user/group, campaign policy inheritance and admin UI structure. |
|
||||
| Policy decision DTOs and provenance | `POLICY_CONTRACTS.md` | Shared explain/provenance shape; module-specific policy docs should link here. |
|
||||
| Events and audit trace context | `EVENTS_AND_AUDIT.md` | Event dispatch semantics and audit payload conventions. |
|
||||
| Action/effect automation layer | `ACTION_EFFECT_AUTOMATION_LAYER.md` | Action/effect contracts, consequence preview, runner semantics, and module boundary for automation. |
|
||||
| External references and integration maturity | `EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md` | Stable external identity and cumulative connector maturity; configured source authority is defined by the meta target architecture. |
|
||||
| Institutional context and governed references | `INSTITUTIONAL_CONTEXT_CONTRACT.md` | Shared temporal, actor/representation, institution, mandate, service, party, decision, evidence, legal-basis, information-governance, presentation, and geo DTO/provider contracts. |
|
||||
| Postbox E2EE target architecture | `POSTBOX_E2EE_ARCHITECTURE.md` | Strategic encrypted postbox/mailbox model, key ownership, role mailbox semantics, and retraction limits. |
|
||||
| Shared state, runtime coordination, and recovery | `STATE_AND_RECOVERY_CONTRACT.md` | State profiles, object storage, node registration/drain, fenced leases, migration ordering, and recovery evidence. |
|
||||
| Module lifecycle recovery | `MODULE_LIFECYCLE_RECOVERY.md` | Installer/live-graph recovery modes, deployment fence, evidence, retry blocking, and operator reconciliation. |
|
||||
|
||||
## Release And Operations
|
||||
|
||||
@@ -25,13 +30,16 @@ operator, and roadmap pages.
|
||||
| Release dependencies and catalogs | `RELEASE_DEPENDENCIES.md` | Release package refs, migration baselines, release lockfiles, catalog trust/licensing, catalog publishing, and release checklist. |
|
||||
| Dependency vulnerability audits | `DEPENDENCY_AUDITS.md` | Local and CI audit commands plus dated audit result notes. |
|
||||
| Remote WebUI bundle design | `REMOTE_WEBUI_BUNDLES.md` | Experimental controlled-deployment design; normal releases still use package builds. |
|
||||
| WebUI loading and bundle budgets | `WEBUI_BUNDLE_BUDGETS.md` | Installed-module lazy boundaries, enforced initial/async budgets, and baseline measurements. |
|
||||
|
||||
## Product And Module Planning
|
||||
|
||||
| Topic | Canonical document | Notes |
|
||||
| --- | --- | --- |
|
||||
| Product roadmap and module routing | `GOVOPLAN_MASTER_ROADMAP.md` | Product-level sequencing, implementation gates, issue routing, and missing-module decisions. |
|
||||
| Institutional governance target | `govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md` | Cross-product semantic layers, source-authority modes, candidate Mandates/Services/Parties/Decisions boundaries, and migration sequence. |
|
||||
| UI/UX decisions | `UI_UX_DECISION_LEDGER.md` | Binding guided-UI decisions, open decisions, impact index, and review checklist. |
|
||||
| Core interface migration | `INTERFACE_PATTERN_MIGRATION.md` | Core-owned settings, credential, retention, lifecycle, and shared-component evidence for the product pattern language. |
|
||||
| Interface ethics and design doctrine | `INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md` | Product-level doctrine for context, decision, consequence, contestability, responsibility, and traceability. |
|
||||
| Public-sector integration posture | `PUBLIC_SECTOR_INTEGRATION_STRATEGY.md` | Strategy index; executable target inventory lives in `govoplan-connectors`. |
|
||||
| Configuration packages | `CONFIGURATION_PACKAGES.md` | Package model, provider contract, import/export flow, and tracking slices. |
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Durable Recovery Operations
|
||||
|
||||
Modules must use `begin_durable_recovery_operation` for work whose effects can
|
||||
outlive the caller's SQLAlchemy transaction. The helper commits the canonical
|
||||
request hash, recovery plan, precondition evidence, running state, and lease
|
||||
fence before the caller mutates object storage, a queue, a filesystem, or an
|
||||
external provider.
|
||||
|
||||
Each later checkpoint is written through an independent database session. A
|
||||
business-transaction rollback therefore cannot erase evidence of an earlier
|
||||
effect. Successful completion requires concrete verification checks and a valid
|
||||
hash chain. Compensation likewise records recovery-required, recovering, and
|
||||
verified-recovered checkpoints rather than reporting an ordinary failure.
|
||||
A definitive pre-effect or provider rejection records terminal `rejected`
|
||||
evidence instead of being mislabeled as success, atomic rollback, or recovery
|
||||
work.
|
||||
|
||||
If a runtime disappears, another runtime may claim the operation only after the
|
||||
lease expires. The takeover records both fences. A stale compensatable operation
|
||||
becomes recovery-required; a stale forward-only or irreversible external effect
|
||||
becomes outcome-unknown; a database-only atomic operation is recorded failed
|
||||
because its transaction rolled back. Takeover never re-executes the original
|
||||
request automatically.
|
||||
|
||||
Evidence and metadata may contain opaque references, digests, counts, and
|
||||
provider result codes. They must never contain credentials or resolved secrets.
|
||||
Ops is the platform surface for unresolved operation status; owning modules must
|
||||
provide the reconciliation action and business-level explanation.
|
||||
|
||||
Database-only operations must use the durable handle's atomic terminal methods
|
||||
when their module rows and final recovery checkpoint belong to one invariant.
|
||||
Those methods stage the terminal checkpoint and lease release in the caller's
|
||||
SQLAlchemy transaction, then commit the domain rows and recovery evidence
|
||||
together. A failed commit rolls both back and leaves the previously durable
|
||||
`running` record available for stale-fence handling; modules must not commit
|
||||
their domain state first and close an `atomic` recovery record afterwards.
|
||||
|
||||
An owning module may reconcile an `outcome_unknown` provider effect through the
|
||||
claimed durable handle's `resolve_unknown` method. External evidence that the
|
||||
effect occurred records verified success. Evidence that it did not occur moves
|
||||
the operation through recovery-required and recovering to verified recovered,
|
||||
so any later attempt must use a new deliberate idempotency key. The method does
|
||||
not infer provider state and requires the same terminal verification structure
|
||||
and hash-chain checks as ordinary completion.
|
||||
+27
-19
@@ -8,25 +8,32 @@ module reactions, and operator diagnostics.
|
||||
|
||||
## Production Transport Decision
|
||||
|
||||
The first production target is a **database outbox plus in-process immediate
|
||||
dispatch**:
|
||||
The production transport is a **transactional database outbox plus retrying
|
||||
dispatcher**:
|
||||
|
||||
- Use `govoplan_core.core.events.PlatformEvent` for domain and platform events.
|
||||
- Use `EventBus` as the in-process dispatch contract for same-process module
|
||||
reactions that are safe to run inline.
|
||||
- Call `emit_platform_event(session, event)` to bind event delivery to the
|
||||
domain transaction.
|
||||
- The optional `platform.eventOutbox` capability persists events atomically.
|
||||
The Audit module provides the current SQL implementation.
|
||||
- Without an outbox provider, Core publishes to `EventBus` only after the outer
|
||||
transaction commits. This preserves reduced installations but is not durable
|
||||
across process failure or multiple workers.
|
||||
- Use `EventBus` as the in-process dispatch contract for module reactions
|
||||
invoked by the outbox dispatcher or for non-critical fallback reactions.
|
||||
- Use the shared `audit_event` / `audit_from_principal` helper for audited
|
||||
module actions. The helper persists the audit row and immediately publishes a
|
||||
module actions. The helper persists the audit row and transactionally emits a
|
||||
governed `PlatformEvent` whose `type` is the audit action.
|
||||
- Use `record_change` for module delta feeds. It persists the change-sequence
|
||||
row and immediately publishes a generic module change event such as
|
||||
row and transactionally emits a generic module change event such as
|
||||
`mail.profile.updated`.
|
||||
- Persist durable integration/workflow events through a database outbox before
|
||||
acknowledging the state change that produced them.
|
||||
- Drain the outbox through a small dispatcher process. The dispatcher may call
|
||||
in-process handlers in the same deployment first, but its storage contract is
|
||||
database-backed.
|
||||
- Drain the outbox with the Celery `govoplan.events.dispatch_outbox` task on the
|
||||
`events` queue. The periodic schedule also retries pending rows.
|
||||
- The dispatcher invokes Dataflow event ingestion when that capability is
|
||||
active, then publishes to the process-local bus.
|
||||
- Treat Redis/Celery as worker/job infrastructure, not as the authoritative
|
||||
first event transport. A Celery dispatcher can consume the outbox later.
|
||||
first event transport. PostgreSQL remains authoritative until dispatch is
|
||||
recorded.
|
||||
- Keep the dispatch implementation pluggable behind the `PlatformEvent`
|
||||
envelope so a future message broker can be added without changing event
|
||||
producers.
|
||||
@@ -44,17 +51,18 @@ Event producers should write their domain state and outbox event in the same
|
||||
database transaction wherever possible. Handlers must be idempotent because the
|
||||
outbox dispatcher can retry after a crash or timeout.
|
||||
|
||||
Recommended first outbox columns:
|
||||
The current outbox stores:
|
||||
|
||||
- `event_id`, `event_type`, `module_id`
|
||||
- `correlation_id`, `causation_id`
|
||||
- `payload`, `occurred_at`
|
||||
- `available_at`, `attempt_count`, `claimed_at`, `claim_token`
|
||||
- `processed_at`, `last_error`
|
||||
- `classification`, serialized event `payload`
|
||||
- `status`, `attempts`, `next_attempt_at`
|
||||
- `dispatched_at`, `last_error`, timestamps
|
||||
|
||||
Inline `EventBus` handlers are allowed only for non-critical local reactions.
|
||||
Anything that must survive process failure, restart, package update, or worker
|
||||
redeployment belongs in the outbox.
|
||||
Handlers must be idempotent: a worker may complete an external effect and fail
|
||||
before marking its outbox row dispatched. Anything that must survive process
|
||||
failure, restart, package update, or worker redeployment requires the outbox
|
||||
provider and dispatcher.
|
||||
|
||||
## Trace IDs
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# External References And Integration Maturity
|
||||
|
||||
GovOPlaN integrations use a shared external-reference contract instead of
|
||||
storing connector-specific URLs and identifiers in every module.
|
||||
|
||||
An external reference identifies an object by:
|
||||
|
||||
- external system instance
|
||||
- object type
|
||||
- stable external object ID
|
||||
- optional connector configuration
|
||||
- canonical HTTP(S) URL without embedded credentials
|
||||
- optional source version, ETag, observation time, and non-secret metadata
|
||||
|
||||
The identity key is `system:object_type:object_id`. A GovOPlaN object may retain
|
||||
multiple references, but one reference must never silently change its identity.
|
||||
Moving or escalating work creates a new object and an explicit relationship; it
|
||||
does not rewrite either object's history.
|
||||
|
||||
## Integration Maturity
|
||||
|
||||
Maturity is cumulative:
|
||||
|
||||
1. `discover`: identify configured external systems and their health.
|
||||
2. `link`: retain and open stable external references.
|
||||
3. `search`: include authorized external objects in GovOPlaN search.
|
||||
4. `read`: display authoritative external content.
|
||||
5. `publish`: create or update external content from GovOPlaN.
|
||||
6. `synchronize`: reconcile changes in both directions with conflict handling.
|
||||
7. `migrate`: perform a governed, verifiable transfer into GovOPlaN.
|
||||
8. `replace`: provide the native operational capability without the external tool.
|
||||
|
||||
Connectors must declare and document the maturity they actually implement.
|
||||
`synchronize` requires durable cursors, idempotency, provenance, conflict
|
||||
handling, deletion semantics, and observable failures. A link-only connector
|
||||
must not imply that GovOPlaN holds an authoritative copy.
|
||||
|
||||
## Source Authority Is A Separate Dimension
|
||||
|
||||
Integration maturity states what an adapter is capable of doing. It does not
|
||||
decide which system owns truth for a configured object or field group. A
|
||||
binding separately selects one of the source-authority modes defined by the
|
||||
[institutional governance target architecture](../../govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md):
|
||||
|
||||
- `native_authoritative`
|
||||
- `external_authoritative`
|
||||
- `external_mirror`
|
||||
- `governed_sync`
|
||||
- `governance_overlay`
|
||||
- `linked_reference`
|
||||
|
||||
A connector can therefore support `synchronize` while a tenant deliberately
|
||||
uses it only as an external mirror. Conversely, a native GovOPlaN object may
|
||||
retain link-only references to several external systems. Authority may be
|
||||
narrowed by tenant, organization, service, object type, object, field group, or
|
||||
process step and must be visible in provenance and configuration preflight.
|
||||
|
||||
## Domain Ownership
|
||||
|
||||
- Domain modules own native GovOPlaN objects and their authorization.
|
||||
- Connectors own protocols, credentials, discovery, transport, and sync state.
|
||||
- Search owns indexing and result aggregation, but source modules remain
|
||||
responsible for authorization.
|
||||
- Core owns only the stable DTOs and extension contracts.
|
||||
|
||||
The Python contract is
|
||||
`govoplan_core.core.external_references.ExternalObjectReference`.
|
||||
+110
-95
@@ -10,12 +10,15 @@ gates. Issues are the active backlog; this document is durable architecture
|
||||
planning context and should be mirrored to the Gitea wiki.
|
||||
|
||||
The meta repository's
|
||||
[Connected Governance Platform Roadmap](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
||||
[Connected Governance Platform Roadmap](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
||||
describes the corresponding cross-product stakeholder visions, configurable
|
||||
service and operating configurations, connected outcome stories, and
|
||||
capability horizons. The selected five-stage delivery sequence and its gates
|
||||
are in the meta repository's
|
||||
[Reference Journey Program](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
|
||||
[Reference Journey Program](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
|
||||
The semantic target, source-authority modes, and reconciliation with the
|
||||
implemented platform are in the meta repository's
|
||||
[Institutional Governance Target Architecture](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
|
||||
Those product documents are canonical; this Core roadmap remains their
|
||||
technical sequencing and module-routing companion.
|
||||
|
||||
@@ -64,8 +67,9 @@ verify or reverse those effects.
|
||||
`INTERFACE_ETHICS_AND_DESIGN_DOCTRINE.md`.
|
||||
- Automation must use governed action/effect contracts, not hidden side
|
||||
effects. The first automation layer is defined in
|
||||
`ACTION_EFFECT_AUTOMATION_LAYER.md` and should start in `govoplan-workflow`
|
||||
unless a separate automation module becomes justified.
|
||||
`ACTION_EFFECT_AUTOMATION_LAYER.md`; its first runner lives in
|
||||
`govoplan-workflow-engine`. Create a separate automation module only if the
|
||||
scheduler/action runtime outgrows workflow coordination.
|
||||
- Encrypted postboxes are a strategic target. Early postbox, access, and
|
||||
identity-trust contracts should stay compatible with the E2EE architecture in
|
||||
`POSTBOX_E2EE_ARCHITECTURE.md`.
|
||||
@@ -144,8 +148,9 @@ pattern exists.
|
||||
| Structured forms and validation | `govoplan-forms` |
|
||||
| Uploaded files and managed storage | `govoplan-files` |
|
||||
| Case record and lifecycle | `govoplan-cases` |
|
||||
| Workflow transitions and automation | `govoplan-workflow` |
|
||||
| Action/effect catalogue and automation runner | first `govoplan-workflow`; possible future `govoplan-automation` if it outgrows workflow |
|
||||
| Workflow runtime, transitions, and automation | `govoplan-workflow-engine` |
|
||||
| Workflow definition editing | optional `govoplan-workflow` |
|
||||
| Action/effect catalogue and automation runner | first `govoplan-workflow-engine`; possible future `govoplan-automation` only if it outgrows workflow |
|
||||
| Internal work queues and tasks | `govoplan-tasks` |
|
||||
| Appointment proposals and booking | `govoplan-appointments`, `govoplan-calendar` |
|
||||
| Postbox, email, and notifications | `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications` |
|
||||
@@ -153,13 +158,18 @@ pattern exists.
|
||||
| Organizational structures, units, and functions | `govoplan-organizations` |
|
||||
| Identity-to-function assignments and directory synchronization | `govoplan-idm` |
|
||||
| Identity trust, device keys, and encrypted postbox key contracts | `govoplan-identity-trust`, `govoplan-access`, `govoplan-postbox` |
|
||||
| Service directory/catalog | `govoplan-portal` |
|
||||
| Service directory presentation | `govoplan-portal` |
|
||||
| Versioned institutional service definition | shared service contract first; candidate `govoplan-services` after reuse proof |
|
||||
| Mandate, jurisdiction, and institutional authority | shared mandate contract first; candidate `govoplan-mandates` after reuse proof |
|
||||
| Procedure-local parties and representation | shared party contract first; candidate `govoplan-parties` after reuse proof |
|
||||
| Formal institutional decisions | shared decision contract first; candidate `govoplan-decisions` after reuse proof |
|
||||
| Permit/document generation | `govoplan-templates`, `govoplan-dms` |
|
||||
| Payment capture and accounting handoff | `govoplan-payments`, `govoplan-ledger` |
|
||||
| Authentication projection, roles, permissions, acting context | `govoplan-access` |
|
||||
| Tenants, policy, and audit | `govoplan-tenancy`, `govoplan-policy`, `govoplan-audit` |
|
||||
| External software integration | `govoplan-connectors` |
|
||||
| Recurring extraction and transformation | possible future `govoplan-datasources`, possible future `govoplan-dataflow` |
|
||||
| Governed data/register catalogue | `govoplan-datasources` |
|
||||
| Recurring extraction and transformation | `govoplan-dataflow` over Datasources and Connectors contracts |
|
||||
| Reports, BI, and management visibility | `govoplan-reporting` |
|
||||
|
||||
## Configuration And Safety Target
|
||||
@@ -204,8 +214,9 @@ an editor applies a high-impact configuration change.
|
||||
|
||||
## Reference Journeys
|
||||
|
||||
The active sequence is selected. Workflow remains deliberately deferred and is
|
||||
not a dependency of these journeys.
|
||||
The active sequence is selected. Workflow Engine and its optional editor are
|
||||
now available foundations, but a reference journey does not depend on Workflow
|
||||
unless its package explicitly composes and proves it.
|
||||
|
||||
### Journey 1: Campaign Demonstration Composition
|
||||
|
||||
@@ -256,9 +267,11 @@ The result must preserve official-key mappings, organizational and reporting
|
||||
date semantics, quality findings, quarantine/replay, transparent calculation,
|
||||
and reproducible promotion between development, test, and production.
|
||||
|
||||
Reporting consumes the product. Create `govoplan-datasources` or
|
||||
`govoplan-dataflow` only after the concrete path proves repeated ownership that
|
||||
does not belong to connectors, Reporting, or the producing domain module.
|
||||
Reporting consumes the product. Datasources owns the governed source and
|
||||
materialization lifecycle; Dataflow owns typed transformation/run lineage;
|
||||
Connectors owns external transport. The concrete path must now prove those
|
||||
implemented boundaries and expose any missing contracts instead of recreating
|
||||
them inside Reporting or a producing domain module.
|
||||
|
||||
### Journey 5: Collaborative Document Lifecycle
|
||||
|
||||
@@ -336,8 +349,9 @@ Create or refine in this order:
|
||||
access.
|
||||
4. `govoplan-cases`: case record, status, assignments, deadlines, and case
|
||||
evidence.
|
||||
5. `govoplan-workflow`: state machine, transitions, commands, and module
|
||||
handoff.
|
||||
5. `govoplan-workflow-engine`: state machine, transitions, commands, module
|
||||
handoff, and resumable execution; optional `govoplan-workflow` supplies the
|
||||
editor.
|
||||
6. `govoplan-tasks`: work queues, assignments, due dates, and follow-ups.
|
||||
7. `govoplan-templates`: permit/decision document generation.
|
||||
8. `govoplan-postbox`, `govoplan-mail`, `govoplan-notifications`: applicant and
|
||||
@@ -412,7 +426,7 @@ Goal: cover internal support and public issue reporting.
|
||||
|
||||
Create or refine in this order:
|
||||
|
||||
1. `govoplan-issue-reporting`: public/internal reports, categories, intake,
|
||||
1. `govoplan-tickets`: public/internal reports, requests, incidents, queues,
|
||||
location, evidence, and triage.
|
||||
2. `govoplan-helpdesk`: service desk tickets, queues, SLAs, assignments,
|
||||
escalation, and resolution evidence.
|
||||
@@ -526,31 +540,34 @@ Refine:
|
||||
dashboard data.
|
||||
- `govoplan-search`: permissioned cross-module discovery.
|
||||
|
||||
Create only when justified:
|
||||
Refine the existing owners:
|
||||
|
||||
- `govoplan-datasources`: source catalog, connection profiles, schema discovery,
|
||||
freshness, provenance.
|
||||
- `govoplan-dataflow`: transformations, validation, lineage, scheduled runs,
|
||||
publication outputs.
|
||||
- `govoplan-projects`: only if OpenProject connectors and existing cases/tasks
|
||||
cannot cover the required semantics.
|
||||
- `govoplan-datasources`: governed data/register catalog, live/cached/static
|
||||
sources, staging, immutable materializations, freshness, quality, legal and
|
||||
organizational context, and provenance. Connector profiles and credentials
|
||||
remain in Connectors.
|
||||
- `govoplan-dataflow`: typed transformations, validation, lineage, manual,
|
||||
scheduled and event-triggered runs, reusable definitions, and publication
|
||||
outputs.
|
||||
- `govoplan-projects`: native projects, portfolios, milestones, goals,
|
||||
dependencies, capacity, outcomes, and external OpenProject references;
|
||||
Connectors owns OpenProject transport and synchronization.
|
||||
|
||||
Reference journey: monthly data extraction, transformation, validation, approval,
|
||||
publication, and reporting.
|
||||
|
||||
Recurring extraction/transformation should start as a configuration package
|
||||
across connectors, files, workflow, reporting, and templates. The package should
|
||||
register sources, declare schemas, define mapping/validation versions, schedule
|
||||
runs, produce previewable diffs, write governed outputs, and preserve lineage,
|
||||
hashes, operator actions, and audit evidence. Create `govoplan-datasources` or
|
||||
`govoplan-dataflow` only after this work exposes repeated contracts that do not
|
||||
belong to existing modules.
|
||||
Recurring extraction/transformation should be delivered as a configuration
|
||||
package across Connectors, Datasources, Dataflow, Workflow Engine, Reporting,
|
||||
Files, and Templates. The package should register sources, declare schemas,
|
||||
define mapping/validation versions, schedule runs, produce previewable diffs,
|
||||
write governed outputs, and preserve lineage, hashes, operator actions, and
|
||||
audit evidence.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- connector catalog exists before building many adapters
|
||||
- dataflow is created only after recurring transformation becomes product
|
||||
behavior
|
||||
- datasource and dataflow ownership remains provider-neutral and is proved by
|
||||
the recurring transformation package
|
||||
- reporting consumes governed sources with provenance
|
||||
|
||||
## Implementation Gates
|
||||
@@ -591,23 +608,25 @@ in the capability waves:
|
||||
4. Implement one data-backed Templates/Reporting path and safe HIS-style deep
|
||||
launch.
|
||||
5. Extend that concrete source into one governed university analytical data
|
||||
product before generalizing data-source or dataflow ownership.
|
||||
product and use it to harden the existing Datasources/Dataflow ownership,
|
||||
quality, lineage, and promotion contracts.
|
||||
6. Implement Files-backed DMS versions and one provider-neutral collaborative
|
||||
editing lifecycle, then connect Records handoff.
|
||||
7. Maintain already integrated Calendar/Scheduling/Poll and other foundations;
|
||||
activate another capability cluster only when the current journey needs it
|
||||
or the product roadmap explicitly reprioritizes it.
|
||||
8. Resume Workflow only by explicit product decision and constrain it with
|
||||
stable actions from one demonstrated package.
|
||||
8. Extend Workflow Engine and the optional editor only through stable actions
|
||||
and one demonstrated package at a time.
|
||||
|
||||
## Deliberate Deferrals
|
||||
|
||||
Defer these until a reference journey proves the need:
|
||||
|
||||
- full ERP replacement
|
||||
- native project management beyond connector support
|
||||
- an unbounded general-purpose dataflow platform; the bounded governed BI
|
||||
reference journey is selected
|
||||
- unsupported breadth in native project management before the Projects/OpenProject
|
||||
boundary is proved in a reference journey
|
||||
- unbounded Dataflow operators or execution engines without golden-flow,
|
||||
quality, lineage, resource-limit, and recovery evidence
|
||||
- every possible public-sector protocol adapter
|
||||
- rich LMS behavior beyond training administration
|
||||
- full qualified digital signing/trust services beyond the identity-trust and
|
||||
@@ -625,73 +644,69 @@ repositories or to explicit missing-module decisions.
|
||||
|
||||
| Idea | Owner | Tracking |
|
||||
| --- | --- | --- |
|
||||
| Government operations backbone reference model | `govoplan-core` | `add-ideas/govoplan-core#213` |
|
||||
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `add-ideas/govoplan-core#214` |
|
||||
| Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `add-ideas/govoplan-core#218` |
|
||||
| Access as a module | `govoplan-access` | `add-ideas/govoplan-access#7` |
|
||||
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `add-ideas/govoplan-core#227` |
|
||||
| Action/effect automation layer | first `govoplan-workflow`; possible future `govoplan-automation` | `add-ideas/govoplan-workflow#1` |
|
||||
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `add-ideas/govoplan-postbox#15`, `add-ideas/govoplan-identity-trust#1` |
|
||||
| Identity, account, function, role, right semantic model | `govoplan-access` | `add-ideas/govoplan-access#9` |
|
||||
| Role-based service directory/catalog | `govoplan-portal` | `add-ideas/govoplan-portal#1` |
|
||||
| Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `add-ideas/govoplan-tasks#1`, `add-ideas/govoplan-notifications#1` |
|
||||
| OpenProject API / project management connector | `govoplan-connectors` | `add-ideas/govoplan-connectors#1` |
|
||||
| Native project-management module decision | connector-first through `govoplan-connectors`; no native project module yet | `add-ideas/govoplan-core#196`, `add-ideas/govoplan-connectors#1` |
|
||||
| Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `add-ideas/govoplan-core#197` |
|
||||
| Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `add-ideas/govoplan-core#198` |
|
||||
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `add-ideas/govoplan-core#216` |
|
||||
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-templates#1` |
|
||||
| Reporting and BI | `govoplan-reporting`, separate from templates | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-reporting#1` |
|
||||
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `add-ideas/govoplan-files#15` |
|
||||
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `add-ideas/govoplan-core#191`, `add-ideas/govoplan-connectors#2` |
|
||||
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `add-ideas/govoplan-core#215` |
|
||||
| Cases module concept | `govoplan-cases` | `add-ideas/govoplan-core#174` |
|
||||
| Workflow module concept | `govoplan-workflow` | `add-ideas/govoplan-core#175` |
|
||||
| Connectors module concept | `govoplan-connectors` | `add-ideas/govoplan-core#176` |
|
||||
| Adrema-style address and distribution-list management | `govoplan-addresses` | `add-ideas/govoplan-addresses#1` |
|
||||
| Consume sources and become a governed source | `govoplan-connectors` plus possible future `govoplan-dataflow` | `add-ideas/govoplan-connectors#3`, `add-ideas/govoplan-core#198` |
|
||||
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `add-ideas/govoplan-connectors#6` |
|
||||
| Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-scheduling#1` |
|
||||
| Terminplaner and calendar primitives | `govoplan-calendar` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-calendar#1` |
|
||||
| Terminbuchung appointment booking | `govoplan-appointments` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-appointments#1` |
|
||||
| Collaborative documents | `govoplan-dms` | `add-ideas/govoplan-dms#1` |
|
||||
| Forms | `govoplan-forms` for definitions and `govoplan-forms-runtime` for submissions/runtime behavior | `add-ideas/govoplan-core#194`, `add-ideas/govoplan-forms#1` |
|
||||
| RSS consume and emit | `govoplan-connectors` | `add-ideas/govoplan-connectors#4` |
|
||||
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `add-ideas/govoplan-idm#1` |
|
||||
| OpenDesk stack integration map | integration profile across IDM/access, mail/calendar, files/DMS, and connectors; not a monolithic module | `add-ideas/govoplan-core#195`, `add-ideas/govoplan-connectors#5` |
|
||||
| Open-Xchange mail/groupware | `govoplan-mail` | `add-ideas/govoplan-mail#5` |
|
||||
| Open-Xchange calendar | `govoplan-calendar` | `add-ideas/govoplan-calendar#2` |
|
||||
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#217` |
|
||||
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#219` |
|
||||
| Collaboration suite integration strategy | `govoplan-connectors`, `govoplan-dms`, `govoplan-workflow`, `govoplan-tasks`, `govoplan-appointments`, `govoplan-calendar` | `add-ideas/govoplan-core#220` |
|
||||
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#19` |
|
||||
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#26` |
|
||||
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#28` |
|
||||
| Government operations backbone reference model | `govoplan-core` | `GovOPlaN/govoplan-core#213` |
|
||||
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `GovOPlaN/govoplan-core#214` |
|
||||
| Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `GovOPlaN/govoplan-core#218` |
|
||||
| Access as a module | `govoplan-access` | `GovOPlaN/govoplan-access#7` |
|
||||
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `GovOPlaN/govoplan-core#227` |
|
||||
| Action/effect automation layer | `govoplan-workflow-engine`; possible future `govoplan-automation` only after boundary proof | `GovOPlaN/govoplan-workflow#1` |
|
||||
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `GovOPlaN/govoplan-postbox#15`, `GovOPlaN/govoplan-identity-trust#1` |
|
||||
| Identity, account, function, role, right semantic model | `govoplan-access` | `GovOPlaN/govoplan-access#9` |
|
||||
| Role-based service directory presentation | `govoplan-portal`; reusable service definition is a shared contract and candidate `govoplan-services` | `GovOPlaN/govoplan-portal#1` |
|
||||
| Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `GovOPlaN/govoplan-tasks#1`, `GovOPlaN/govoplan-notifications#1` |
|
||||
| OpenProject API / project management connector | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#1` |
|
||||
| Native project-management module | `govoplan-projects`; OpenProject transport remains connector-owned | `GovOPlaN/govoplan-projects#1`, `GovOPlaN/govoplan-connectors#12` |
|
||||
| Datasources for databases, CSV, files, APIs | `govoplan-datasources`, with external protocol and credential ownership in Connectors | `GovOPlaN/govoplan-datasources#1` |
|
||||
| Dataflow for pipelines, BI, publication | `govoplan-dataflow` over Datasources and module-owned providers | `GovOPlaN/govoplan-dataflow#1` |
|
||||
| Monthly datasource and transformation workflows | configuration package across Connectors, Datasources, Dataflow, Workflow Engine, Reporting, Files, and Templates | `GovOPlaN/govoplan#8`, `GovOPlaN/govoplan-dataflow#17` |
|
||||
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-templates#1` |
|
||||
| Reporting and BI | `govoplan-reporting`, separate from templates | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-reporting#1` |
|
||||
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `GovOPlaN/govoplan-files#15` |
|
||||
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `GovOPlaN/govoplan-core#191`, `GovOPlaN/govoplan-connectors#2` |
|
||||
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `GovOPlaN/govoplan-core#215` |
|
||||
| Cases module concept | `govoplan-cases` | `GovOPlaN/govoplan-core#174` |
|
||||
| Workflow runtime/editor split | `govoplan-workflow-engine` runtime plus optional `govoplan-workflow` editor | `GovOPlaN/govoplan-workflow#12`, `GovOPlaN/govoplan-workflow#13` |
|
||||
| Connectors module concept | `govoplan-connectors` | `GovOPlaN/govoplan-core#176` |
|
||||
| Adrema-style address and distribution-list management | `govoplan-addresses` | `GovOPlaN/govoplan-addresses#1` |
|
||||
| Consume sources and become a governed source | Connectors acquires, Datasources governs/materializes, Dataflow transforms/publishes | `GovOPlaN/govoplan-connectors#3`, `GovOPlaN/govoplan-datasources#3` |
|
||||
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#6` |
|
||||
| Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-scheduling#1` |
|
||||
| Terminplaner and calendar primitives | `govoplan-calendar` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-calendar#1` |
|
||||
| Terminbuchung appointment booking | `govoplan-appointments` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-appointments#1` |
|
||||
| Collaborative documents | `govoplan-dms` | `GovOPlaN/govoplan-dms#1` |
|
||||
| Forms | `govoplan-forms` for definitions and `govoplan-forms-runtime` for submissions/runtime behavior | `GovOPlaN/govoplan-core#194`, `GovOPlaN/govoplan-forms#1` |
|
||||
| RSS consume and emit | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#4` |
|
||||
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `GovOPlaN/govoplan-idm#1` |
|
||||
| OpenDesk stack integration map | integration profile across IDM/access, mail/calendar, files/DMS, and connectors; not a monolithic module | `GovOPlaN/govoplan-core#195`, `GovOPlaN/govoplan-connectors#5` |
|
||||
| Open-Xchange mail/groupware | `govoplan-mail` | `GovOPlaN/govoplan-mail#5` |
|
||||
| Open-Xchange calendar | `govoplan-calendar` | `GovOPlaN/govoplan-calendar#2` |
|
||||
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#217` |
|
||||
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#219` |
|
||||
| Collaboration suite integration strategy | `govoplan-connectors`, `govoplan-dms`, `govoplan-workflow`, `govoplan-tasks`, `govoplan-appointments`, `govoplan-calendar` | `GovOPlaN/govoplan-core#220` |
|
||||
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#19` |
|
||||
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#26` |
|
||||
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#28` |
|
||||
|
||||
Boundary rationale lives in `MODULE_ARCHITECTURE.md`. Current decisions:
|
||||
|
||||
- templates and reporting are separate modules
|
||||
- RSS/source consume-publish starts in connectors; datasources/dataflow are not
|
||||
repositories yet
|
||||
- RSS/source consume-publish starts in Connectors; governed source identity and
|
||||
snapshots belong to Datasources and transformations belong to Dataflow
|
||||
- calendar, scheduling, and appointments are three separate modules
|
||||
- forms definitions and forms runtime are separate responsibilities
|
||||
- OpenDesk is an integration profile across modules, not a monolithic module
|
||||
- OpenProject is connector-first; no native projects module yet
|
||||
- OpenProject transport is connector-owned; native portfolio/project semantics
|
||||
belong to Projects
|
||||
- public-sector integration strategy stays in core; executable catalogue work
|
||||
lives in connectors
|
||||
- encrypted postbox and identity-trust are strategic contracts, not mail-module
|
||||
behavior
|
||||
- automation starts as workflow-owned action/effect execution and may split into
|
||||
a dedicated module only after the runner becomes broader than workflow
|
||||
|
||||
The following modules are intentionally not created yet:
|
||||
|
||||
- `govoplan-datasources`
|
||||
- `govoplan-dataflow`
|
||||
- `govoplan-projects`
|
||||
|
||||
Create a repository only after a concrete implementation package proves that
|
||||
existing connector, files, reporting, workflow, or task ownership is too narrow.
|
||||
- automation starts in Workflow Engine and may split into a dedicated module
|
||||
only after the runner becomes broader than workflow
|
||||
- Mandates, Services, Parties, and Decisions begin as shared semantic contracts;
|
||||
create repositories only after independent persistence, lifecycle, security,
|
||||
and multiple-consumer evidence passes the repository threshold in the
|
||||
institutional governance target architecture
|
||||
|
||||
Core keeps the strategy index in
|
||||
`PUBLIC_SECTOR_INTEGRATION_STRATEGY.md`: integration postures, default
|
||||
@@ -705,7 +720,7 @@ Release composition and tag-only repository handling are documented in
|
||||
## Next Practical Work
|
||||
|
||||
The active cross-product story is
|
||||
[`add-ideas/govoplan#14`](https://git.add-ideas.de/add-ideas/govoplan/issues/14).
|
||||
[`GovOPlaN/govoplan#14`](https://git.add-ideas.de/GovOPlaN/govoplan/issues/14).
|
||||
Module repositories own implementation issues; do not clone their state here.
|
||||
|
||||
Immediate issue buckets:
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# Institutional Context And Governed References
|
||||
|
||||
GovOPlaN consequential work must retain enough context to answer who acted,
|
||||
for whom, through which function, under which mandate and jurisdiction, using
|
||||
which rule and evidence versions, and with which requested and observed effect.
|
||||
The shared contract lives in `govoplan_core.core.institutional`.
|
||||
|
||||
Core owns reference shapes and provider protocols only. It does not own shared
|
||||
Mandate, Service, Party, Decision, evidence, or geography tables. Domain modules
|
||||
own persistence and authorization; optional capabilities resolve the references.
|
||||
|
||||
## Envelope
|
||||
|
||||
`GovernedContextEnvelope` version 1 carries:
|
||||
|
||||
- a tenant and `TemporalRevision` with validity, recording, supersession, and
|
||||
change reason;
|
||||
- the real account or service account and represented account, function,
|
||||
procedure party, assignment, delegation/power, and mandate;
|
||||
- institution, organization unit, function, task, mandate, jurisdiction,
|
||||
service, case, party, work item, workflow, approval, decision, and record
|
||||
references;
|
||||
- versioned legal bases and evidence references;
|
||||
- information classification, purposes, retention/holds, minimization, and
|
||||
disclosure state;
|
||||
- external-source authority, maturity, freshness, health, and conflict state;
|
||||
- language, accessibility, channel, explanation, and availability references.
|
||||
|
||||
Every institutional reference includes the owner module, tenant, stable object
|
||||
identity, optional version/effective instant, and a protected display label.
|
||||
Cross-tenant references are rejected. Safe serialization omits labels,
|
||||
inspection URLs, formal reasoning, operative results, and conditions unless a
|
||||
caller explicitly requests the protected projection.
|
||||
|
||||
## Semantic Providers
|
||||
|
||||
The first provider-neutral capabilities are:
|
||||
|
||||
- `mandates.resolver`: resolve competence for a task/authority type at an
|
||||
effective instant and return the governing Mandate definition and evidence;
|
||||
- `services.definitions`: obtain versioned institutional service definitions;
|
||||
- `parties.resolver`: obtain effective procedure-local parties and powers of
|
||||
representation without copying Identity or Organizations subjects; and
|
||||
- `decisions.registry`: record and retrieve formal Decisions under optimistic
|
||||
revision control.
|
||||
|
||||
The Mandate, Service, Party/representation, and Decision DTOs have strict
|
||||
mapping round-trips so they can cross capability, event, package, and storage
|
||||
boundaries without shared ORM models. Their lifecycle states are explicit:
|
||||
Mandates distinguish draft/active/suspended/replaced/retired, Services retain
|
||||
publication state, Parties retain effective representation and revocation, and
|
||||
Decisions retain correction, revocation, and supersession references.
|
||||
|
||||
The DTOs are a repository threshold, not a mandate to create four modules.
|
||||
Independent persistence, lifecycle, security/operations behavior, release
|
||||
reason, reuse, and tests are still required before extraction.
|
||||
|
||||
Mandate resolution is deterministic: Core filters candidates by tenant,
|
||||
effective interval, active state, task and authority type, stable
|
||||
organization/function identity, jurisdiction coverage, and subject type. A
|
||||
result is competent only when exactly one matching Mandate remains and it has
|
||||
no unresolved conflicts. Evidence from matching definitions is deduplicated
|
||||
and retained in the explanation result. `revise_mandate_definition` applies
|
||||
optimistic concurrency and the allowed activation, suspension, replacement,
|
||||
and retirement transitions while leaving the previous revision immutable.
|
||||
|
||||
`revise_formal_decision` provides the equivalent lifecycle primitive for
|
||||
formal outcomes. Every accepted transition requires a new recorded revision
|
||||
and change reason, links `supersedes_ref` to the prior version, updates the
|
||||
authority envelope to the new version, and records explicit correction or
|
||||
revocation provenance. Terminal and backward transitions fail closed. Each
|
||||
Decision also records whether responsibility was human, human-reviewed
|
||||
automation, or an automated service account acting under mandate. Automation
|
||||
preparation/recommendation references remain inspectable without being
|
||||
mistaken for the responsible outcome.
|
||||
|
||||
Procedure-party corrections use `revise_procedure_party`: the stable party
|
||||
identity is retained, a new revision and reason are required, stale writes are
|
||||
rejected, and revoked/expired/superseded assignments are terminal.
|
||||
`revoke_party_representation` separately records when a limited power ceased
|
||||
to authorize actions. This allows consuming procedures to evaluate historical
|
||||
delivery or representation authority without rewriting Identity,
|
||||
Organizations, or Addresses records.
|
||||
|
||||
Service templates and package/tenant specializations use
|
||||
`derive_service_restriction`. The derived definition retains an explicit
|
||||
parent-version reference, cannot extend the parent's validity, audience,
|
||||
channels, or publication ceiling, and cannot remove inherited prerequisites,
|
||||
required evidence, legal bases, or bindings. This is the fail-closed semantic
|
||||
rule; configuration-package signature and provenance checks remain the package
|
||||
transport rule.
|
||||
|
||||
`ServiceAvailabilityRequirement` represents module, capability, mandate,
|
||||
policy, connector, maintenance, audience, and configuration prerequisites with
|
||||
an explicit unavailable-or-hidden failure mode and explanation reference. The
|
||||
optional `services.availability` evaluator returns policy-scoped boolean
|
||||
assessments, reason codes, and evidence. Unknown consequential requirements
|
||||
fail closed; a reference itself never grants access.
|
||||
|
||||
## Service Launch
|
||||
|
||||
`ServiceLaunchRequest` and `ServiceLaunchResult` define the owner-neutral
|
||||
boundary between Portal entry and a case, form, or workflow runtime effect.
|
||||
The request carries the exact published Service definition, exact selected
|
||||
binding, tenant, acting identity, timezone-aware request time, bounded
|
||||
parameters, and idempotency key. The result must retain that exact Service and
|
||||
binding, a same-tenant target reference, optional same-tenant evidence, and
|
||||
only a relative or credential-free HTTP(S) destination.
|
||||
|
||||
`service_launch_capability(kind)` maps bindings to owner capabilities:
|
||||
|
||||
- `case` -> `cases.service_launcher`
|
||||
- `form` -> `forms_runtime.service_launcher`
|
||||
- `workflow` -> `workflow_engine.service_launcher`
|
||||
|
||||
`FormDefinition`, `FormFieldDefinition`, and `forms.definitions` provide the
|
||||
owner-neutral exact-schema boundary used by Forms Runtime. Definitions carry an
|
||||
exact tenant/revision, field types/options/constraints/defaults, publication,
|
||||
draft, attachment, signature, policy, and handoff requirements. Forms owns
|
||||
those immutable definitions; Forms Runtime persists instances and validation
|
||||
evidence. A form Service binding uses `<form-id>/<revision>` and the launcher
|
||||
rejects missing, superseded, unpublished, cross-tenant, or invalid definitions.
|
||||
|
||||
Portal may discover and invoke those capabilities but cannot write owner
|
||||
tables. The owner must revalidate its definition/binding and current
|
||||
authorization, produce its normal audit/event state, and make replay after an
|
||||
ambiguous response safe. If the capability is absent, the service is
|
||||
explainably unavailable. URL-only entries pass through the same launch-time
|
||||
availability check and destination validation. Forms Runtime now supplies the
|
||||
definition-aware form launcher when both Forms and Forms Runtime are active;
|
||||
otherwise Portal continues to fail closed.
|
||||
|
||||
## Propagation
|
||||
|
||||
`PlatformEvent`, `ActionExecutionRequest`, and `AuditEvent` can carry the
|
||||
envelope. Audit persistence stores only its safe projection; platform-event
|
||||
outbox serialization preserves it across asynchronous delivery. A module must
|
||||
not invent a parallel context dictionary when the shared fields apply.
|
||||
|
||||
## First Proof
|
||||
|
||||
Committee's `committee.decision_path` capability is the first bounded proof. It
|
||||
requires one effective, conflict-free Mandate covering the organization unit
|
||||
function, and jurisdiction, an approval reference, fact evidence, versioned legal bases,
|
||||
operative result, and reasoning. It emits a reconstructable `FormalDecision`,
|
||||
including requested/observed effects and information governance. If a Decision
|
||||
registry is installed it persists there; Committee does not take ownership of
|
||||
the generic Decision lifecycle.
|
||||
|
||||
## Compatibility And Security
|
||||
|
||||
- Contract version changes follow Core compatibility policy.
|
||||
- Unknown tenant or reference-kind combinations fail closed.
|
||||
- Datetimes that affect authority must be timezone-aware.
|
||||
- Protected labels, reasoning, evidence inspection links, and source details
|
||||
remain subject to the owning module's access policy.
|
||||
- References do not grant access to their targets.
|
||||
- Evidence and audit payloads must contain stable references/checksums, not
|
||||
plaintext secrets.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Core Interface Pattern Migration
|
||||
|
||||
This document records the Core-owned part of the product-wide interface
|
||||
pattern-language rollout. The normative product grammar and complete route
|
||||
inventory live in the `govoplan` meta repository. Core owns reusable behavior;
|
||||
domain modules own their compositions.
|
||||
|
||||
## Core Surfaces
|
||||
|
||||
| Surface | Pattern | Consequence and provenance contract | Evidence |
|
||||
| --- | --- | --- | --- |
|
||||
| User settings | Two-zone settings workspace with typed controls and unsaved-change protection | Save actions distinguish busy, unchanged, and test-in-progress states; contextual help resolves through Docs or the hosted fallback | `SettingsPage.tsx`, `test-core-interface-patterns.mjs` |
|
||||
| Reusable credentials | Repeated administration with an adaptive create/edit dialog, optional password generator, and destructive confirmation | Secret values are write-only; generated candidates use the browser cryptographic API without a weak fallback and do not replace the field until explicitly confirmed; scope/permission blockers name the required action, responsible actor, and destination; unavailable row actions remain keyboard-explainable | `CredentialEnvelopeManager.tsx`, shared `PasswordField`, `PasswordGeneratorDialog`, `ActionBlockerHint`, `Button`, `TableActionGroup`, and `ConfirmDialog` |
|
||||
| Retention policy | Effective-policy editor with inherited source paths and typed, narrowing-only controls | Parent locks and missing write authority are explicit; the save action distinguishes locks, missing target, loading, clean draft, and active save | `RetentionPolicyManagement.tsx`, policy logic tests, `test-core-interface-patterns.mjs` |
|
||||
| Module lifecycle | Guided operator projection over durable installer-queue evidence | Preflight, handoff, progress, stale evidence, recovery, and rollback consequences remain visible | Admin module lifecycle tests and the Core installer-queue contract |
|
||||
| Shared configuration primitives | Cross-module component contract | Dialog focus, blocker structure, disabled-action focus, contextual help, unsaved changes, confirmation, loading, alerts, problem lists, and policy provenance are centralized | Core component tests and module-permutation build |
|
||||
|
||||
## Boundary
|
||||
|
||||
Files and Mail are the first two external consumers of the layered
|
||||
server/credential/policy pattern. Their own repositories retain provider
|
||||
discovery, transport behavior, authorization, and migration evidence. Remaining
|
||||
module surfaces are tracked by bounded module-owned issues under GovOPlaN #11;
|
||||
they are not reasons to add sibling-private behavior to Core.
|
||||
|
||||
Raw JSON remains permitted only for diagnostics, expert inspection,
|
||||
interchange, or conflict evidence. It is not a primary Core configuration
|
||||
editor.
|
||||
+359
-8
@@ -13,6 +13,10 @@ Policy decision, source provenance, and explain-response contracts are tracked
|
||||
in [`POLICY_CONTRACTS.md`](POLICY_CONTRACTS.md).
|
||||
The experimental remote WebUI bundle loading design is tracked in
|
||||
[`REMOTE_WEBUI_BUNDLES.md`](REMOTE_WEBUI_BUNDLES.md).
|
||||
The cross-product semantic layers, source-authority modes, and candidate
|
||||
Mandates, Services, Parties, and Decisions boundaries are canonical in the
|
||||
meta repository's
|
||||
[`INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md`](../../govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
|
||||
|
||||
## Layer Model
|
||||
|
||||
@@ -24,6 +28,36 @@ The experimental remote WebUI bundle loading design is tracked in
|
||||
| Business modules | Public-sector workflows | campaigns, cases, forms, approvals, appointments |
|
||||
| Connector modules | External system integration | FIT-Connect, XÖV/XTA, DMS/eAkte, ERP, IDM |
|
||||
|
||||
This table is the technical composition model. The product portfolio uses a
|
||||
more detailed institutional layer model, but it does not change dependency
|
||||
direction: Core provides contracts and composition; modules own semantics;
|
||||
packages compose modules.
|
||||
|
||||
## Institutional Semantic Boundaries
|
||||
|
||||
Cross-module references must keep these answers distinct:
|
||||
|
||||
- Organizations owns where structures, units, and functions exist.
|
||||
- Identity owns who a subject is; Access owns accounts, roles, permissions,
|
||||
and authorization decisions; IDM owns effective function assignments.
|
||||
- A Mandates capability will answer why a unit or function is competent for a
|
||||
task, jurisdiction, subject, or period. It must not become another RBAC
|
||||
system.
|
||||
- A Services capability will own versioned institutional service definitions;
|
||||
Portal presents and starts them.
|
||||
- A Parties capability will own procedure-local participant roles,
|
||||
representation, and delivery authority; it must reference rather than copy
|
||||
Identity, Organizations, and Addresses subjects.
|
||||
- A Decisions capability will own formal institutional outcomes and their
|
||||
authority, facts, rules, reasoning, effects, correction, and review.
|
||||
Approvals owns review gates, Committee owns deliberation/votes, and Workflow
|
||||
Engine owns coordination.
|
||||
|
||||
Start each missing concept as a versioned DTO/provider contract used by a
|
||||
bounded journey. A repository is justified only when the concept gains
|
||||
independent persistence, lifecycle, security/operations behavior, release
|
||||
reason, and reuse. Core must not store these domain objects.
|
||||
|
||||
## Kernel Responsibilities
|
||||
|
||||
The kernel target owns:
|
||||
@@ -74,6 +108,10 @@ The compatibility/deprecation plan for the current split line is:
|
||||
- reject new cross-module imports that bypass manifests, capabilities, events,
|
||||
or public module APIs
|
||||
|
||||
The retention windows and removal checklist for database bridges,
|
||||
configuration/export schemas, and runtime/API aliases are defined in
|
||||
`COMPATIBILITY_POLICY.md`.
|
||||
|
||||
## Stable Kernel Contracts
|
||||
|
||||
The following contracts are the baseline API that modules can rely on:
|
||||
@@ -87,20 +125,59 @@ The following contracts are the baseline API that modules can rely on:
|
||||
- capability factory contract
|
||||
- access DTO/protocol contracts in `govoplan_core.core.access`
|
||||
- resource ACL provider contract
|
||||
- tenant summary provider contract
|
||||
- bounded reference-option search provider contract
|
||||
- single-tenant and optional batched tenant summary provider contracts
|
||||
- tenant delete-veto provider contract
|
||||
- WebUI module contribution contract
|
||||
- navigation metadata contract
|
||||
- command/event envelope contract
|
||||
- policy decision and source provenance contract in `govoplan_core.core.policy`
|
||||
- external object reference and integration-maturity contract in
|
||||
`govoplan_core.core.external_references`
|
||||
- action/effect preview and execution contract in
|
||||
`govoplan_core.core.automation`
|
||||
- workflow definition contribution and runtime-worker contracts
|
||||
|
||||
Changes to these contracts must be versioned or accompanied by compatibility shims.
|
||||
|
||||
Tenant list pages prefer `tenant_summary_batch_providers`. A batch provider
|
||||
receives the unique tenant IDs on the current page and returns count mappings
|
||||
keyed by tenant ID. Missing tenant keys mean that the provider has no counts for
|
||||
that tenant; provider errors remain visible. Modules that expose only the
|
||||
single-tenant contract remain compatible through a per-tenant fallback.
|
||||
Destructive tenant lifecycle planning deliberately continues to use the
|
||||
single-tenant path so it invokes every registered provider for the target
|
||||
tenant, independent of ordinary list-page projections.
|
||||
|
||||
This list is the Milestone A kernel-contract freeze baseline. New module work
|
||||
may extend the kernel by adding explicit contracts, but existing contracts must
|
||||
remain source-compatible through the 0.1.x split line unless a migration shim
|
||||
and deprecation note are provided.
|
||||
|
||||
### Architecture Metadata
|
||||
|
||||
`ModuleManifest.architecture` is the backward-compatible, versioned product-
|
||||
portfolio declaration for:
|
||||
|
||||
- module kind and institutional architecture layer;
|
||||
- evidence-backed maturity claim (`concept`, `scaffold`, `vertical_slice`,
|
||||
`reference_ready`, `supported`, or `lts`);
|
||||
- owned and explicitly non-owned concepts;
|
||||
- supported source-authority modes;
|
||||
- reference packages, tested providers, and known limits;
|
||||
- migration, upgrade, recovery, security, operations, and documentation
|
||||
evidence references.
|
||||
|
||||
Core validates the claim and all provider references during registry startup.
|
||||
`reference_ready`, `supported`, and `lts` claims require a named reference
|
||||
package and the cumulative evidence set; target-tested providers additionally
|
||||
require provider evidence. A supported module with migrations must include
|
||||
migration evidence. Signed release catalogs retain and revalidate the
|
||||
declaration. The meta manifest check validates repository evidence paths and
|
||||
provides an opt-in `--require-architecture` rollout gate. Platform metadata,
|
||||
Docs, and Ops project the same declaration. A module cannot make itself
|
||||
supported solely by changing its maturity string.
|
||||
|
||||
Known access-related capability names are defined in
|
||||
`govoplan_core.core.access`, including:
|
||||
|
||||
@@ -130,10 +207,28 @@ Other stable runtime capabilities currently include:
|
||||
|
||||
- `identity.directory` and `identity.search`
|
||||
- `organizations.directory`
|
||||
- `idm.directory`
|
||||
- `calendar.outbox` and `calendar.scheduling`
|
||||
- `idm.directory`, `idm.function_assignments`, `idm.relationships`, and
|
||||
`idm.assignment_lifecycle`
|
||||
- `calendar.outbox`, `calendar.scheduling`, `calendar.invitations`, and
|
||||
`calendar.externalProfiles`
|
||||
- `poll.scheduling`
|
||||
- `notifications.dispatch`
|
||||
- `workflow.definitionContributions` and `workflow.runtimeWorker`
|
||||
|
||||
The provider-neutral `idm.relationships` contract carries tenant-scoped typed
|
||||
groups, effective-dated identity relationships, and explicit membership
|
||||
decisions. It deliberately does not expose IDM persistence models or imply an
|
||||
Access permission. Consumers can retain source revisions and inclusion or
|
||||
exclusion provenance while remaining optional-module safe.
|
||||
|
||||
Modules contribute reusable process baselines through
|
||||
`ModuleManifest.workflow_definitions`. Each contribution pins its origin module
|
||||
and version, stable key, schema and content hash, native graph/BPMN content,
|
||||
governance ceilings, execution mode, and required capabilities/interfaces.
|
||||
`govoplan-workflow-engine` reconciles these declarations idempotently. A module
|
||||
upgrade appends a baseline revision without replacing the active revision or
|
||||
mutating a local override; the optional `govoplan-workflow` package supplies
|
||||
the comparison, derivation, and reset UI.
|
||||
|
||||
### Named Interface Contracts
|
||||
|
||||
@@ -156,11 +251,52 @@ intended for SemVer major-version lines. Missing optional interfaces are
|
||||
allowed, but an installed provider with an incompatible version blocks
|
||||
activation because the integration would otherwise bind to an unsafe API.
|
||||
|
||||
### Source Authority And Provider Operations
|
||||
|
||||
Integration maturity and configured authority are independent. The existing
|
||||
external-reference maturity ladder describes whether an adapter can discover,
|
||||
link, search, read, publish, synchronize, migrate, or replace. A binding must
|
||||
also state whether GovOPlaN is native authoritative, the external system is
|
||||
authoritative, GovOPlaN keeps a mirror, both sides use governed sync, GovOPlaN
|
||||
adds only a governance overlay, or the object is link-only.
|
||||
|
||||
`ModuleManifest.external_providers` composes existing contracts rather than
|
||||
replacing them. Each declaration describes owned object/field groups, authority modes,
|
||||
operations, revisions, freshness, health, limits, idempotency, conflicts,
|
||||
outcome-unknown handling, evidence, correction/compensation, reconciliation,
|
||||
outage behavior, classification, purpose, retention, and secret requirements.
|
||||
Core owns the typed declaration and validation. Connectors and domain modules
|
||||
own the actual protocol and domain behavior; configuration packages select the
|
||||
effective mode and block missing/incompatible/unhealthy providers; Docs and Ops
|
||||
explain the result. Effect-capable declarations fail validation unless their
|
||||
retry, concurrency, outcome-unknown, correction, reconciliation, evidence,
|
||||
audit, timeout, outage, classification, purpose, retention, and secret behavior
|
||||
is explicit.
|
||||
|
||||
Declarations are release-time capability claims. Configured state is projected
|
||||
separately through `ModuleManifest.external_provider_state_providers`. A state
|
||||
provider receives a bounded tenant context and returns one sanitized observation
|
||||
per configured binding: stable binding reference, effective authority mode,
|
||||
active/configured state, health, freshness, conflict, recovery readiness,
|
||||
observation/last-success time, and scalar metrics. Core validates and aggregates
|
||||
those observations, isolates provider failures, and never accepts URLs,
|
||||
credentials, or arbitrary nested metadata as runtime-state fields. Docs removes
|
||||
binding-level detail from ordinary-user projections; Ops may show the full
|
||||
sanitized operator projection.
|
||||
|
||||
Configuration-package preflight selects the exact requested binding from this
|
||||
runtime state before evaluating authority, health, freshness, and recovery. A
|
||||
healthy sibling binding therefore cannot mask an unhealthy required binding.
|
||||
Providers with multiple configurations must use non-secret, stable references
|
||||
such as `calendar:sync-source:<id>`.
|
||||
|
||||
Current named interfaces, generated from the source manifests by the workspace
|
||||
contract checks, are:
|
||||
|
||||
- `addresses.contact_writer`, `addresses.lookup`, `addresses.recipient_source`
|
||||
- `calendar.outbox`, `calendar.scheduling`
|
||||
- `addresses.contact_point_resolution`, `addresses.contact_writer`,
|
||||
`addresses.lookup`, `addresses.recipient_source`
|
||||
- `calendar.external_profiles`, `calendar.invitations`, `calendar.outbox`,
|
||||
`calendar.scheduling`
|
||||
- `campaigns.access`, `campaigns.delivery_tasks`,
|
||||
`campaigns.mail_policy_context`, `campaigns.policy_context`,
|
||||
`campaigns.retention`
|
||||
@@ -263,6 +399,23 @@ unsafe methods.
|
||||
This avoids retransmitting unchanged snapshots. It does not identify which row
|
||||
changed inside a collection.
|
||||
|
||||
### Mutation Preconditions
|
||||
|
||||
Weak response ETags are cache validators only. Mutable aggregates expose a
|
||||
separate positive, monotonic revision and an opaque strong ETag generated by
|
||||
`govoplan_core.core.concurrency.strong_resource_etag`. HTTP mutations send that
|
||||
strong ETag in `If-Match`; capability and worker calls carry the equivalent
|
||||
typed `expected_revision`.
|
||||
|
||||
Core's compare-and-set primitive advances the revision in the same transaction
|
||||
as the domain mutation. A missing HTTP precondition is `428 Precondition
|
||||
Required`, a stale HTTP precondition is `412 Precondition Failed`, and a
|
||||
domain/reconciliation conflict is `409 Conflict`. Conflict responses contain
|
||||
bounded resource and revision metadata rather than the complete current
|
||||
object. Modules may opt into the conservative three-way merge helper, but must
|
||||
declare protected workflow, delivery, ownership, lock, evidence, signature,
|
||||
and cryptographic paths that can never be merged automatically.
|
||||
|
||||
### Delta Collections
|
||||
|
||||
Collection endpoints that can expose row-level changes should use the shared
|
||||
@@ -333,6 +486,31 @@ full snapshot with `full: true`. A first-use `seq:0` watermark remains valid
|
||||
until such a floor exists, even if unrelated collections have advanced the
|
||||
global sequence.
|
||||
|
||||
### Bounded Reference Selectors
|
||||
|
||||
Cross-module selectors use the module-neutral contract in
|
||||
`govoplan_core.core.references`; consumers must not load an optional module's
|
||||
complete directory and filter it in memory.
|
||||
|
||||
- Providers receive a normalized `ReferenceSearchRequest` with `kind`,
|
||||
`tenant_id`, `query`, `selected_values`, `limit`, optional opaque `cursor`,
|
||||
and policy context.
|
||||
- Providers apply visibility and text filtering before materializing rows and
|
||||
return `ReferenceSearchPage(options, next_cursor, has_more)`.
|
||||
- A page contains at most the requested bounded search results. Already-selected
|
||||
references are retained in addition to that bound so historical values remain
|
||||
readable and removable even when they are inactive, deleted, or outside the
|
||||
current search page.
|
||||
- API consumers expose `next_cursor` and `has_more`. The current searchable
|
||||
selector requests the first bounded page for each query; later load-more UI
|
||||
can use the same cursor without changing the provider contract.
|
||||
- `access.reference_options` supplies SQL-backed account, membership, and group
|
||||
searches. When it is absent, Core degrades to the legacy Access directory or
|
||||
to principal-only/unavailable references without importing Access.
|
||||
- The shared WebUI `apiReferenceOptionProvider` resolves selected values in
|
||||
chunks of at most 200, preventing a large existing selection from turning
|
||||
into an unbounded request.
|
||||
|
||||
### Cursor/Keyset Pages
|
||||
|
||||
Offset pagination remains supported for compatibility and for first page loads,
|
||||
@@ -527,6 +705,46 @@ Rules:
|
||||
one migration run, and do not switch a database between tracks unless it is a
|
||||
disposable development database.
|
||||
|
||||
### Shared State And Runtime Ordering
|
||||
|
||||
Multi-host application roles use the `shared` state profile. In that profile,
|
||||
PostgreSQL, Redis, a stable installation identifier, and S3-compatible object
|
||||
storage are mandatory. Module durable artifacts must use Core's object-storage
|
||||
contract and module-owned opaque key namespaces; node-local paths are limited
|
||||
to temporary materialization. Same-host replicas may use the `host-shared`
|
||||
profile and one shared volume.
|
||||
|
||||
Only the migration command mutates schema. PostgreSQL migration runs acquire a
|
||||
deployment-wide advisory lock before module pre-tasks, Alembic, and post-tasks.
|
||||
API, worker, and scheduler roles wait for exact configured migration heads and
|
||||
fail closed instead of applying migrations during startup.
|
||||
|
||||
Runtime roles register identity, software/module composition, queues, heartbeat,
|
||||
and drain state in PostgreSQL. Singleton work must use a distributed lease and
|
||||
validate its monotonically increasing fencing token at the consequential
|
||||
commit. See `STATE_AND_RECOVERY_CONTRACT.md` for the complete contract.
|
||||
|
||||
### Recovery Evidence
|
||||
|
||||
Operations spanning transactions, object storage, queues, or external systems
|
||||
must choose an explicit Core recovery mode: atomic, compensation,
|
||||
snapshot-restore, forward-recovery, or irreversible. Plans require verification
|
||||
steps and mode-specific recovery material. Use idempotency keys, append-only
|
||||
evidence checkpoints, and a runtime fence where work may race across nodes.
|
||||
|
||||
The recovery ledger is a shared primitive, not automatic coverage. A module may
|
||||
claim its guarantees only after its operation records preconditions before side
|
||||
effects, transitions partial/unknown outcomes honestly, and records verified
|
||||
completion or recovery. Plaintext secrets must never enter recovery metadata or
|
||||
evidence.
|
||||
|
||||
For a conclusive external result, modules may commit their local success
|
||||
projection and the verified terminal checkpoint in one database transaction via
|
||||
`DurableRecoveryOperation.commit_verified_success`. This does not make the
|
||||
external provider effect atomic. It prevents a local `succeeded` state from
|
||||
becoming authoritative when the recovery evidence chain is damaged or the
|
||||
terminal checkpoint cannot commit.
|
||||
|
||||
## Install, Uninstall, And Catalogs
|
||||
|
||||
Core owns the install plan, signed catalog validation, license entitlement
|
||||
@@ -585,11 +803,18 @@ Uninstall remains non-destructive unless the operator explicitly requests
|
||||
|
||||
## WebUI Contract
|
||||
|
||||
A WebUI module exports a `PlatformWebModule` from its package. The object contributes local/fallback metadata and route render functions.
|
||||
A WebUI module exports a `PlatformWebModule` from its package. The object
|
||||
contributes local/fallback metadata and route render functions. The package
|
||||
must ship `src/module.ts` with the default contribution export: Core's Vite
|
||||
host imports that descriptor directly after the backend reports the module as
|
||||
enabled. This keeps package-root re-exports from pulling page implementations
|
||||
into the initial shell.
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
const FilesPage = lazy(() => import("./features/files/FilesPage"));
|
||||
|
||||
export const filesModule: PlatformWebModule = {
|
||||
id: "files",
|
||||
label: "Files",
|
||||
@@ -604,12 +829,39 @@ export const filesModule: PlatformWebModule = {
|
||||
};
|
||||
```
|
||||
|
||||
Route pages and substantial panels must use stable lazy imports. Core supplies
|
||||
the shared loading and retryable error state around route rendering. The
|
||||
initial static import closure and largest asynchronous chunk are enforced by
|
||||
the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md).
|
||||
|
||||
Every public platform interface has a stable declaration identity. Backend
|
||||
routes, capabilities, interfaces, search providers/sources, permissions,
|
||||
frontend routes/navigation, and View surfaces derive that identity from typed
|
||||
`ModuleManifest` values. Typed WebUI capabilities declare IDs for settings,
|
||||
admin sections, widgets, search contexts, and extension actions. Shared form
|
||||
and action controls accept `interfaceId` and `helpTopicId`; use module-namespaced
|
||||
values when another contract, documentation topic, or automated check must
|
||||
refer to the control across source changes. The static inventory assigns a
|
||||
line-independent source anchor when an explicit ID is absent and reports that
|
||||
fact for later review.
|
||||
|
||||
Core exposes the sanitized runtime declaration set at
|
||||
`GET /api/v1/platform/interface-catalog`. The endpoint is read-only, requires
|
||||
`admin:module:read` or `system:settings:read`, and includes only modules
|
||||
effective in the caller's active tenant context. It never serializes factories,
|
||||
credentials, executable callbacks, or mutable module state. Registry validation
|
||||
rejects conflicting declaration IDs before startup.
|
||||
|
||||
WebUI modules receive only the core route context:
|
||||
|
||||
- `settings`
|
||||
- `auth`
|
||||
|
||||
A module should call its own API client and module-owned backend routes. Shared API helpers should live in core only when they are truly platform-level concerns.
|
||||
For ordinary JSON mutations, use Core's `apiPostJson` and `apiPatchJson`
|
||||
helpers. They preserve the shared authentication, CSRF, error, and request
|
||||
invalidation behavior while leaving endpoint types and feature semantics in the
|
||||
owning module.
|
||||
|
||||
Modules can also contribute named UI capabilities for explicit extension
|
||||
points. Capability values must be narrow, typed contracts, not imports from a
|
||||
@@ -792,6 +1044,16 @@ Decision: templates and reporting are separate modules.
|
||||
and export targets
|
||||
- report permissions, report execution history, generated report evidence, and
|
||||
report-specific retention inputs
|
||||
|
||||
Cross-module reports use Core's versioned
|
||||
`reporting.report_provider.<provider-id>` contract. Source modules own
|
||||
authorization, parameters, source revisions, effective scope, result schema,
|
||||
and privacy transforms; Reporting owns discovery, validation, governed
|
||||
execution, provenance, export history, and the global `/reports` route. The
|
||||
optional `policy.reporting_governance` capability can only tighten execution,
|
||||
retention, export, and re-identification-risk handling. Reporting exposes
|
||||
`reporting.retention` so the Policy-owned retention run can minimize expired
|
||||
provider results without importing Reporting models.
|
||||
- downstream export handoff to files, dataflow, connectors, or publication
|
||||
surfaces
|
||||
|
||||
@@ -821,7 +1083,7 @@ First slice:
|
||||
- `govoplan-files` owns file-backed governed locations and uploaded/stored file
|
||||
evidence.
|
||||
- `govoplan-reporting` owns report/data views and scheduled outputs.
|
||||
- `govoplan-workflow` owns process state, approvals, scheduling of process
|
||||
- `govoplan-workflow-engine` owns process state, approvals, scheduling of process
|
||||
steps, and human review.
|
||||
|
||||
Future `govoplan-datasources` is justified when GovOPlaN needs a broad source
|
||||
@@ -884,7 +1146,7 @@ from workflow semantics.
|
||||
- form definitions, schemas, validation rules, field visibility rules,
|
||||
localization, versioning, admin editing, and reusable form package fragments
|
||||
|
||||
`govoplan-forms-runtime` owns, when implemented:
|
||||
`govoplan-forms-runtime` owns:
|
||||
|
||||
- public/internal submissions, drafts, submitted values, validation evidence,
|
||||
attachment references, submission receipts, and handoff events
|
||||
@@ -897,6 +1159,16 @@ Boundary:
|
||||
- Reporting/dataflow may consume submitted data through governed DTOs or
|
||||
source lifecycle contracts.
|
||||
|
||||
Implemented contract:
|
||||
|
||||
- Core owns the provider-neutral `FormDefinition`/`FormFieldDefinition` DTOs.
|
||||
- Forms persists immutable exact definitions and provides `forms.definitions`.
|
||||
- Forms Runtime resolves that capability, persists revisioned instances and
|
||||
events, validates draft/final values, and provides
|
||||
`forms_runtime.service_launcher`.
|
||||
- Portal delegates exact `<form-id>/<revision>` bindings and never writes either
|
||||
owner's tables.
|
||||
|
||||
### OpenDesk Integration Profile
|
||||
|
||||
Tracking: `govoplan-core#195`, `govoplan-connectors#5`,
|
||||
@@ -985,6 +1257,61 @@ devserver, development bootstrap, background worker registry, and migration
|
||||
metadata plan all read the saved desired state from `system_settings` before
|
||||
building their module registry.
|
||||
|
||||
### Tenant entitlement and personal visibility
|
||||
|
||||
Deployment activation remains process-wide: one installed and active registry
|
||||
is shared by every tenant served by that process. Tenant module selection is a
|
||||
separate entitlement document in `core_scopes.settings.module_entitlements`:
|
||||
|
||||
- a system policy marks each installed module `unavailable`, `available`, or
|
||||
`forced` for one tenant;
|
||||
- the tenant selection may enable or disable only available modules;
|
||||
- protected platform modules, forced modules, and transitive dependencies stay
|
||||
effective;
|
||||
- malformed explicit entitlement fails closed to protected modules, while an
|
||||
absent document preserves the pre-entitlement behavior for upgraded tenants;
|
||||
- an optimistic revision prevents concurrent system and tenant administrators
|
||||
from silently replacing each other's changes.
|
||||
|
||||
The authenticated platform metadata and module route guard intersect global
|
||||
runtime activation with the active tenant's effective entitlement. Entitlement
|
||||
does not grant a permission. Access authorization must still allow every API
|
||||
operation and resource.
|
||||
|
||||
The same boundary applies outside authenticated request handling:
|
||||
|
||||
- capability factories retain their owning module, and tenant-scoped capability
|
||||
lookup treats a provider that is unavailable to the tenant as absent;
|
||||
- workers partition scheduled scans by tenant before claiming rows;
|
||||
- new work is rejected while a module is unavailable, while already accepted
|
||||
durable work remains in provider-owned storage and is reported as
|
||||
`operator_action_required` instead of being dropped or executed;
|
||||
- Workflow, Dataflow, event consumers, reconciliation jobs, and external-effect
|
||||
outboxes run inside a tenant execution context, so their optional capability
|
||||
calls inherit the same provider checks;
|
||||
- public signed-link modules declare a `public_tenant_resolver`; valid token
|
||||
context is resolved before the route runs and the module entitlement is then
|
||||
enforced without requiring an authenticated principal.
|
||||
|
||||
Entitlement resolution uses a bounded process-local cache. A local policy
|
||||
mutation invalidates its tenant entry immediately; changes made by another node
|
||||
become authoritative after `TENANT_MODULE_ENTITLEMENT_CACHE_TTL_SECONDS`
|
||||
(five seconds by default). This is a bounded staleness optimization, not an
|
||||
authorization grant: a cache miss or resolution failure fails closed.
|
||||
|
||||
Users and groups do not own another module-runtime state. Every WebUI module
|
||||
already contributes a root `<module>.module` View surface, so personal and
|
||||
group module visibility is expressed through Views. View policy controls who
|
||||
may select, assign, edit, derive, or workflow-activate those projections;
|
||||
required View assignments can retain required UI. Thus tenant entitlement owns
|
||||
operational availability, Views own presentation, and Access owns authority.
|
||||
|
||||
Capability-style modules such as Encryption must keep activation separate from
|
||||
domain data state. Making Encryption effective only exposes its capability and
|
||||
administration surfaces. Encrypting, rekeying, decrypting, or migrating data is
|
||||
an explicit versioned protection-policy operation owned by Encryption and the
|
||||
module that owns the data.
|
||||
|
||||
Hot enable/disable is a core design principle for every module:
|
||||
|
||||
- Core keeps one mutable active `PlatformRegistry` object and swaps its manifest
|
||||
@@ -1092,6 +1419,11 @@ The package install-plan API records operator intent only:
|
||||
default; successful uninstalls are removed from saved startup state by default.
|
||||
Use `--no-activate-installed-modules` or
|
||||
`--keep-uninstalled-modules-in-desired` only for staged rollout workflows.
|
||||
- Every non-dry installer and live active-graph mutation acquires the
|
||||
deployment-wide `core:module-lifecycle:deployment` lease and records a Core
|
||||
recovery operation. Unresolved effects block later lifecycle changes. The
|
||||
operation modes and operator reconciliation contract are defined in
|
||||
`MODULE_LIFECYCLE_RECOVERY.md`.
|
||||
- `govoplan-module-installer --supervise --migrate --health-url http://127.0.0.1:8000/health --restart-command '<restart govoplan server>'`
|
||||
is the preferred disruptive-change path. It applies the plan, optionally runs
|
||||
migrations in a fresh Python process after a fresh-process manifest
|
||||
@@ -1141,6 +1473,9 @@ the same restart/health set after restoring package and database snapshots.
|
||||
The installer preflight is intentionally conservative:
|
||||
|
||||
- maintenance mode must be active;
|
||||
- the `shared` state profile blocks in-place package mutation; clustered
|
||||
installations must roll one verified immutable module composition across all
|
||||
replicas;
|
||||
- installed module manifests must be compatible with the supported manifest
|
||||
contract and current core version;
|
||||
- uninstalling `tenancy`, `access`, or `admin` is blocked;
|
||||
@@ -1241,6 +1576,22 @@ The first implementation is a platform access gate. It does not replace
|
||||
database backups, process supervision, migration checks, or external load
|
||||
balancer maintenance pages.
|
||||
|
||||
## Connector Runtime Contract
|
||||
|
||||
Core defines provider-neutral connector preview and diagnostic primitives in
|
||||
`govoplan_core.core.connector_runtime`. The contract keeps optional modules
|
||||
decoupled: Connectors owns transport, endpoint discovery, retries, and protocol
|
||||
health; the consuming domain module owns mappings, validation, reconciliation,
|
||||
and mutations of its records.
|
||||
|
||||
Every dry run is bounded and identifies the source revision, source fingerprint,
|
||||
immutable input hash, effects, and redacted diagnostics. Its summary must match
|
||||
the returned effect list exactly. An apply token is usable only when the preview
|
||||
is complete, current, conflict-free, and contains no error diagnostic. Endpoint
|
||||
URLs never contain credentials; only credential-envelope references cross the
|
||||
contract. Provider-specific details belong in sanitized provenance rather than
|
||||
in a shared domain schema.
|
||||
|
||||
## Build And Verification
|
||||
|
||||
Backend verification from core:
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Module Lifecycle Recovery
|
||||
|
||||
## Migration Revision Namespace
|
||||
|
||||
All enabled module migration directories are assembled into one Alembic graph. Revision IDs are therefore global across Core and every module even though each module owns a separate `migrations/versions` directory. Core validates literal revision declarations before constructing the graph and rejects duplicates with both file paths. A module must assign a new globally unique revision ID; reusing another module's ID can otherwise make Alembic treat an unrelated schema change as already applied or report an ancestor/head overlap.
|
||||
|
||||
When correcting a collision that has already reached a database, first verify the schema objects that identify which migration actually ran. Rename the unapplied migration, or transactionally translate the corresponding `alembic_version` row when the applied owner is unambiguous. Never add both colliding IDs as heads or blindly stamp the database.
|
||||
|
||||
Package changes and live module-graph changes use Core's durable recovery
|
||||
ledger. The local `install.lock` still prevents duplicate work in one runtime
|
||||
directory; the database lease `core:module-lifecycle:deployment` is the
|
||||
deployment-wide authority across API, installer, worker, and scheduler nodes.
|
||||
|
||||
## Declared Boundaries
|
||||
|
||||
| Operation | Recovery mode | Completion condition |
|
||||
| --- | --- | --- |
|
||||
| `module-lifecycle.pre-migration` | compensation | package, WebUI, manifest, and desired-graph evidence match |
|
||||
| `module-lifecycle.post-migration` | forward recovery | migration tasks, manifests, desired graph, restart, and health are verified |
|
||||
| `module-retirement.destroy-data` | snapshot restore | a hashed, restore-checked backup exists and retirement state is verified |
|
||||
| `module-runtime.apply-graph` | compensation | hooks, capability contexts, active graph, and workflow contributions match |
|
||||
|
||||
The installer prepares the recovery operation before it captures the database
|
||||
snapshot. A full database restore therefore retains the prepared operation and
|
||||
its fence instead of erasing the fact that a mutation was attempted. Backup
|
||||
artifacts are hashed and sized before any package, migration, or retirement
|
||||
effect starts.
|
||||
|
||||
Every command boundary records the command source and canonical hashes of the
|
||||
redacted command/result records. Credentials, database URLs, command output,
|
||||
and package-registry secrets are never copied into recovery evidence.
|
||||
|
||||
## Failure And Retry Rules
|
||||
|
||||
- A conclusive failure before effects is terminal `failed`.
|
||||
- A command or compensatable effect that started but did not complete is
|
||||
`recovery_required`.
|
||||
- A lost or unexpected outcome after a migration/external boundary is
|
||||
`outcome_unknown`.
|
||||
- A verified package/database rollback becomes `recovered`.
|
||||
- A supervised install becomes `succeeded` only after restart and all configured
|
||||
health probes succeed.
|
||||
|
||||
An unresolved lifecycle operation blocks every later lifecycle mutation on the
|
||||
same deployment fence, even after its execution lease is released. Operators
|
||||
must inspect the checkpoint chain and run record, restore or complete the
|
||||
declared recovery path, and explicitly reconcile the operation. A new install
|
||||
must not be used as an implicit retry.
|
||||
|
||||
Live graph changes use the same fence. A non-migrating hook or registry failure
|
||||
restores the prior in-process graph and records verified compensation. A failure
|
||||
after migrations begin remains unresolved because restoring the process-local
|
||||
registry does not reverse database schema effects.
|
||||
|
||||
## Operator Evidence
|
||||
|
||||
The installer run record contains the recovery operation id, mode, plan hash,
|
||||
and current lifecycle status. The Ops recovery view is authoritative for the
|
||||
durable state and evidence-chain result. Keep both the run directory and the
|
||||
state-service backup evidence until the operation is terminal and the normal
|
||||
retention policy permits removal.
|
||||
|
||||
Run the module installer rollback drill and recovery-runtime test matrix before
|
||||
enabling lifecycle mutation in a new deployment. Shared-state deployments must
|
||||
still use immutable release images; the ledger does not make in-place package
|
||||
mutation across replicas safe.
|
||||
@@ -13,6 +13,7 @@ consistent while each module still owns its domain rules.
|
||||
| RBAC/access policy | `govoplan-access` | access capabilities in `govoplan_core.core.access` | Permission decisions should use access capability contracts. Explain responses should adopt `PolicyDecision` when an API-level explanation is added. |
|
||||
| Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. |
|
||||
| Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. |
|
||||
| Definition governance | `govoplan-policy` | capability `policy.definitionGovernance` | Resolves view, edit, run/start, reuse, derive, and automate for system, tenant, group, and user Dataflow/Workflow definitions. |
|
||||
|
||||
## Policy Decision
|
||||
|
||||
@@ -111,6 +112,20 @@ matching compatibility DTOs only for its legacy admin surface. Admin overview
|
||||
responses remain module-local because the same counters are exposed from
|
||||
different menu contexts and are not yet a separately versioned platform API.
|
||||
|
||||
## Definition Governance
|
||||
|
||||
Dataflow and Workflow submit a `DefinitionGovernanceRequest` using only stable
|
||||
scope, principal, status, definition-kind, and limit fields. Policy returns a
|
||||
standard `PolicyDecision`. System definitions may be inherited as read-only;
|
||||
group and user definitions are visible only in matching contexts. Templates
|
||||
may be viewed and derived but never run or automated. A derived definition
|
||||
passes its pinned ancestor limits back through the request context, and Policy
|
||||
applies those limits as ceilings rather than defaults that can be broadened.
|
||||
|
||||
When the capability is absent, modules must not silently emulate cross-scope
|
||||
inheritance. Their conservative fallback is limited to local tenant
|
||||
definitions and disables reuse, derivation, and automation.
|
||||
|
||||
## Frontend Contract
|
||||
|
||||
Policy UIs must:
|
||||
|
||||
@@ -5,6 +5,9 @@ before deciding to replace specialist workflows. This document is the core
|
||||
strategy index. The executable connector catalogue lives in
|
||||
`govoplan-connectors/docs/PUBLIC_SECTOR_INTEGRATION_CATALOGUE.md`.
|
||||
|
||||
The canonical cumulative maturity model and external-object DTO are documented
|
||||
in [EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md](EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md).
|
||||
|
||||
## Strategy Labels
|
||||
|
||||
Use one or more of these labels for every external system family:
|
||||
@@ -172,8 +175,10 @@ connector or module issue.
|
||||
queries, untraceable manual transformations.
|
||||
- MVP test path: publish one report/export as a governed file plus RSS/Atom
|
||||
entry with checksum, timestamp, and permission check.
|
||||
- Owner/priority: `govoplan-reporting`, `govoplan-connectors`, possible future
|
||||
`govoplan-datasources`/`govoplan-dataflow`, Wave 2.
|
||||
- Owner/priority: `govoplan-reporting`, `govoplan-connectors`,
|
||||
`govoplan-datasources`, and `govoplan-dataflow`, Wave 2. Reporting owns
|
||||
presentation/publication, Connectors owns transport, Datasources owns the
|
||||
governed source/materialization catalogue, and Dataflow owns transformations.
|
||||
|
||||
### Public-Sector Protocols And Registries
|
||||
|
||||
|
||||
@@ -40,26 +40,26 @@ cd /mnt/DATA/git/govoplan
|
||||
Update those refs when cutting a release:
|
||||
|
||||
```text
|
||||
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.8
|
||||
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.8
|
||||
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.8
|
||||
govoplan-idm git@git.add-ideas.de:add-ideas/govoplan-idm.git v0.1.8
|
||||
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.8
|
||||
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.8
|
||||
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.8
|
||||
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.8
|
||||
govoplan-dashboard git@git.add-ideas.de:add-ideas/govoplan-dashboard.git v0.1.8
|
||||
govoplan-addresses git@git.add-ideas.de:add-ideas/govoplan-addresses.git v0.1.8
|
||||
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.8
|
||||
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.8
|
||||
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.8
|
||||
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.8
|
||||
govoplan-poll git@git.add-ideas.de:add-ideas/govoplan-poll.git v0.1.8
|
||||
govoplan-scheduling git@git.add-ideas.de:add-ideas/govoplan-scheduling.git v0.1.8
|
||||
govoplan-notifications git@git.add-ideas.de:add-ideas/govoplan-notifications.git v0.1.8
|
||||
govoplan-evaluation git@git.add-ideas.de:add-ideas/govoplan-evaluation.git v0.1.8
|
||||
govoplan-docs git@git.add-ideas.de:add-ideas/govoplan-docs.git v0.1.8
|
||||
govoplan-ops git@git.add-ideas.de:add-ideas/govoplan-ops.git v0.1.8
|
||||
govoplan-tenancy git@git.add-ideas.de:GovOPlaN/govoplan-tenancy.git v0.1.8
|
||||
govoplan-organizations git@git.add-ideas.de:GovOPlaN/govoplan-organizations.git v0.1.8
|
||||
govoplan-identity git@git.add-ideas.de:GovOPlaN/govoplan-identity.git v0.1.8
|
||||
govoplan-idm git@git.add-ideas.de:GovOPlaN/govoplan-idm.git v0.1.8
|
||||
govoplan-access git@git.add-ideas.de:GovOPlaN/govoplan-access.git v0.1.8
|
||||
govoplan-admin git@git.add-ideas.de:GovOPlaN/govoplan-admin.git v0.1.8
|
||||
govoplan-policy git@git.add-ideas.de:GovOPlaN/govoplan-policy.git v0.1.8
|
||||
govoplan-audit git@git.add-ideas.de:GovOPlaN/govoplan-audit.git v0.1.8
|
||||
govoplan-dashboard git@git.add-ideas.de:GovOPlaN/govoplan-dashboard.git v0.1.8
|
||||
govoplan-addresses git@git.add-ideas.de:GovOPlaN/govoplan-addresses.git v0.1.8
|
||||
govoplan-files git@git.add-ideas.de:GovOPlaN/govoplan-files.git v0.1.8
|
||||
govoplan-mail git@git.add-ideas.de:GovOPlaN/govoplan-mail.git v0.1.8
|
||||
govoplan-campaign git@git.add-ideas.de:GovOPlaN/govoplan-campaign.git v0.1.8
|
||||
govoplan-calendar git@git.add-ideas.de:GovOPlaN/govoplan-calendar.git v0.1.8
|
||||
govoplan-poll git@git.add-ideas.de:GovOPlaN/govoplan-poll.git v0.1.8
|
||||
govoplan-scheduling git@git.add-ideas.de:GovOPlaN/govoplan-scheduling.git v0.1.8
|
||||
govoplan-notifications git@git.add-ideas.de:GovOPlaN/govoplan-notifications.git v0.1.8
|
||||
govoplan-evaluation git@git.add-ideas.de:GovOPlaN/govoplan-evaluation.git v0.1.8
|
||||
govoplan-docs git@git.add-ideas.de:GovOPlaN/govoplan-docs.git v0.1.8
|
||||
govoplan-ops git@git.add-ideas.de:GovOPlaN/govoplan-ops.git v0.1.8
|
||||
```
|
||||
|
||||
## WebUI Packages
|
||||
@@ -151,7 +151,8 @@ Current tag-only module repositories:
|
||||
- `govoplan-search`
|
||||
- `govoplan-tasks`
|
||||
- `govoplan-templates`
|
||||
- `govoplan-workflow`
|
||||
- `govoplan-workflow-engine` (headless definitions, migrations, and execution)
|
||||
- `govoplan-workflow` (optional authoring and inspection WebUI)
|
||||
- `govoplan-xoev`
|
||||
- `govoplan-xrechnung`
|
||||
- `govoplan-xta-osci`
|
||||
@@ -788,10 +789,27 @@ tools/checks/postgres-integration-check.py \
|
||||
The script checks migrations and `/health` startup for core-only, files-only,
|
||||
mail-only, campaign-only, campaign+files, campaign+mail, and full-product
|
||||
module sets. `--reset-schema` is destructive and must only be used against a
|
||||
throwaway database.
|
||||
throwaway database. Before those permutations, the required Core proof runs in
|
||||
random, test-owned schemas without modifying `public`. It exercises Files' real
|
||||
credential-owning retirement provider and proves that credential scrubbing,
|
||||
non-secret audit
|
||||
insertion, and table retirement commit together; database-injected audit and
|
||||
DDL failures roll the entire unit back. A 500 ms PostgreSQL `lock_timeout` and
|
||||
captured backend process IDs also prove that each `DROP TABLE` uses the
|
||||
installer Session connection instead of waiting through a second connection.
|
||||
The meta check enables the release-gate flag so missing PostgreSQL configuration
|
||||
or full-stack test packages are a hard failure; ordinary Core-only test discovery
|
||||
skips this integration proof. Do not pass `--skip-retirement-atomicity` when
|
||||
collecting release evidence.
|
||||
|
||||
## Migration Baselines
|
||||
|
||||
Release migration support follows `COMPATIBILITY_POLICY.md`: every tagged
|
||||
`0.1.x` installation is a supported upgrade origin, released revision IDs are
|
||||
immutable, and migration-only reconciliation remains available for at least one
|
||||
subsequent major release cycle after the matching runtime compatibility path is
|
||||
removed.
|
||||
|
||||
Development migrations may be small and numerous while a feature is moving.
|
||||
GovOPlaN keeps those detailed migrations on an explicit development track and
|
||||
publishes reviewed release shortcuts on the release track. Before a stable
|
||||
@@ -881,7 +899,7 @@ before that baseline, so pre-v0.1.7 development revisions are not release
|
||||
upgrade targets. Future release-to-release changes must start from a recorded
|
||||
release baseline and add a new release-track step-up instead of replacing prior
|
||||
release shortcuts. The tracking issue is
|
||||
`add-ideas/govoplan-core#223`.
|
||||
`GovOPlaN/govoplan-core#223`.
|
||||
|
||||
## Related Operator Documents
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Search event indexing contract
|
||||
|
||||
Core defines, but does not implement, the optional Search indexing boundary.
|
||||
Feature modules register `SearchSourceProvider` implementations for bounded
|
||||
backfills and live authorization checks. A provider may additionally implement
|
||||
`SearchEventSourceProvider` to translate a committed `PlatformEvent` into one
|
||||
or more authoritative `SearchIndexChange` values.
|
||||
|
||||
When the Search index-writer capability is active, the platform event worker
|
||||
uses the durable consumer identity `search.indexing.v1`. It accepts only public
|
||||
and internal events, passes the outbox delivery key to each event-capable
|
||||
source, and then advances a bounded batch of queued index changes in the same
|
||||
worker transaction. Stable change IDs make delivery replay idempotent.
|
||||
|
||||
The boundary has three non-negotiable rules:
|
||||
|
||||
- a source may emit changes only for its registered module, provider, resource
|
||||
type, and event tenant;
|
||||
- Search validates every upsert document before queueing it and rejects secret
|
||||
metadata keys;
|
||||
- an index ACL is only a candidate filter. Resources marked for authorization
|
||||
recheck are returned only after the owning source explicitly allows the
|
||||
current principal at query time.
|
||||
|
||||
Search and its worker remain optional. Core-only startup and feature-module
|
||||
operation do not require the Search package.
|
||||
@@ -0,0 +1,155 @@
|
||||
# State And Recovery Contract
|
||||
|
||||
## State Profiles
|
||||
|
||||
Core accepts three runtime state profiles:
|
||||
|
||||
| Profile | Runtime placement | Durable storage |
|
||||
| --- | --- | --- |
|
||||
| `local` | One development process set | Local filesystem is permitted. |
|
||||
| `host-shared` | Replicas on one host | One host-shared volume plus PostgreSQL and Redis. |
|
||||
| `shared` | Independent hosts | PostgreSQL, Redis, and S3-compatible object storage. |
|
||||
|
||||
All replicas in one installation use one stable
|
||||
`GOVOPLAN_INSTALLATION_ID`, one immutable module composition, and identical
|
||||
database, broker, encryption-key, and object-storage bindings. Core rejects
|
||||
replicas with the `local` profile and rejects `shared` without PostgreSQL,
|
||||
Redis, S3, and a non-default installation identifier.
|
||||
|
||||
`FILE_STORAGE_S3_ENDPOINT_TRUSTED=true` is an explicit deployment trust
|
||||
declaration for a clean HTTPS S3 origin. It does not authorize a
|
||||
user-controlled connector endpoint and it is separate from installer-managed
|
||||
Garage's exact endpoint trust.
|
||||
|
||||
## Object Storage
|
||||
|
||||
`govoplan_core.core.object_storage` is the shared backend contract for durable
|
||||
module artifacts. It provides bounded read/write/list/stat/delete operations
|
||||
for local and S3-compatible storage. Modules own their object-key namespace and
|
||||
business metadata; Core does not interpret module files.
|
||||
|
||||
`stat` and `list_objects` return object size plus a UTC `modified_at` value when
|
||||
the backend can prove it. Reconciliation and retention code may use that value
|
||||
for conservative grace periods, but must treat a missing timestamp as
|
||||
ineligible for automatic deletion rather than guessing an age.
|
||||
|
||||
Rules for modules:
|
||||
|
||||
- Store only opaque object keys in business records, never local absolute
|
||||
paths.
|
||||
- Use node-local directories only for temporary materialization.
|
||||
- Verify expected size and digest before consuming consequential artifacts.
|
||||
- If object creation precedes database commit, compensate successfully created
|
||||
objects on failure.
|
||||
- If object deletion fails, retain the database reference and report a retryable
|
||||
failure rather than claiming deletion.
|
||||
- Define an orphan-inventory strategy for hard process loss between object
|
||||
creation and metadata commit.
|
||||
|
||||
The Files module delegates its backend implementation to this Core contract.
|
||||
Campaign generated EML artifacts use a Campaign-owned object prefix and are
|
||||
read by workers through the same shared backend.
|
||||
|
||||
## Runtime Nodes And Leases
|
||||
|
||||
API and worker incarnations register in `core_runtime_nodes` with role,
|
||||
software version, module-composition hash, queues, start time, and heartbeat.
|
||||
The registration identity includes a process incarnation so a stale process
|
||||
cannot update a replacement's row.
|
||||
|
||||
Worker metadata also records the orchestrator pool and declared concurrency.
|
||||
Every Celery prefork child disposes the SQLAlchemy pool inherited from its
|
||||
parent and creates a process-local pool before handling work. Deployment
|
||||
rendering must therefore budget one database pool for the worker parent and
|
||||
each child. Ops compares active queue ownership, software versions, and the
|
||||
order-independent module-composition hash with the graph loaded by the API.
|
||||
|
||||
Drain is durable operator intent:
|
||||
|
||||
- an API enters not-ready state after observing drain;
|
||||
- a worker cancels queue consumers after observing drain;
|
||||
- cancellation returns an eligible draining node to active state;
|
||||
- clean shutdown marks the matching incarnation stopped.
|
||||
|
||||
Coordination loss also fails closed. An API reports
|
||||
`coordination_unavailable` from `/health/ready` until a heartbeat succeeds
|
||||
again. A worker cancels its local queue consumers on any heartbeat or database
|
||||
failure and only resumes them after its existing incarnation heartbeats
|
||||
successfully. It never re-registers from the heartbeat path, so a stale worker
|
||||
cannot reclaim a node identity from its replacement.
|
||||
|
||||
`core_distributed_leases` provides installation/resource uniqueness, expiry,
|
||||
holder incarnation, and monotonically increasing fencing tokens. Lease expiry
|
||||
does not make an old process harmless by itself: code performing an effect must
|
||||
assert its exact fence before commit. `govoplan_core.commands.fenced_run`
|
||||
renews a lease around a subprocess and terminates the child when the lease is
|
||||
lost. The deployment profiles use it for the singleton scheduler.
|
||||
|
||||
## Migration Ordering
|
||||
|
||||
Only `govoplan_core.commands.init_db` mutates schema. On PostgreSQL it acquires
|
||||
a deterministic installation/track advisory lock before pre-migration tasks,
|
||||
Alembic, and post-migration tasks. The lock is session-scoped and therefore
|
||||
released if the migration process dies.
|
||||
|
||||
Runtime roles run `govoplan_core.commands.wait_for_database`. It waits until
|
||||
the database has exactly the configured Core/module Alembic heads and never
|
||||
upgrades schema. This permits a migration Job and runtime Deployments to be
|
||||
submitted together while keeping startup fail-closed.
|
||||
|
||||
## Recovery Ledger
|
||||
|
||||
`govoplan_core.core.recovery` provides a durable operation and evidence
|
||||
contract. Recovery modes are:
|
||||
|
||||
- `atomic`: one database transaction, no external effect;
|
||||
- `compensation`: explicit inverse actions;
|
||||
- `snapshot_restore`: separately verified backup reference;
|
||||
- `forward_recovery`: repair/resume the current version;
|
||||
- `irreversible`: explicit approval, no automated recovery claim.
|
||||
|
||||
Every plan requires verification steps. Mode-specific evidence is mandatory.
|
||||
Operations bind an idempotency key to a canonical request hash, may bind a
|
||||
runtime fencing token, and emit append-only hash-chained checkpoints. Backup
|
||||
and approval references are part of the hashed plan evidence. Every low-level
|
||||
state transition and checkpoint append revalidates the operation's recorded
|
||||
fence while holding the operation row lock. Plaintext secrets are rejected
|
||||
from metadata and evidence.
|
||||
|
||||
The state machine makes partial and uncertain outcomes visible. A non-atomic
|
||||
running operation cannot transition directly to ordinary failure, and success
|
||||
or recovery requires explicit verified checks. Ops projects states requiring
|
||||
attention, but module behavior gains this guarantee only after it adopts the
|
||||
ledger around its own side effects.
|
||||
|
||||
## Recovery Boundary
|
||||
|
||||
Application/configuration rollback and database rollback are not equivalent.
|
||||
Once an incompatible migration starts, old code may be unsafe even if its image
|
||||
is available. Deployment automation must switch to forward recovery unless a
|
||||
coordinated and verified database/object/key backup is restored.
|
||||
|
||||
Core does not create production database backups. The deployment owner must
|
||||
provide backup, retention, encryption, restore verification, and recovery-point
|
||||
coordination for PostgreSQL, object storage, and encryption keys. The canonical
|
||||
operator procedure is documented in
|
||||
`govoplan/docs/RECOVERY_AND_ROLLBACK_GUARANTEES.md`.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused contracts are covered by:
|
||||
|
||||
```sh
|
||||
/mnt/DATA/git/govoplan/.venv/bin/python -m pytest -q \
|
||||
tests/test_object_storage.py \
|
||||
tests/test_runtime_coordination.py \
|
||||
tests/test_runtime_agents.py \
|
||||
tests/test_fenced_run.py \
|
||||
tests/test_migration_lock.py \
|
||||
tests/test_wait_for_database.py \
|
||||
tests/test_recovery_guarantees.py
|
||||
```
|
||||
|
||||
Production acceptance additionally requires multi-node failure and coordinated
|
||||
restore drills against the actual PostgreSQL, Redis, object-store, ingress, and
|
||||
secret-provider topology.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Tabular Source Preview Contract
|
||||
|
||||
Core defines provider-neutral DTOs for optional tabular source providers. A
|
||||
source declares whether it is live, cached, file-backed, or static; its schema
|
||||
and immutable fingerprint; structured health; and the exact projection,
|
||||
pagination, filter, aggregation, and sorting operations that the provider can
|
||||
push down. Consumers must not infer pushdown support from a provider name.
|
||||
|
||||
Every preview request carries independent row, byte, and elapsed-time budgets.
|
||||
A provider may tighten these values but must return its effective limits,
|
||||
returned byte count, elapsed milliseconds, truncation state, and structured
|
||||
diagnostics. Equivalent fields on the Datasources read request and result
|
||||
preserve that evidence when a live source is consumed through the catalogue.
|
||||
A row that cannot fit within the byte budget fails explicitly rather than
|
||||
leaking a partial value. Timeout, stale fingerprint, unavailable source, and
|
||||
authorization failures remain distinct provider-neutral errors.
|
||||
|
||||
Connector health and preview diagnostics must contain no credentials, endpoint
|
||||
userinfo, row values, or unbounded remote error bodies. A Datasource origin
|
||||
preserves this contract so registration and staging do not erase source mode,
|
||||
health, pushdown, or preview-limit evidence.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Template And Generated Artifact Capability Contracts
|
||||
|
||||
Core defines provider-neutral contracts for optional template libraries and
|
||||
generated artifact storage. Core does not render templates or store generated
|
||||
files itself.
|
||||
|
||||
## Templates
|
||||
|
||||
- `templates.catalog` lists typed, versioned template references and checks a
|
||||
consumer's available fields, usage, and output format.
|
||||
- `templates.renderer` accepts a `TemplateRenderRequest` containing pinned
|
||||
input data and returns immutable render evidence plus an artifact reference.
|
||||
|
||||
The DTOs contain identifiers, hashes, plain mappings, and scalar metadata. They
|
||||
do not expose Template ORM models or require Campaign, Distribution Lists,
|
||||
Addresses, Reporting, Forms, or Mail.
|
||||
|
||||
## Generated Artifacts
|
||||
|
||||
`files.artifact_store` accepts a `ManagedArtifactWriteRequest` and returns a
|
||||
`ManagedArtifactRef`. Producers supply bytes, a safe filename/content type,
|
||||
idempotency key, and non-secret provenance. Files owns path normalization,
|
||||
authorization, versions, storage, and download behavior.
|
||||
|
||||
Consumers must discover both contracts through the module registry and degrade
|
||||
only the unavailable path. A template renderer may return a bounded download
|
||||
when Files is absent. A caller must not infer successful external delivery from
|
||||
successful rendering or artifact persistence.
|
||||
@@ -0,0 +1,28 @@
|
||||
# WebUI Theme Contract
|
||||
|
||||
GovOPlaN supports `system`, `light`, and `dark` as persisted user preferences.
|
||||
`system` follows `prefers-color-scheme` live; it is not resolved permanently at
|
||||
save time. Core applies the resolved mode through `data-theme` on the document
|
||||
root and exposes the selected preference through `data-theme-preference`.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Core owns semantic CSS tokens, native `color-scheme`, preference persistence,
|
||||
the Settings selector, and the shared shell.
|
||||
- Modules consume semantic tokens such as `--surface`, `--text`, `--line`, and
|
||||
the status token families. They may define domain aliases whose values resolve
|
||||
to shared tokens.
|
||||
- User preference selects the mode. Tenant and system policy may provide a
|
||||
future default, but must not silently replace an explicit user choice.
|
||||
- Tenant branding is a separate policy surface and must preserve contrast and
|
||||
status semantics in both modes.
|
||||
|
||||
Do not introduce fixed foreground/background colors in a module merely to make
|
||||
one mode look correct. Add or reuse a semantic Core token, then define both
|
||||
light and dark values. Bitmap content and externally authored HTML are exempt,
|
||||
but their surrounding controls must still use the shared tokens.
|
||||
|
||||
`npm run test:theme-contract` verifies the root behavior and representative
|
||||
Campaign, Calendar, Files, and Mail token consumption. The check runs before a
|
||||
production WebUI build.
|
||||
|
||||
+7
-4
@@ -3,10 +3,13 @@
|
||||
Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints
|
||||
that need bounded fixed-window counters. Subjects are SHA-256 hashed before they
|
||||
become store keys. A configured Redis instance provides atomic counters shared
|
||||
across API workers; development, a missing Redis configuration, and temporary
|
||||
Redis outages use a bounded process-local fallback. When Redis fails, local
|
||||
attempts are still mirrored so losing the distributed store does not reset the
|
||||
active worker's protection window.
|
||||
across API workers. Development and temporary Redis outages use a bounded
|
||||
process-local fallback. Production-like startup rejects an enabled login
|
||||
throttle without `REDIS_URL` unless
|
||||
`GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` explicitly acknowledges the
|
||||
single-process limitation. When Redis fails at runtime, local attempts are still
|
||||
mirrored so losing the distributed store does not reset the active worker's
|
||||
protection window.
|
||||
|
||||
Callers define one or more `ThrottleDimension` values with a controlled
|
||||
namespace, a subject and a positive limit. They must call `check` before an
|
||||
|
||||
@@ -6,7 +6,7 @@ binding design reference: future implementation should follow these decisions
|
||||
unless the decision is explicitly revised here and affected screens are updated
|
||||
to match.
|
||||
|
||||
Active tracking issue: `add-ideas/govoplan-core#225`.
|
||||
Active tracking issue: `GovOPlaN/govoplan-core#225`.
|
||||
|
||||
## Operating Rule
|
||||
|
||||
@@ -50,6 +50,11 @@ contestability, responsibility, and traceability at the point of action.
|
||||
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
|
||||
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
|
||||
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
|
||||
| UX-027 | The platform icon rail keeps its brand header and utility footer visible. Only the module-navigation region scrolls when installed and permitted modules exceed the available viewport height. | Accepted | Core WebUI shell |
|
||||
| UX-028 | Maintenance and offline state change the titlebar surface and repeat a quiet status label behind its controls. They must not replace, cover, or intercept the centered global-search surface; an accessible status control remains in the leading titlebar area. | Accepted | Core WebUI shell |
|
||||
| UX-029 | Recoverable page and module errors use the central compact `DismissibleAlert` presentation with an explicit recovery action where one exists. Full-height workspaces must overlay page feedback instead of allowing an alert to become a stretched workspace row. | Accepted | Core and module WebUIs |
|
||||
| UX-030 | At narrow widths, the titlebar uses separate context and command rows. Context selectors remain horizontally reachable, search retains its compact trigger, and language/help/notification/account commands remain fixed icon controls without overlap. Shared content padding contracts so domain workspaces retain usable width. | Accepted | Core WebUI shell and all module workspaces |
|
||||
| UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs |
|
||||
|
||||
## Confirmed Implementation Decisions
|
||||
|
||||
@@ -223,6 +228,10 @@ instead of reproducing their behavior.
|
||||
- `help` content is contextual guidance, not the accessible name. The persisted
|
||||
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by
|
||||
applying `ui-hide-help-hints` at the document root.
|
||||
- Shared action-bearing components accept an optional disabled reason. In
|
||||
particular, `MailServerSettingsPanel` forwards protocol-specific test
|
||||
blockers into the shared focusable disabled-action tooltip; modules provide
|
||||
the domain-specific required field, permission, or in-progress reason.
|
||||
- A dirty editor registers once with `useUnsavedDraftGuard`. An explicit
|
||||
Discard button calls `useUnsavedChanges().requestDiscard(afterResolve)`; SPA
|
||||
navigation uses `useGuardedNavigate` or `requestNavigation`. Both paths show
|
||||
@@ -233,9 +242,37 @@ instead of reproducing their behavior.
|
||||
`disabledReason` for row state; omit an action only when that action does not
|
||||
belong to the table. `minimumSlots` reserves trailing positions for an empty
|
||||
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
|
||||
- A paginated `DataGrid` has exactly one query owner. Client mode receives the
|
||||
complete logical row set and applies filtering and sorting before slicing a
|
||||
page. Server mode receives only the loaded page, requires `onQueryChange`,
|
||||
and the backend applies every emitted filter/sort before pagination while
|
||||
returning `totalRows` for the filtered result. Server list filters declare
|
||||
their complete option domain instead of deriving it from the loaded page.
|
||||
External filter affordances such as summary-count shortcuts update the
|
||||
grid's `query` contract; the grid header controls and backend query therefore
|
||||
always display and execute the same filter state.
|
||||
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
|
||||
`DismissibleAlert`. They never fall back to `window.alert`.
|
||||
|
||||
### DUE-012: Rich HTML Editing Contract
|
||||
|
||||
Decision: modules that edit persisted HTML use the central
|
||||
`WysiwygEditor` exported from `@govoplan/core-webui/wysiwyg`.
|
||||
|
||||
- The dedicated subpath is intentional: the editor and its engine remain a
|
||||
shared Core contract without adding their code to module combinations that
|
||||
never consume rich-text editing.
|
||||
- Consumers provide controlled HTML and domain-specific token labels. The
|
||||
editor owns visual/source switching, formatting, links, images, safe URL
|
||||
handling, and atomic inline token rendering; it does not own template
|
||||
semantics or persistence.
|
||||
- Existing HTML outside the supported visual subset opens in source mode.
|
||||
Rendering the value must not rewrite it, and users receive an explicit
|
||||
warning before choosing the visual surface.
|
||||
- Domain placeholders remain their original serialized text. Atomic token
|
||||
presentation is an editing aid only, so backend renderers and existing
|
||||
templates do not need a new storage format.
|
||||
|
||||
#### FieldLabel Omission Register
|
||||
|
||||
Every Core field surface that intentionally does not render `FieldLabel` is
|
||||
@@ -244,7 +281,7 @@ UI documentation until a central cross-repository audit is available.
|
||||
|
||||
| Core scope | Why `FieldLabel` is omitted | Accessible/context label source |
|
||||
| --- | --- | --- |
|
||||
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
|
||||
| `PasswordField`, `ColorPickerField`, `DateField`, `TimeField`, and `DateTimeField` input internals | These are label-neutral composite primitives and are placed inside `FormField`/`FieldLabel` by the consuming form. Rendering another label inside the primitive would duplicate it. `PasswordField` may opt into the shared cryptographic generator; the candidate dialog is subordinate to the enclosing field and commits only through its explicit Use action. | Enclosing label; a direct consumer must pass an accessible name and record that direct composition here. |
|
||||
| `ToggleSwitch` native checkbox | The shared component already renders its visible text through `FieldLabel`; the native input must not render a second label. | The enclosing native label and derived `aria-label`. |
|
||||
| `FileDropZone` hidden file input | The input is an implementation detail of the labelled keyboard-operable drop target. | Drop target text and `inputLabel`/`aria-label`. |
|
||||
| `AdminSelectionList` and `DataGrid` list-filter checkboxes | Each option is self-explanatory and already enclosed by its visible option label. | Enclosing native option label. |
|
||||
@@ -275,14 +312,14 @@ converted or reviewed.
|
||||
|
||||
| Surface | Repository | UX State | Next Action |
|
||||
| --- | --- | --- | --- |
|
||||
| File connector settings | `govoplan-files` | First adaptive modal slice started: connections and credentials now use full-state create/edit forms with conditional fields, advanced panels, and blocker primitives. Wizard shell is retained for later assisted setup. Central policy card still needs a layered editor. | Finish provider discovery/test-in-flow, then convert policy editing. |
|
||||
| Mail server settings | `govoplan-mail` / `govoplan-core` | Uses the shared server/credential model visually, but create/edit still needs the same adaptive pattern as files. | Migrate to adaptive server/credential/policy dialogs, with optional assisted wizard later. |
|
||||
| File connector settings | `govoplan-files` | Migrated to the shared adaptive server/credential/policy pattern with provider discovery, typed controls, actionable blockers, consequence-aware removal, and module-owned verification evidence. | Continue only through bounded Files-owned follow-ups. |
|
||||
| Mail server settings | `govoplan-mail` / `govoplan-core` | Migrated to the same layered profile/server/credential/policy pattern, including focused connection tests, unsaved-state handling, contextual help, and permission/target blockers. | Continue only through bounded Mail-owned follow-ups. |
|
||||
| Connector policy/effective rows | `govoplan-core`, module UIs | Effective-policy direction exists, but many editors still expose broad option sets. | Put effective value first, move overrides into modal, and explain blocked edits. |
|
||||
| Admin module management | `govoplan-admin` | Has preflight concepts, but operational choices are still technical and dense. | Convert install/uninstall/package changes to operator wizards. |
|
||||
| Configuration packages | `govoplan-admin` | Catalog/import work exists, but package editing can still drift toward technical fields. | Add guided import/review/problem-list flow. |
|
||||
| Retention and privacy | `govoplan-core` | Functional editor exists; consequence language and provenance can be stronger. | Layer advanced retention options and add review for broad changes. |
|
||||
| Retention and privacy | `govoplan-core` | Typed effective-policy editor exposes source paths, narrowing semantics, platform locks, permission/target blockers, and explicit clean/loading/save states. | Broader governed-change review remains module-owned where a policy change requires approval. |
|
||||
| API keys | `govoplan-access` / admin UI | Security-sensitive creation needs least-privilege guidance. | Add scoped creation wizard with expiry/owner review. |
|
||||
| User settings | `govoplan-core` | Preferences persistence exists; interface navigation issue was fixed earlier, but the surface still needs UX review. | Keep simple sections, remove double-click traps, and add quiet explanations. |
|
||||
| User settings | `govoplan-core` | Simple typed sections use unsaved-change guards, quiet result feedback, contextual help, and explicit busy/clean disabled-action reasons. | Keep bounded; new contributed sections must satisfy the checklist. |
|
||||
|
||||
## Impact Index
|
||||
|
||||
@@ -311,6 +348,11 @@ Every new or changed admin/configuration surface should answer:
|
||||
- Does the screen explain disabled actions and failed validation in plain
|
||||
language?
|
||||
- Does it say who can fix a blocker and where?
|
||||
- Does a module-localized blocker pass its translated row labels through the
|
||||
shared `ActionBlockerHint` contract instead of reproducing the component?
|
||||
- Does longer field or blocker guidance use a stable `DocumentationHelpLink`
|
||||
topic/context reference, with hosted fallback when the optional Docs module
|
||||
is absent?
|
||||
- Does it reuse existing core patterns for wizard steps, problem lists, modals,
|
||||
help, and review?
|
||||
- Is there a review or preflight step before broad, destructive, or risky
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# WebUI Loading And Bundle Budgets
|
||||
|
||||
The Core WebUI host owns the loading boundary for installed module packages.
|
||||
Vite discovers configured packages at build time, but emits an asynchronous
|
||||
loader for each package's `src/module.ts` contribution descriptor. At runtime,
|
||||
Core imports only descriptors whose backend manifests are enabled and identify
|
||||
the matching `frontend.package_name`.
|
||||
|
||||
The direct descriptor entry is intentional. A package root may re-export pages
|
||||
for consumers; importing that barrel as module wiring can cause those pages to
|
||||
be evaluated before navigation. Route pages and substantial panels should use
|
||||
`React.lazy`, and Core wraps routes in the shared loading/error boundary.
|
||||
|
||||
## Enforced Budgets
|
||||
|
||||
`webui/bundle-budget.json` contains the production limits:
|
||||
|
||||
| Measurement | Raw limit | Gzip limit |
|
||||
| --- | ---: | ---: |
|
||||
| Initial JavaScript static import closure | 512 KiB | 160 KiB |
|
||||
| Largest individual asynchronous JavaScript chunk | 384 KiB | 110 KiB |
|
||||
|
||||
`npm run build` writes a Vite manifest, measures the entry and its recursive
|
||||
static imports, writes `dist/bundle-metrics.json`, and fails when either budget
|
||||
is exceeded. `npm run test:module-permutations` applies the same gate to every
|
||||
permutation and records the collected results in
|
||||
`dist/module-permutation-bundle-metrics.json`. In CI, each result is also added
|
||||
to the step summary.
|
||||
|
||||
Budgets are limits, not targets. A change that approaches a limit should add a
|
||||
new lazy boundary or remove unnecessary entry code instead of raising the
|
||||
limit without measurement and review.
|
||||
|
||||
## 2026-07-30 Baseline
|
||||
|
||||
Measurements use the same full-product source tree and Node 22 runtime. The
|
||||
post-change build additionally includes the Search module in the default and
|
||||
full-product sets.
|
||||
|
||||
| Initial-load measurement | Before | After | Reduction |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| JavaScript assets in initial static closure | 1 | 1 | 0% |
|
||||
| Raw JavaScript | 1,387,043 B | 453,769 B | 67.3% |
|
||||
| Gzip level 9 | 364,767 B | 141,725 B | 61.1% |
|
||||
| Brotli quality 11 | 254,797 B | 106,701 B | 58.1% |
|
||||
| Parse proxy median | 18.776 ms | 7.750 ms | 58.7% |
|
||||
| Parse proxy p95 | 21.980 ms | 8.739 ms | 60.2% |
|
||||
|
||||
The parse proxy constructs a fresh `node:vm` `SourceTextModule` from the entry
|
||||
source 30 times with a randomized source marker. It is useful for a controlled
|
||||
before/after comparison, but is not enforced in CI because absolute timings
|
||||
vary across runners. Transfer budgets use deterministic raw and gzip byte
|
||||
counts.
|
||||
|
||||
The first budgeted full-product build reported:
|
||||
|
||||
- initial JavaScript: 453,769 B raw / 141,725 B gzip;
|
||||
- largest async chunk: `CampaignWorkspace`, 353,724 B raw / 98,142 B gzip.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-core/webui
|
||||
npm run build
|
||||
npm run check:bundle-budget
|
||||
npm run test:module-permutations
|
||||
```
|
||||
|
||||
The build gate also catches accidental eager imports: a page pulled into the
|
||||
entry closure consumes the initial budget, while an oversized page or module
|
||||
descriptor consumes the asynchronous chunk budget.
|
||||
@@ -0,0 +1,12 @@
|
||||
# WebUI Module Package Layout
|
||||
|
||||
Core discovers a module contribution from `src/module.ts` when `node_modules`
|
||||
links directly to a module's `webui` package. Tagged release dependencies are
|
||||
installed from repository-root packages and expose the same contribution at
|
||||
`webui/src/module.ts`. The Vite registry accepts both layouts and imports the
|
||||
contribution descriptor directly so route-level lazy loading is preserved.
|
||||
|
||||
A release package is invalid if neither entry exists. The module-permutation CI
|
||||
matrix builds source-linked and installed release compositions; it must not fall
|
||||
back to a package root barrel because that would eagerly pull module pages into
|
||||
the shell bundle.
|
||||
@@ -8,7 +8,7 @@
|
||||
"description": "Portal form, case workflow, task creation, mail notification, payment setup, and access roles for a simple application process.",
|
||||
"publisher": "ADD ideas",
|
||||
"category": "workflow",
|
||||
"artifact_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-configuration-packages.git@application-handling-basic-v0.1.0",
|
||||
"artifact_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-configuration-packages.git@application-handling-basic-v0.1.0",
|
||||
"artifact_sha256": "<sha256>",
|
||||
"required_modules": [
|
||||
{ "module_id": "portal", "version": ">=0.1.0" },
|
||||
|
||||
@@ -81,8 +81,8 @@
|
||||
],
|
||||
"recorded_at": "2026-07-11T01:39:45Z",
|
||||
"release": "0.1.7",
|
||||
"track": "release",
|
||||
"squash_policy": "reviewed-manual"
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
@@ -165,8 +165,906 @@
|
||||
],
|
||||
"recorded_at": "2026-07-20T02:45:41Z",
|
||||
"release": "0.1.8",
|
||||
"track": "release",
|
||||
"squash_policy": "reviewed-manual"
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "4d5e6f7a9203"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "608192abcdef"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revision": "6e7f8a9b0c1d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revision": "6f7a8b9c0d1e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "8f9a0b1c2d3e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "a7b8c9d0e1f3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "af1b2c3d4e5f"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revision": "c9d4e7f1a2b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revision": "e1f2a4b5c6d"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"4a5b6c7d8e9f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"e1f2a4b5c6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"af1b2c3d4e5f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"4d5e6f7a9203"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"4f2a9c8e7b6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"a7b8c9d0e1f3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"8f9a0b1c2d3e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"608192abcdef"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revisions": [
|
||||
"6f7a8b9c0d1e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"6d7e8f9a0b1c"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revisions": [
|
||||
"6e7f8a9b0c1d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revisions": [
|
||||
"c9d4e7f1a2b3"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-07-22T02:42:27Z",
|
||||
"release": "0.1.11",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "608192abcdef"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revision": "6e7f8a9b0c1d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revision": "6f7a8b9c0d1e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "8f9a0b1c2d3e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "a7b8c9d0e1f3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "af1b2c3d4e5f"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revision": "c9d4e7f1a2b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "d8b3e2c1f4a5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revision": "e1f2a4b5c6d"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"4a5b6c7d8e9f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"e1f2a4b5c6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"af1b2c3d4e5f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"d8b3e2c1f4a5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"4f2a9c8e7b6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"a7b8c9d0e1f3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"8f9a0b1c2d3e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"608192abcdef"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revisions": [
|
||||
"6f7a8b9c0d1e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"6d7e8f9a0b1c"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revisions": [
|
||||
"6e7f8a9b0c1d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revisions": [
|
||||
"c9d4e7f1a2b3"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-07-22T07:06:21Z",
|
||||
"release": "0.1.12",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "608192abcdef"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revision": "6e7f8a9b0c1d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revision": "6f7a8b9c0d1e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "8f9a0b1c2d3e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "a7b8c9d0e1f3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "af1b2c3d4e5f"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revision": "c9d4e7f1a2b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "d8b3e2c1f4a5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revision": "e1f2a4b5c6d"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"4a5b6c7d8e9f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"e1f2a4b5c6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"af1b2c3d4e5f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"d8b3e2c1f4a5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"4f2a9c8e7b6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"a7b8c9d0e1f3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"8f9a0b1c2d3e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"608192abcdef"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revisions": [
|
||||
"6f7a8b9c0d1e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"6d7e8f9a0b1c"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revisions": [
|
||||
"6e7f8a9b0c1d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revisions": [
|
||||
"c9d4e7f1a2b3"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-07-22T08:39:02Z",
|
||||
"release": "0.1.13",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "608192abcdef"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revision": "6e7f8a9b0c1d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revision": "6f7a8b9c0d1e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "8f9a0b1c2d3e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "a7b8c9d0e1f3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "af1b2c3d4e5f"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revision": "c9d4e7f1a2b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "d8b3e2c1f4a5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revision": "e1f2a4b5c6d"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"4a5b6c7d8e9f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"e1f2a4b5c6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"af1b2c3d4e5f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"d8b3e2c1f4a5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"4f2a9c8e7b6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"a7b8c9d0e1f3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"8f9a0b1c2d3e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"608192abcdef"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revisions": [
|
||||
"6f7a8b9c0d1e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"6d7e8f9a0b1c"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revisions": [
|
||||
"6e7f8a9b0c1d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revisions": [
|
||||
"c9d4e7f1a2b3"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-07-22T18:15:01Z",
|
||||
"release": "0.1.14",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revision": "6e2f91ab4c70"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revision": "6e7f8a9b0c1d"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dashboard",
|
||||
"revision": "7b9d2f4a6c8e"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-voting",
|
||||
"revision": "8b9c0d1e2f3a"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revision": "93b4c5d6e7f8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms-runtime",
|
||||
"revision": "a3d5f7b9c1e2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-templates",
|
||||
"revision": "a3f7c9d2e1b4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revision": "a61e4d9c72b8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mandates",
|
||||
"revision": "a8b1c2d3e4f5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-audit",
|
||||
"revision": "a8d1e4f7b2c5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-approvals",
|
||||
"revision": "a91c4e72b5d8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-policy",
|
||||
"revision": "a9c4e7b2d5f8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revision": "b1c2d3e4f5a6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-search",
|
||||
"revision": "b2c3d4e5f607"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-datasources",
|
||||
"revision": "b8d2f5a0c3e7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-views",
|
||||
"revision": "b8e4c1f7a2d9"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-risk-compliance",
|
||||
"revision": "b9c0d1e2f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-services",
|
||||
"revision": "b9c2d3e4f5a6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-parties",
|
||||
"revision": "c0d3e4f5a6b7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity-trust",
|
||||
"revision": "c3f5a7b9d1e2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-projects",
|
||||
"revision": "c4a1e8f2d6b9"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revision": "c5d7e8f9a0b1"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revision": "c7e0a3d6f9b2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-reporting",
|
||||
"revision": "c8d5e2f6a9b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revision": "c9d4e7f1a2b3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-decisions",
|
||||
"revision": "d1e4f5a6b7c8"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revision": "d24e5f607182"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-committee",
|
||||
"revision": "d8b9f0a1c2e3"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-postbox",
|
||||
"revision": "d8e3f6a9b2c5"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "e3c8f4a5b6d7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-workflow-engine",
|
||||
"revision": "e4a1f8c2d7b6"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-encryption",
|
||||
"revision": "e5b7c9d1f3a4"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dist-lists",
|
||||
"revision": "e7c3a9d1b5f2"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revision": "f1a2b3c4d5e7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revision": "f25c9d3e7a01"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revision": "f6c2a9d4e7b1"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-cases",
|
||||
"revision": "f6d3a8b1c4e7"
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-connectors",
|
||||
"revision": "f7c8d9e0a1b2"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"c7e0a3d6f9b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"c5d7e8f9a0b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-approvals",
|
||||
"revisions": [
|
||||
"a91c4e72b5d8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-audit",
|
||||
"revisions": [
|
||||
"a8d1e4f7b2c5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"d24e5f607182"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"e3c8f4a5b6d7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-cases",
|
||||
"revisions": [
|
||||
"f6d3a8b1c4e7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-committee",
|
||||
"revisions": [
|
||||
"d8b9f0a1c2e3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-connectors",
|
||||
"revisions": [
|
||||
"f7c8d9e0a1b2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-core",
|
||||
"revisions": [
|
||||
"f25c9d3e7a01"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dashboard",
|
||||
"revisions": [
|
||||
"7b9d2f4a6c8e"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dataflow",
|
||||
"revisions": [
|
||||
"f6c2a9d4e7b1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-datasources",
|
||||
"revisions": [
|
||||
"b8d2f5a0c3e7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-decisions",
|
||||
"revisions": [
|
||||
"d1e4f5a6b7c8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-dist-lists",
|
||||
"revisions": [
|
||||
"e7c3a9d1b5f2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-encryption",
|
||||
"revisions": [
|
||||
"e5b7c9d1f3a4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-files",
|
||||
"revisions": [
|
||||
"f1a2b3c4d5e7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms",
|
||||
"revisions": [
|
||||
"e1f2a3b4c5d6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-forms-runtime",
|
||||
"revisions": [
|
||||
"a3d5f7b9c1e2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity",
|
||||
"revisions": [
|
||||
"5c6d7e8f9a10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-identity-trust",
|
||||
"revisions": [
|
||||
"c3f5a7b9d1e2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-idm",
|
||||
"revisions": [
|
||||
"b1c2d3e4f5a6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mail",
|
||||
"revisions": [
|
||||
"93b4c5d6e7f8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-mandates",
|
||||
"revisions": [
|
||||
"a8b1c2d3e4f5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-notifications",
|
||||
"revisions": [
|
||||
"6e2f91ab4c70"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-organizations",
|
||||
"revisions": [
|
||||
"a61e4d9c72b8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-parties",
|
||||
"revisions": [
|
||||
"c0d3e4f5a6b7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-policy",
|
||||
"revisions": [
|
||||
"a9c4e7b2d5f8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-poll",
|
||||
"revisions": [
|
||||
"6e7f8a9b0c1d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-postbox",
|
||||
"revisions": [
|
||||
"d8e3f6a9b2c5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-projects",
|
||||
"revisions": [
|
||||
"c4a1e8f2d6b9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-reporting",
|
||||
"revisions": [
|
||||
"c8d5e2f6a9b3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-risk-compliance",
|
||||
"revisions": [
|
||||
"b9c0d1e2f3a4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-scheduling",
|
||||
"revisions": [
|
||||
"c9d4e7f1a2b3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-search",
|
||||
"revisions": [
|
||||
"b2c3d4e5f607"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-services",
|
||||
"revisions": [
|
||||
"b9c2d3e4f5a6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-templates",
|
||||
"revisions": [
|
||||
"a3f7c9d2e1b4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-views",
|
||||
"revisions": [
|
||||
"b8e4c1f7a2d9"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-voting",
|
||||
"revisions": [
|
||||
"8b9c0d1e2f3a"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-workflow-engine",
|
||||
"revisions": [
|
||||
"e4a1f8c2d7b6"
|
||||
]
|
||||
}
|
||||
],
|
||||
"recorded_at": "2026-08-04T13:09:52Z",
|
||||
"release": "0.1.15",
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
"version": "0.1.4",
|
||||
"action": "install",
|
||||
"python_package": "govoplan-files",
|
||||
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
|
||||
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
|
||||
"webui_package": "@govoplan/files-webui",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
|
||||
"migration_safety": "forward_only",
|
||||
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
||||
"migration_after": ["access"],
|
||||
@@ -54,14 +54,14 @@
|
||||
],
|
||||
"artifact_integrity": {
|
||||
"python": {
|
||||
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
|
||||
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
|
||||
"sha256": "<sha256 of the resolved Python artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-0.1.4.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-0.1.4.intoto.jsonl",
|
||||
"git_ref": "refs/tags/v0.1.4"
|
||||
},
|
||||
"webui": {
|
||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
|
||||
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
|
||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-webui-0.1.4.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-webui-0.1.4.intoto.jsonl",
|
||||
@@ -78,9 +78,9 @@
|
||||
"version": "0.1.4",
|
||||
"action": "install",
|
||||
"python_package": "govoplan-mail",
|
||||
"python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",
|
||||
"python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
|
||||
"webui_package": "@govoplan/mail-webui",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
|
||||
"provides_interfaces": [
|
||||
{
|
||||
"name": "mail.campaign_delivery",
|
||||
@@ -97,14 +97,14 @@
|
||||
],
|
||||
"artifact_integrity": {
|
||||
"python": {
|
||||
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",
|
||||
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
|
||||
"sha256": "<sha256 of the resolved Python artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-0.1.4.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-0.1.4.intoto.jsonl",
|
||||
"git_ref": "refs/tags/v0.1.4"
|
||||
},
|
||||
"webui": {
|
||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
|
||||
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
|
||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-webui-0.1.4.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-webui-0.1.4.intoto.jsonl",
|
||||
@@ -121,19 +121,19 @@
|
||||
"version": "0.1.6",
|
||||
"action": "install",
|
||||
"python_package": "govoplan-dashboard",
|
||||
"python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6",
|
||||
"python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
|
||||
"webui_package": "@govoplan/dashboard-webui",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
|
||||
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
|
||||
"artifact_integrity": {
|
||||
"python": {
|
||||
"ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6",
|
||||
"ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
|
||||
"sha256": "<sha256 of the resolved Python artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-0.1.6.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-0.1.6.intoto.jsonl",
|
||||
"git_ref": "refs/tags/v0.1.6"
|
||||
},
|
||||
"webui": {
|
||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
|
||||
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
|
||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-webui-0.1.6.spdx.json",
|
||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-webui-0.1.6.intoto.jsonl",
|
||||
|
||||
+9
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.10"
|
||||
version = "0.1.15"
|
||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -19,6 +19,7 @@ dependencies = [
|
||||
"celery>=5,<6",
|
||||
"redis>=5,<6",
|
||||
"alembic>=1,<2",
|
||||
"boto3>=1.34,<2",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
@@ -27,9 +28,16 @@ where = ["src"]
|
||||
[tool.setuptools.package-data]
|
||||
govoplan_core = ["py.typed"]
|
||||
|
||||
[tool.setuptools.data-files]
|
||||
"govoplan_core_runtime" = ["alembic.ini"]
|
||||
"govoplan_core_runtime/alembic" = ["alembic/env.py", "alembic/script.py.mako"]
|
||||
"govoplan_core_runtime/alembic/versions" = ["alembic/versions/*.py"]
|
||||
"govoplan_core_runtime/alembic/dev_versions" = ["alembic/dev_versions/*.py"]
|
||||
|
||||
[project.scripts]
|
||||
govoplan-config = "govoplan_core.commands.config:main"
|
||||
govoplan-devserver = "govoplan_core.devserver:main"
|
||||
govoplan-first-admin = "govoplan_core.commands.first_admin:main"
|
||||
govoplan-module-install-plan = "govoplan_core.commands.module_install_plan:main"
|
||||
govoplan-module-installer = "govoplan_core.commands.module_installer:main"
|
||||
|
||||
|
||||
@@ -39,6 +39,24 @@ class DeltaCollectionResponse(BaseModel):
|
||||
full: bool = False
|
||||
|
||||
|
||||
class ReferenceOptionResponse(BaseModel):
|
||||
value: str
|
||||
label: str
|
||||
description: str | None = None
|
||||
kind: str | None = None
|
||||
availability: Literal["available", "inactive", "unavailable"] = "available"
|
||||
disabled: bool = False
|
||||
source_module: str | None = None
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ReferenceOptionListResponse(BaseModel):
|
||||
options: list[ReferenceOptionResponse] = Field(default_factory=list)
|
||||
provider_available: bool = True
|
||||
next_cursor: str | None = None
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -55,6 +73,12 @@ class SwitchTenantRequest(BaseModel):
|
||||
tenant_id: str
|
||||
|
||||
|
||||
class SwitchActingContextRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
assignment_id: str | None = Field(default=None, max_length=36)
|
||||
|
||||
|
||||
class TenantInfo(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
@@ -160,6 +184,7 @@ class PrincipalContextInfo(BaseModel):
|
||||
api_key_id: str | None = None
|
||||
session_id: str | None = None
|
||||
service_account_id: str | None = None
|
||||
acting_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
email: str | None = None
|
||||
display_name: str | None = None
|
||||
|
||||
@@ -16,8 +16,9 @@ from govoplan_core.core.events import (
|
||||
PlatformEvent,
|
||||
current_event_trace,
|
||||
normalize_trace_id,
|
||||
publish_platform_event,
|
||||
emit_platform_event,
|
||||
)
|
||||
from govoplan_core.core.institutional import GovernedContextEnvelope
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
|
||||
from govoplan_core.security.redaction import redact_secret_values
|
||||
@@ -171,9 +172,23 @@ class _NullAuditRecorder:
|
||||
)
|
||||
|
||||
|
||||
def _publish_audit_platform_event(item: AuditRecordRef) -> None:
|
||||
def _publish_audit_platform_event(
|
||||
session: Session,
|
||||
item: AuditRecordRef,
|
||||
) -> None:
|
||||
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
|
||||
publish_platform_event(
|
||||
raw_context = (
|
||||
item.details.get("_institutional_context")
|
||||
if isinstance(item.details, Mapping)
|
||||
else None
|
||||
)
|
||||
institutional_context = (
|
||||
GovernedContextEnvelope.from_mapping(raw_context)
|
||||
if isinstance(raw_context, Mapping)
|
||||
else None
|
||||
)
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
type=item.action,
|
||||
module_id=_module_id_for_audit_action(item.action),
|
||||
@@ -190,6 +205,7 @@ def _publish_audit_platform_event(item: AuditRecordRef) -> None:
|
||||
tenant=EventTenantRef(id=item.tenant_id) if item.tenant_id else None,
|
||||
resource=EventObjectRef(type=item.object_type, id=item.object_id) if item.object_type else None,
|
||||
classification="internal",
|
||||
institutional_context=institutional_context,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -207,6 +223,7 @@ def audit_event(
|
||||
details: dict[str, Any] | None = None,
|
||||
correlation_id: str | None = None,
|
||||
causation_id: str | None = None,
|
||||
institutional_context: GovernedContextEnvelope | None = None,
|
||||
commit: bool = False,
|
||||
) -> AuditRecordRef:
|
||||
"""Persist one audit event.
|
||||
@@ -219,8 +236,17 @@ def audit_event(
|
||||
if scope not in {"tenant", "system"}:
|
||||
raise ValueError(f"Unsupported audit scope: {scope}")
|
||||
|
||||
if (
|
||||
institutional_context is not None
|
||||
and tenant_id is not None
|
||||
and institutional_context.tenant_id != tenant_id
|
||||
):
|
||||
raise ValueError("Audit institutional context belongs to another tenant")
|
||||
raw_details = dict(details or {})
|
||||
if institutional_context is not None:
|
||||
raw_details["_institutional_context"] = institutional_context.to_dict()
|
||||
traced_details, trace = _trace_details(
|
||||
_sanitize_details(details or {}),
|
||||
_sanitize_details(raw_details),
|
||||
correlation_id=correlation_id,
|
||||
causation_id=causation_id,
|
||||
)
|
||||
@@ -238,6 +264,7 @@ def audit_event(
|
||||
api_key_id=api_key_id,
|
||||
resource_type=object_type,
|
||||
resource_id=object_id,
|
||||
institutional_context=institutional_context,
|
||||
details=stored_details,
|
||||
))
|
||||
record_change(
|
||||
@@ -252,7 +279,7 @@ def audit_event(
|
||||
actor_id=user_id or api_key_id,
|
||||
payload={"scope": scope, "action": action, "object_type": object_type, "object_id": object_id},
|
||||
)
|
||||
_publish_audit_platform_event(item)
|
||||
_publish_audit_platform_event(session, item)
|
||||
if commit:
|
||||
session.commit()
|
||||
return item
|
||||
@@ -269,6 +296,7 @@ def audit_from_principal(
|
||||
details: dict[str, Any] | None = None,
|
||||
correlation_id: str | None = None,
|
||||
causation_id: str | None = None,
|
||||
institutional_context: GovernedContextEnvelope | None = None,
|
||||
commit: bool = False,
|
||||
) -> AuditRecordRef:
|
||||
return audit_event(
|
||||
@@ -283,5 +311,6 @@ def audit_from_principal(
|
||||
details=details,
|
||||
correlation_id=correlation_id,
|
||||
causation_id=causation_id,
|
||||
institutional_context=institutional_context,
|
||||
commit=commit,
|
||||
)
|
||||
|
||||
@@ -81,6 +81,10 @@ class ApiPrincipal:
|
||||
def acting_for_account_id(self) -> str | None:
|
||||
return self.principal.acting_for_account_id
|
||||
|
||||
@property
|
||||
def acting_assignment_id(self) -> str | None:
|
||||
return self.principal.acting_assignment_id
|
||||
|
||||
@property
|
||||
def auth_method(self) -> str:
|
||||
return self.principal.auth_method
|
||||
@@ -118,7 +122,7 @@ def _registry_from_request(request: Request) -> PlatformRegistry | None:
|
||||
def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider:
|
||||
registry = _registry_from_request(request)
|
||||
if registry is None or not registry.has_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Auth provider is not available")
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Auth provider is not available")
|
||||
capability = registry.require_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER)
|
||||
if not isinstance(capability, ApiPrincipalProvider):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability")
|
||||
@@ -131,6 +135,9 @@ def get_api_principal(
|
||||
authorization: str | None = Header(default=None),
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
) -> ApiPrincipal:
|
||||
cached = getattr(request.state, "govoplan_api_principal", None)
|
||||
if isinstance(cached, ApiPrincipal):
|
||||
return cached
|
||||
principal = _api_principal_provider_from_request(request).resolve_api_principal(
|
||||
request,
|
||||
session,
|
||||
@@ -139,6 +146,7 @@ def get_api_principal(
|
||||
)
|
||||
if not isinstance(principal, ApiPrincipal):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid API principal")
|
||||
request.state.govoplan_api_principal = principal
|
||||
return principal
|
||||
|
||||
|
||||
|
||||
+1312
-32
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Sequence
|
||||
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
LeaseClaim,
|
||||
acquire_lease,
|
||||
release_lease,
|
||||
renew_lease,
|
||||
runtime_identity,
|
||||
)
|
||||
from govoplan_core.db.session import configure_database, get_database
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run one process while holding a database-fenced deployment lease."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resource", required=True, help="Stable cluster-wide lease key."
|
||||
)
|
||||
parser.add_argument("--ttl-seconds", type=int, default=60)
|
||||
parser.add_argument("--renew-seconds", type=int, default=15)
|
||||
parser.add_argument("--wait-seconds", type=int, default=0)
|
||||
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
command = list(args.command)
|
||||
if command and command[0] == "--":
|
||||
command.pop(0)
|
||||
if not command:
|
||||
raise SystemExit("fenced-run requires a command after --")
|
||||
if args.ttl_seconds < 10:
|
||||
raise SystemExit("--ttl-seconds must be at least 10")
|
||||
if args.renew_seconds < 2 or args.renew_seconds * 2 >= args.ttl_seconds:
|
||||
raise SystemExit("--renew-seconds must be less than half the lease TTL")
|
||||
configure_database(settings.database_url)
|
||||
identity = runtime_identity(
|
||||
settings,
|
||||
software_version=_core_version(),
|
||||
role=str(settings.runtime_role or "deployment"),
|
||||
)
|
||||
claim = _wait_for_lease(
|
||||
resource=args.resource,
|
||||
identity=identity,
|
||||
ttl_seconds=args.ttl_seconds,
|
||||
wait_seconds=max(0, args.wait_seconds),
|
||||
)
|
||||
if claim is None:
|
||||
print(
|
||||
f"lease unavailable: {args.resource}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 75
|
||||
|
||||
process = subprocess.Popen(command) # noqa: S603 - argv is an operator-owned container command
|
||||
stop = threading.Event()
|
||||
fence_lost = threading.Event()
|
||||
renewer = threading.Thread(
|
||||
target=_renew_loop,
|
||||
kwargs={
|
||||
"claim": claim,
|
||||
"ttl_seconds": args.ttl_seconds,
|
||||
"renew_seconds": args.renew_seconds,
|
||||
"stop": stop,
|
||||
"fence_lost": fence_lost,
|
||||
"process": process,
|
||||
},
|
||||
daemon=True,
|
||||
name=f"govoplan-fence:{args.resource}",
|
||||
)
|
||||
renewer.start()
|
||||
previous_handlers = _forward_signals(process)
|
||||
try:
|
||||
return_code = process.wait()
|
||||
finally:
|
||||
stop.set()
|
||||
renewer.join(timeout=args.renew_seconds + 2)
|
||||
_restore_signals(previous_handlers)
|
||||
_release(claim)
|
||||
if fence_lost.is_set():
|
||||
return 74
|
||||
return int(return_code)
|
||||
|
||||
|
||||
def _wait_for_lease(
|
||||
*,
|
||||
resource: str,
|
||||
identity,
|
||||
ttl_seconds: int,
|
||||
wait_seconds: int,
|
||||
) -> LeaseClaim | None:
|
||||
deadline = time.monotonic() + wait_seconds
|
||||
while True:
|
||||
with get_database().SessionLocal() as session:
|
||||
claim = acquire_lease(
|
||||
session,
|
||||
installation_id=identity.installation_id,
|
||||
resource_key=resource,
|
||||
holder_node_id=identity.node_id,
|
||||
holder_incarnation=identity.incarnation,
|
||||
ttl_seconds=ttl_seconds,
|
||||
metadata={"role": identity.role},
|
||||
)
|
||||
session.commit()
|
||||
if claim is not None or time.monotonic() >= deadline:
|
||||
return claim
|
||||
time.sleep(min(2, max(0.1, deadline - time.monotonic())))
|
||||
|
||||
|
||||
def _renew_loop(
|
||||
*,
|
||||
claim: LeaseClaim,
|
||||
ttl_seconds: int,
|
||||
renew_seconds: int,
|
||||
stop: threading.Event,
|
||||
fence_lost: threading.Event,
|
||||
process: subprocess.Popen[bytes],
|
||||
) -> None:
|
||||
active_claim = claim
|
||||
while not stop.wait(renew_seconds):
|
||||
try:
|
||||
with get_database().SessionLocal() as session:
|
||||
active_claim = renew_lease(
|
||||
session,
|
||||
active_claim,
|
||||
ttl_seconds=ttl_seconds,
|
||||
)
|
||||
session.commit()
|
||||
except Exception: # noqa: BLE001 - any renewal failure loses authority
|
||||
fence_lost.set()
|
||||
_terminate_process(process)
|
||||
return
|
||||
|
||||
|
||||
def _terminate_process(
|
||||
process: subprocess.Popen[bytes],
|
||||
*,
|
||||
timeout_seconds: float = 5.0,
|
||||
) -> None:
|
||||
if process.poll() is not None:
|
||||
return
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=timeout_seconds)
|
||||
|
||||
|
||||
def _release(claim: LeaseClaim) -> None:
|
||||
try:
|
||||
with get_database().SessionLocal() as session:
|
||||
release_lease(session, claim)
|
||||
session.commit()
|
||||
except Exception: # noqa: BLE001 - authority is already lost; release is best effort
|
||||
return
|
||||
|
||||
|
||||
def _forward_signals(
|
||||
process: subprocess.Popen[bytes],
|
||||
) -> dict[int, signal.Handlers]:
|
||||
previous: dict[int, signal.Handlers] = {}
|
||||
|
||||
def forward(signum, _frame) -> None:
|
||||
if process.poll() is None:
|
||||
process.send_signal(signum)
|
||||
|
||||
for signum in (signal.SIGTERM, signal.SIGINT):
|
||||
previous[signum] = signal.getsignal(signum)
|
||||
signal.signal(signum, forward)
|
||||
return previous
|
||||
|
||||
|
||||
def _restore_signals(previous: dict[int, signal.Handlers]) -> None:
|
||||
for signum, handler in previous.items():
|
||||
signal.signal(signum, handler)
|
||||
|
||||
|
||||
def _core_version() -> str:
|
||||
try:
|
||||
return version("govoplan-core")
|
||||
except PackageNotFoundError:
|
||||
return "development"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,233 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from typing import Any
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
|
||||
FirstAdminProvisioner,
|
||||
)
|
||||
from govoplan_core.core.first_admin import (
|
||||
FirstAdminEnrollmentError,
|
||||
first_admin_enrollment_status,
|
||||
issue_first_admin_credential,
|
||||
)
|
||||
from govoplan_core.core.module_management import (
|
||||
load_startup_enabled_modules,
|
||||
startup_candidate_module_ids,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.runtime import configure_runtime
|
||||
from govoplan_core.db.session import configure_database, get_database
|
||||
from govoplan_core.server.registry import (
|
||||
available_module_manifests,
|
||||
build_platform_registry,
|
||||
)
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Manage the single-use production first-administrator credential",
|
||||
)
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=("status", "issue", "recover"),
|
||||
help="Inspect readiness, issue the initial credential, or rotate lost/expired material.",
|
||||
)
|
||||
parser.add_argument("--database-url", default=settings.database_url)
|
||||
parser.add_argument("--installation-id", default=settings.installation_id)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=Path(settings.first_admin_enrollment_file),
|
||||
help="Root-readable/equivalent JSON credential artifact.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ttl-seconds",
|
||||
type=int,
|
||||
default=settings.first_admin_enrollment_ttl_seconds,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reason",
|
||||
default=None,
|
||||
help="Audited local-operator reason for issue or recovery.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
configure_database(args.database_url)
|
||||
provisioner = _configure_first_admin_provisioner()
|
||||
with get_database().SessionLocal() as session:
|
||||
if args.command == "status":
|
||||
enrollment = first_admin_enrollment_status(
|
||||
session,
|
||||
installation_id=args.installation_id,
|
||||
provisioner=provisioner,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"installation_id": args.installation_id,
|
||||
"enrollment_required": enrollment.enrollment_required,
|
||||
"credential_active": enrollment.credential_active,
|
||||
"state": enrollment.state,
|
||||
"generation": enrollment.generation,
|
||||
"expires_at": (
|
||||
enrollment.expires_at.isoformat()
|
||||
if enrollment.expires_at is not None
|
||||
else None
|
||||
),
|
||||
"readiness": enrollment.readiness,
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
reason = args.reason or (
|
||||
"initial production administrator enrollment"
|
||||
if args.command == "issue"
|
||||
else "local operator recovery of first-administrator enrollment"
|
||||
)
|
||||
try:
|
||||
credential = issue_first_admin_credential(
|
||||
session,
|
||||
installation_id=args.installation_id,
|
||||
provisioner=provisioner,
|
||||
ttl_seconds=args.ttl_seconds,
|
||||
reason=reason,
|
||||
replace_active=args.command == "recover",
|
||||
)
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"installation_id": args.installation_id,
|
||||
"endpoint": "/api/v1/bootstrap/first-admin",
|
||||
"header": "X-GovOPlaN-Enrollment-Token",
|
||||
"enrollment_token": credential.secret,
|
||||
"fingerprint": credential.fingerprint,
|
||||
"generation": credential.generation,
|
||||
"expires_at": credential.expires_at.isoformat(),
|
||||
}
|
||||
previous = _secure_file_snapshot(args.output)
|
||||
_write_private_json(args.output, payload)
|
||||
try:
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
_restore_secure_file(args.output, previous)
|
||||
raise
|
||||
except FirstAdminEnrollmentError as exc:
|
||||
session.rollback()
|
||||
parser.error(str(exc))
|
||||
|
||||
print(f"First-administrator credential written to {args.output}")
|
||||
print(f"Fingerprint: {credential.fingerprint}")
|
||||
print(f"Expires: {credential.expires_at.isoformat()}")
|
||||
print("The secret was not printed. Read it from the restricted artifact on the host.")
|
||||
|
||||
|
||||
def _configure_first_admin_provisioner() -> FirstAdminProvisioner:
|
||||
raw_enabled = load_startup_enabled_modules(settings.enabled_modules)
|
||||
candidates = startup_candidate_module_ids(settings.enabled_modules, raw_enabled)
|
||||
available = available_module_manifests(
|
||||
enabled_modules=candidates,
|
||||
ignore_load_errors=True,
|
||||
)
|
||||
enabled = load_startup_enabled_modules(
|
||||
settings.enabled_modules,
|
||||
available=available,
|
||||
)
|
||||
registry = build_platform_registry(enabled)
|
||||
context = ModuleContext(registry=registry, settings=settings)
|
||||
registry.configure_capability_context(context)
|
||||
configure_runtime(context)
|
||||
if not registry.has_capability(CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER):
|
||||
raise RuntimeError(
|
||||
"Install and enable the Access module before issuing a first-administrator credential."
|
||||
)
|
||||
capability = registry.require_capability(CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER)
|
||||
if not isinstance(capability, FirstAdminProvisioner):
|
||||
raise RuntimeError("The Access first-administrator capability is invalid.")
|
||||
return capability
|
||||
|
||||
|
||||
def _secure_file_snapshot(path: Path) -> tuple[bytes, int] | None:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise RuntimeError(f"Refusing to replace non-regular credential artifact: {path}")
|
||||
if metadata.st_uid != os.geteuid():
|
||||
raise RuntimeError(f"Credential artifact is not owned by the current operator: {path}")
|
||||
if stat.S_IMODE(metadata.st_mode) & 0o077:
|
||||
raise RuntimeError(f"Credential artifact permissions are too broad: {path}")
|
||||
return path.read_bytes(), stat.S_IMODE(metadata.st_mode)
|
||||
|
||||
|
||||
def _write_private_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
descriptor = os.open(temporary, flags, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
||||
json.dump(payload, stream, indent=2, sort_keys=True)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, 0o600)
|
||||
_fsync_directory(path.parent)
|
||||
except Exception:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _restore_secure_file(path: Path, snapshot: tuple[bytes, int] | None) -> None:
|
||||
if snapshot is None:
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
return
|
||||
content, mode = snapshot
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.restore")
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
stream.write(content)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, mode)
|
||||
_fsync_directory(path.parent)
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
if not hasattr(os, "O_DIRECTORY"):
|
||||
return
|
||||
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -12,6 +12,7 @@ from govoplan_core.db.migrations import (
|
||||
migrate_database,
|
||||
run_registered_module_migration_tasks,
|
||||
)
|
||||
from govoplan_core.db.migration_lock import deployment_migration_lock
|
||||
from govoplan_core.db.session import configure_database, get_database
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
@@ -28,6 +29,12 @@ def main() -> None:
|
||||
parser.add_argument("--enabled-module", action="append", default=[], help="Target enabled module id used to discover module migrations; may be repeated.")
|
||||
parser.add_argument("--migration-module", action="append", default=[], help="Module id whose migration heads should be upgraded in this order before final heads.")
|
||||
parser.add_argument("--migration-task-record-output", type=Path, help="Write executed module migration task records to this JSON file.")
|
||||
parser.add_argument(
|
||||
"--migration-lock-timeout-seconds",
|
||||
type=float,
|
||||
default=900.0,
|
||||
help="Maximum wait for the deployment-wide PostgreSQL advisory lock.",
|
||||
)
|
||||
parser.add_argument("--with-dev-data", action="store_true", help="Create default tenant/user/roles and a development API key")
|
||||
parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create")
|
||||
args = parser.parse_args()
|
||||
@@ -37,6 +44,12 @@ def main() -> None:
|
||||
migration_order = tuple(args.migration_module) if args.migration_module else None
|
||||
task_records: list[dict[str, object]] = []
|
||||
try:
|
||||
with deployment_migration_lock(
|
||||
args.database_url,
|
||||
installation_id=settings.installation_id,
|
||||
migration_track=args.migration_track,
|
||||
timeout_seconds=args.migration_lock_timeout_seconds,
|
||||
):
|
||||
_run_migration_tasks(
|
||||
task_records,
|
||||
database_url=args.database_url,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
@@ -33,6 +34,10 @@ from govoplan_core.core.module_installer_notifications import (
|
||||
installer_notification_priority,
|
||||
installer_notification_subject,
|
||||
)
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
bind_process_runtime_identity,
|
||||
runtime_identity,
|
||||
)
|
||||
from govoplan_core.core.module_license import issue_module_license, module_license_diagnostics
|
||||
from govoplan_core.core.module_package_catalog import sign_module_package_catalog, validate_module_package_catalog
|
||||
from govoplan_core.core.module_management import (
|
||||
@@ -107,11 +112,27 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
def main() -> int:
|
||||
args = _build_parser().parse_args()
|
||||
runtime_dir = args.runtime_dir or default_installer_runtime_dir(args.database_url)
|
||||
bind_process_runtime_identity(
|
||||
runtime_identity(
|
||||
settings,
|
||||
software_version=_core_version(),
|
||||
role="installer",
|
||||
)
|
||||
)
|
||||
try:
|
||||
return _dispatch_command(args=args, runtime_dir=runtime_dir)
|
||||
except ModuleInstallerError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
bind_process_runtime_identity(None)
|
||||
|
||||
|
||||
def _core_version() -> str:
|
||||
try:
|
||||
return version("govoplan-core")
|
||||
except PackageNotFoundError:
|
||||
return "development"
|
||||
|
||||
|
||||
def _dispatch_command(*, args: argparse.Namespace, runtime_dir: Path) -> int:
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
|
||||
from govoplan_core.db.migrations import (
|
||||
configured_migration_heads,
|
||||
database_migration_heads,
|
||||
)
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Wait until the database is at this release's migration heads."
|
||||
)
|
||||
parser.add_argument("--database-url", default=settings.database_url)
|
||||
parser.add_argument(
|
||||
"--migration-track",
|
||||
default=settings.migration_track,
|
||||
choices=("release", "dev"),
|
||||
)
|
||||
parser.add_argument("--timeout-seconds", type=float, default=900.0)
|
||||
parser.add_argument("--poll-seconds", type=float, default=2.0)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.timeout_seconds < 0 or args.poll_seconds <= 0:
|
||||
raise SystemExit("timeouts must be non-negative and polling must be positive")
|
||||
expected = configured_migration_heads(
|
||||
args.database_url,
|
||||
migration_track=args.migration_track,
|
||||
)
|
||||
deadline = time.monotonic() + args.timeout_seconds
|
||||
last_error = ""
|
||||
while True:
|
||||
try:
|
||||
actual = database_migration_heads(args.database_url)
|
||||
if actual == expected:
|
||||
print("Database migration heads are ready: " + ",".join(actual))
|
||||
return 0
|
||||
last_error = (
|
||||
f"database heads={','.join(actual) or '<none>'}; "
|
||||
f"expected={','.join(expected) or '<none>'}"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - connection may become ready later
|
||||
last_error = f"{type(exc).__name__}: {exc}"
|
||||
if time.monotonic() >= deadline:
|
||||
print(
|
||||
"Database did not reach configured migration heads: " + last_error,
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 75
|
||||
time.sleep(min(args.poll_seconds, max(0.05, deadline - time.monotonic())))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
||||
from typing import Literal, Protocol, cast, runtime_checkable
|
||||
|
||||
from govoplan_core.core.modules import AccessDecision
|
||||
from govoplan_core.core.institutional import GovernedContextEnvelope
|
||||
|
||||
|
||||
ACCESS_MODULE_ID = "access"
|
||||
@@ -20,6 +21,7 @@ CAPABILITY_ACCESS_RESOURCE_ACCESS = "access.resourceAccess"
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY = "access.semanticDirectory"
|
||||
CAPABILITY_ACCESS_EXPLANATION = "access.explanation"
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER = "access.tenantProvisioner"
|
||||
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER = "access.firstAdminProvisioner"
|
||||
CAPABILITY_ACCESS_ADMINISTRATION = "access.administration"
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
|
||||
@@ -28,6 +30,9 @@ CAPABILITY_AUDIT_RECORDER = "audit.recorder"
|
||||
CAPABILITY_AUDIT_RETENTION = "audit.retention"
|
||||
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider"
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER = (
|
||||
"auth.automationPrincipalProvider"
|
||||
)
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
|
||||
@@ -41,6 +46,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||
CAPABILITY_ACCESS_EXPLANATION,
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER,
|
||||
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
|
||||
CAPABILITY_ACCESS_ADMINISTRATION,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
||||
@@ -48,6 +54,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
||||
CAPABILITY_AUDIT_RECORDER,
|
||||
CAPABILITY_AUDIT_RETENTION,
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||
@@ -57,12 +64,19 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
||||
AUTH_CAPABILITY_NAMES = frozenset(
|
||||
{
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||
}
|
||||
)
|
||||
|
||||
DEFAULT_CAPABILITY_PROVIDERS: Mapping[str, str] = {
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER: ACCESS_MODULE_ID,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR: ACCESS_MODULE_ID,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: ACCESS_MODULE_ID,
|
||||
}
|
||||
|
||||
AuthMethod = Literal["session", "api_key", "service_account"]
|
||||
AccessSubjectKind = Literal[
|
||||
"identity",
|
||||
@@ -114,6 +128,7 @@ class PrincipalRef:
|
||||
api_key_id: str | None = None
|
||||
session_id: str | None = None
|
||||
service_account_id: str | None = None
|
||||
acting_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
email: str | None = None
|
||||
display_name: str | None = None
|
||||
@@ -133,6 +148,7 @@ class PrincipalRef:
|
||||
"api_key_id": self.api_key_id,
|
||||
"session_id": self.session_id,
|
||||
"service_account_id": self.service_account_id,
|
||||
"acting_assignment_id": self.acting_assignment_id,
|
||||
"acting_for_account_id": self.acting_for_account_id,
|
||||
"email": self.email,
|
||||
"display_name": self.display_name,
|
||||
@@ -154,6 +170,7 @@ class PrincipalRef:
|
||||
api_key_id=_optional_str(value.get("api_key_id")),
|
||||
session_id=_optional_str(value.get("session_id")),
|
||||
service_account_id=_optional_str(value.get("service_account_id")),
|
||||
acting_assignment_id=_optional_str(value.get("acting_assignment_id")),
|
||||
acting_for_account_id=_optional_str(value.get("acting_for_account_id")),
|
||||
email=_optional_str(value.get("email")),
|
||||
display_name=_optional_str(value.get("display_name")),
|
||||
@@ -327,6 +344,19 @@ class DevelopmentBootstrapRef:
|
||||
created_api_key: CreatedApiKeyRef | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FirstSystemAdministratorRef:
|
||||
account_id: str
|
||||
email: str
|
||||
display_name: str | None = None
|
||||
membership_id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
|
||||
|
||||
class FirstAdminProvisioningError(RuntimeError):
|
||||
"""Safe, user-facing rejection from the Access enrollment boundary."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantContextSwitchRef:
|
||||
account_id: str
|
||||
@@ -363,6 +393,7 @@ class AuditEvent:
|
||||
occurred_at: datetime | None = None
|
||||
correlation_id: str | None = None
|
||||
causation_id: str | None = None
|
||||
institutional_context: GovernedContextEnvelope | None = None
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -563,6 +594,25 @@ class TenantAccessProvisioner(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FirstAdminProvisioner(Protocol):
|
||||
"""Narrow Access boundary used only by the production bootstrap flow."""
|
||||
|
||||
def has_durable_system_administrator(self, session: object) -> bool:
|
||||
...
|
||||
|
||||
def create_first_system_administrator(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant: object,
|
||||
email: str,
|
||||
display_name: str | None,
|
||||
password: str,
|
||||
) -> FirstSystemAdministratorRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AccessAdministration(Protocol):
|
||||
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_APPROVAL_REQUESTS = "approvals.requests"
|
||||
|
||||
|
||||
class ApprovalCapabilityError(ValueError):
|
||||
"""Stable error raised by Approval capability implementations."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApprovalActorSelector:
|
||||
kind: str
|
||||
value: str
|
||||
label: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApprovalStepDefinition:
|
||||
key: str
|
||||
label: str
|
||||
selectors: tuple[ApprovalActorSelector, ...]
|
||||
required_approvals: int = 1
|
||||
rejection_policy: str = "fail_fast"
|
||||
due_at: datetime | None = None
|
||||
signature_required: bool = False
|
||||
forbidden_evidence_roles: tuple[str, ...] = ()
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApprovalRequestCreateCommand:
|
||||
title: str
|
||||
subject_module: str
|
||||
subject_type: str
|
||||
subject_id: str
|
||||
subject_version: str | None
|
||||
subject_digest: str
|
||||
steps: tuple[ApprovalStepDefinition, ...]
|
||||
description: str | None = None
|
||||
separation_of_duties: bool = True
|
||||
unique_actors_across_steps: bool = False
|
||||
expires_at: datetime | None = None
|
||||
policy_refs: tuple[str, ...] = ()
|
||||
evidence_actors: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
|
||||
template_id: str | None = None
|
||||
template_revision: int | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApprovalTemplateCreateCommand:
|
||||
key: str
|
||||
title: str
|
||||
steps: tuple[ApprovalStepDefinition, ...]
|
||||
description: str | None = None
|
||||
separation_of_duties: bool = True
|
||||
unique_actors_across_steps: bool = False
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApprovalTemplateRef:
|
||||
id: str
|
||||
key: str
|
||||
revision: int
|
||||
state: str
|
||||
content_sha256: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApprovalDecisionCommand:
|
||||
outcome: str
|
||||
reason: str
|
||||
expected_revision: int
|
||||
idempotency_key: str
|
||||
delegated_for_account_id: str | None = None
|
||||
signature_ref: Mapping[str, object] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApprovalRequestRef:
|
||||
id: str
|
||||
revision: int
|
||||
state: str
|
||||
current_step_key: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApprovalDecisionReceipt:
|
||||
request_id: str
|
||||
revision: int
|
||||
state: str
|
||||
step_key: str
|
||||
outcome: str
|
||||
actor_id: str
|
||||
recorded_at: datetime
|
||||
receipt_sha256: str
|
||||
authority_provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApprovalCheck:
|
||||
request_id: str
|
||||
revision: int
|
||||
state: str
|
||||
approved: bool
|
||||
subject_module: str
|
||||
subject_type: str
|
||||
subject_id: str
|
||||
subject_version: str | None
|
||||
subject_digest: str
|
||||
completed_at: datetime | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ApprovalRequestProvider(Protocol):
|
||||
def create_template(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
command: ApprovalTemplateCreateCommand,
|
||||
idempotency_key: str,
|
||||
) -> ApprovalTemplateRef: ...
|
||||
|
||||
def revise_template(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
template_id: str,
|
||||
command: ApprovalTemplateCreateCommand,
|
||||
expected_revision: int,
|
||||
idempotency_key: str,
|
||||
) -> ApprovalTemplateRef: ...
|
||||
|
||||
def publish_template(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
template_id: str,
|
||||
expected_revision: int,
|
||||
idempotency_key: str,
|
||||
) -> ApprovalTemplateRef: ...
|
||||
|
||||
def create_request(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
command: ApprovalRequestCreateCommand,
|
||||
idempotency_key: str,
|
||||
) -> ApprovalRequestRef: ...
|
||||
|
||||
def get_request(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request_id: str,
|
||||
) -> Mapping[str, object] | None: ...
|
||||
|
||||
def decide(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request_id: str,
|
||||
command: ApprovalDecisionCommand,
|
||||
) -> ApprovalDecisionReceipt: ...
|
||||
|
||||
def check_approved(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request_id: str,
|
||||
subject_module: str,
|
||||
subject_type: str,
|
||||
subject_id: str,
|
||||
subject_version: str | None,
|
||||
subject_digest: str,
|
||||
) -> ApprovalCheck: ...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ApprovalActorSelector",
|
||||
"ApprovalCapabilityError",
|
||||
"ApprovalCheck",
|
||||
"ApprovalDecisionCommand",
|
||||
"ApprovalDecisionReceipt",
|
||||
"ApprovalRequestCreateCommand",
|
||||
"ApprovalRequestProvider",
|
||||
"ApprovalRequestRef",
|
||||
"ApprovalStepDefinition",
|
||||
"ApprovalTemplateCreateCommand",
|
||||
"ApprovalTemplateRef",
|
||||
"CAPABILITY_APPROVAL_REQUESTS",
|
||||
]
|
||||
@@ -0,0 +1,419 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
)
|
||||
from govoplan_core.core.institutional import GovernedContextEnvelope
|
||||
|
||||
AutomationInvocationKind = Literal[
|
||||
"manual",
|
||||
"api",
|
||||
"schedule",
|
||||
"event",
|
||||
"workflow",
|
||||
"dependency",
|
||||
"retry",
|
||||
"backfill",
|
||||
]
|
||||
AutomationSubjectKind = Literal["delegated_user", "service_account"]
|
||||
AUTOMATION_PRINCIPAL_CONTRACT_VERSION = "1"
|
||||
ACTION_EFFECT_CONTRACT_VERSION = "1"
|
||||
|
||||
ActionRiskLevel = Literal["low", "moderate", "high", "critical"]
|
||||
ActionReversibility = Literal[
|
||||
"reversible",
|
||||
"compensatable",
|
||||
"corrective_only",
|
||||
"irreversible",
|
||||
]
|
||||
ActionRecoveryMode = Literal[
|
||||
"atomic",
|
||||
"compensation",
|
||||
"snapshot_restore",
|
||||
"forward_recovery",
|
||||
"irreversible",
|
||||
]
|
||||
ActionExecutionState = Literal[
|
||||
"pending",
|
||||
"running",
|
||||
"completed",
|
||||
"blocked",
|
||||
"retryable",
|
||||
"quarantined",
|
||||
"manual_required",
|
||||
"compensation_required",
|
||||
]
|
||||
EffectOperation = Literal[
|
||||
"created",
|
||||
"changed",
|
||||
"deleted",
|
||||
"sent",
|
||||
"notified",
|
||||
"locked",
|
||||
"retained",
|
||||
"external",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EffectDefinition:
|
||||
effect_key: str
|
||||
owner_module: str
|
||||
operation: EffectOperation
|
||||
description: str
|
||||
resource_types: tuple[str, ...] = ()
|
||||
visibility_classification: str = "internal"
|
||||
audit_event_types: tuple[str, ...] = ()
|
||||
compensation_hint: str | None = None
|
||||
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_contract_version(self.contract_version)
|
||||
_require_text(self.effect_key, "Effect key")
|
||||
_require_text(self.owner_module, "Effect owner module")
|
||||
_require_text(self.description, "Effect description")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ActionDefinition:
|
||||
action_key: str
|
||||
owner_module: str
|
||||
description: str
|
||||
input_schema_ref: str
|
||||
required_scopes: tuple[str, ...] = ()
|
||||
required_capabilities: tuple[str, ...] = ()
|
||||
policy_checks: tuple[str, ...] = ()
|
||||
risk_level: ActionRiskLevel = "moderate"
|
||||
reversibility: ActionReversibility = "corrective_only"
|
||||
expected_effect_keys: tuple[str, ...] = ()
|
||||
idempotency_strategy: str = "caller_supplied"
|
||||
audit_event_types: tuple[str, ...] = ()
|
||||
preview_required: bool = True
|
||||
recovery_mode: ActionRecoveryMode = "forward_recovery"
|
||||
recovery_verification: tuple[str, ...] = (
|
||||
"verify the provider result and every announced effect before continuation",
|
||||
)
|
||||
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_contract_version(self.contract_version)
|
||||
_require_text(self.action_key, "Action key")
|
||||
_require_text(self.owner_module, "Action owner module")
|
||||
_require_text(self.description, "Action description")
|
||||
_require_text(self.input_schema_ref, "Action input schema reference")
|
||||
_require_text(self.idempotency_strategy, "Action idempotency strategy")
|
||||
if self.recovery_mode not in {
|
||||
"atomic",
|
||||
"compensation",
|
||||
"snapshot_restore",
|
||||
"forward_recovery",
|
||||
"irreversible",
|
||||
}:
|
||||
raise ValueError("Action recovery mode is not supported")
|
||||
if any(not value.strip() for value in self.required_scopes):
|
||||
raise ValueError("Action scopes must not be empty")
|
||||
if any(not value.strip() for value in self.required_capabilities):
|
||||
raise ValueError("Action capabilities must not be empty")
|
||||
if any(not value.strip() for value in self.expected_effect_keys):
|
||||
raise ValueError("Expected effect keys must not be empty")
|
||||
if not self.recovery_verification or any(
|
||||
not value.strip() for value in self.recovery_verification
|
||||
):
|
||||
raise ValueError("Actions must declare recovery verification steps")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ActionExecutionRequest:
|
||||
tenant_id: str
|
||||
action_key: str
|
||||
input: Mapping[str, object]
|
||||
idempotency_key: str
|
||||
invocation: AutomationInvocation
|
||||
institutional_context: GovernedContextEnvelope | None = None
|
||||
actor_ref: str | None = None
|
||||
preview_ref: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_contract_version(self.contract_version)
|
||||
_require_text(self.tenant_id, "Action tenant id")
|
||||
_require_text(self.action_key, "Action key")
|
||||
_require_text(self.idempotency_key, "Action idempotency key")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EffectPreview:
|
||||
effect_key: str
|
||||
summary: str
|
||||
resource_refs: tuple[str, ...] = ()
|
||||
external_system_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ActionPreview:
|
||||
action_key: str
|
||||
allowed: bool
|
||||
summary: str
|
||||
risk_level: ActionRiskLevel
|
||||
reversibility: ActionReversibility
|
||||
effects: tuple[EffectPreview, ...] = ()
|
||||
blockers: tuple[str, ...] = ()
|
||||
policy_provenance: tuple[Mapping[str, object], ...] = ()
|
||||
preview_ref: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ObservedEffect:
|
||||
effect_key: str
|
||||
operation: EffectOperation
|
||||
resource_ref: str | None = None
|
||||
external_system_ref: str | None = None
|
||||
audit_event_ref: str | None = None
|
||||
summary: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ActionExecutionResult:
|
||||
state: ActionExecutionState
|
||||
output: Mapping[str, object] = field(default_factory=dict)
|
||||
observed_effects: tuple[ObservedEffect, ...] = ()
|
||||
error: str | None = None
|
||||
retry_after: datetime | None = None
|
||||
manual_instructions: str | None = None
|
||||
compensation_action_key: str | None = None
|
||||
audit_event_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ActionEffectProvider(Protocol):
|
||||
def action_definitions(self) -> tuple[ActionDefinition, ...]: ...
|
||||
|
||||
def effect_definitions(self) -> tuple[EffectDefinition, ...]: ...
|
||||
|
||||
def preview_action(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ActionExecutionRequest,
|
||||
) -> ActionPreview: ...
|
||||
|
||||
def execute_action(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ActionExecutionRequest,
|
||||
) -> ActionExecutionResult: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AutomationInvocation:
|
||||
kind: AutomationInvocationKind = "manual"
|
||||
trigger_ref: str | None = None
|
||||
delivery_ref: str | None = None
|
||||
event_id: str | None = None
|
||||
event_type: str | None = None
|
||||
correlation_id: str | None = None
|
||||
causation_id: str | None = None
|
||||
scheduled_for: datetime | None = None
|
||||
requested_by: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AutomationPrincipalRequest:
|
||||
tenant_id: str
|
||||
authorization_ref: str
|
||||
grant_scopes: tuple[str, ...]
|
||||
account_id: str | None = None
|
||||
membership_id: str | None = None
|
||||
service_account_id: str | None = None
|
||||
subject_kind: AutomationSubjectKind = "delegated_user"
|
||||
context: Mapping[str, object] = field(default_factory=dict)
|
||||
contract_version: str = AUTOMATION_PRINCIPAL_CONTRACT_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
self.contract_version
|
||||
!= AUTOMATION_PRINCIPAL_CONTRACT_VERSION
|
||||
):
|
||||
raise ValueError(
|
||||
"Unsupported automation-principal contract version"
|
||||
)
|
||||
if not self.tenant_id.strip():
|
||||
raise ValueError("Automation tenant id is required")
|
||||
if not self.authorization_ref.strip():
|
||||
raise ValueError(
|
||||
"Automation authorization artifact reference is required"
|
||||
)
|
||||
if self.subject_kind == "delegated_user":
|
||||
if (
|
||||
not self.account_id
|
||||
or not self.membership_id
|
||||
or self.service_account_id is not None
|
||||
):
|
||||
raise ValueError(
|
||||
"Delegated-user automation requires account and "
|
||||
"membership references only"
|
||||
)
|
||||
elif (
|
||||
not self.service_account_id
|
||||
or self.account_id is not None
|
||||
or self.membership_id is not None
|
||||
):
|
||||
raise ValueError(
|
||||
"Service-account automation requires only a service-account "
|
||||
"reference"
|
||||
)
|
||||
if any(
|
||||
not scope.strip()
|
||||
for scope in self.grant_scopes
|
||||
):
|
||||
raise ValueError("Automation grant scopes must not be empty")
|
||||
|
||||
@classmethod
|
||||
def delegated_user(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
membership_id: str,
|
||||
authorization_ref: str,
|
||||
grant_scopes: tuple[str, ...],
|
||||
context: Mapping[str, object] | None = None,
|
||||
) -> AutomationPrincipalRequest:
|
||||
return cls(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
membership_id=membership_id,
|
||||
authorization_ref=authorization_ref,
|
||||
grant_scopes=grant_scopes,
|
||||
context=context or {},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def service_account(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
authorization_ref: str,
|
||||
grant_scopes: tuple[str, ...],
|
||||
context: Mapping[str, object] | None = None,
|
||||
) -> AutomationPrincipalRequest:
|
||||
return cls(
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
subject_kind="service_account",
|
||||
authorization_ref=authorization_ref,
|
||||
grant_scopes=grant_scopes,
|
||||
context=context or {},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AutomationPrincipalResolution:
|
||||
allowed: bool
|
||||
principal: object | None = None
|
||||
reason: str | None = None
|
||||
granted_scopes: tuple[str, ...] = ()
|
||||
missing_scopes: tuple[str, ...] = ()
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AutomationPrincipalProvider(Protocol):
|
||||
def resolve_automation_principal(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: AutomationPrincipalRequest,
|
||||
) -> AutomationPrincipalResolution:
|
||||
...
|
||||
|
||||
|
||||
def automation_principal_provider(
|
||||
registry: object | None,
|
||||
) -> AutomationPrincipalProvider | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
|
||||
)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
|
||||
)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, AutomationPrincipalProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def action_effect_provider(
|
||||
registry: object | None,
|
||||
capability_name: str,
|
||||
) -> ActionEffectProvider | None:
|
||||
name = capability_name.strip()
|
||||
if (
|
||||
not name
|
||||
or registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(name)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, ActionEffectProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def _validate_contract_version(value: str) -> None:
|
||||
if value != ACTION_EFFECT_CONTRACT_VERSION:
|
||||
raise ValueError("Unsupported action/effect contract version")
|
||||
|
||||
|
||||
def _require_text(value: str, label: str) -> None:
|
||||
if not value.strip():
|
||||
raise ValueError(f"{label} is required")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ACTION_EFFECT_CONTRACT_VERSION",
|
||||
"CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER",
|
||||
"AUTOMATION_PRINCIPAL_CONTRACT_VERSION",
|
||||
"ActionDefinition",
|
||||
"ActionEffectProvider",
|
||||
"ActionExecutionRequest",
|
||||
"ActionExecutionResult",
|
||||
"ActionExecutionState",
|
||||
"AutomationInvocation",
|
||||
"AutomationInvocationKind",
|
||||
"AutomationPrincipalProvider",
|
||||
"AutomationPrincipalRequest",
|
||||
"AutomationPrincipalResolution",
|
||||
"AutomationSubjectKind",
|
||||
"ActionPreview",
|
||||
"ActionRecoveryMode",
|
||||
"ActionReversibility",
|
||||
"ActionRiskLevel",
|
||||
"EffectDefinition",
|
||||
"EffectOperation",
|
||||
"EffectPreview",
|
||||
"ObservedEffect",
|
||||
"action_effect_provider",
|
||||
"automation_principal_provider",
|
||||
]
|
||||
@@ -8,6 +8,8 @@ from typing import Protocol, runtime_checkable
|
||||
|
||||
CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling"
|
||||
CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox"
|
||||
CAPABILITY_CALENDAR_INVITATIONS = "calendar.invitations"
|
||||
CAPABILITY_CALENDAR_EXTERNAL_PROFILES = "calendar.externalProfiles"
|
||||
CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read"
|
||||
CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write"
|
||||
|
||||
@@ -43,6 +45,94 @@ class CalendarEventRef:
|
||||
outbox_operation_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalendarInvitationAttendeeRequest:
|
||||
address: str
|
||||
name: str | None = None
|
||||
role: str = "REQ-PARTICIPANT"
|
||||
participation_status: str = "NEEDS-ACTION"
|
||||
rsvp: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalendarInvitationRequest:
|
||||
correlation_id: str
|
||||
source_module: str
|
||||
source_resource_type: str
|
||||
source_resource_id: str | None
|
||||
summary: str
|
||||
start_at: datetime
|
||||
attendees: tuple[CalendarInvitationAttendeeRequest, ...]
|
||||
calendar_id: str | None = None
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
end_at: datetime | None = None
|
||||
timezone: str | None = None
|
||||
organizer: Mapping[str, object] | None = None
|
||||
classification: str = "PUBLIC"
|
||||
categories: tuple[str, ...] = ()
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalendarInvitationRef:
|
||||
event_id: str
|
||||
calendar_id: str
|
||||
uid: str
|
||||
correlation_id: str
|
||||
source_module: str
|
||||
source_resource_type: str
|
||||
source_resource_id: str | None
|
||||
attendees: tuple[Mapping[str, object], ...] = ()
|
||||
external_state: str = "local"
|
||||
outbox_operation_id: str | None = None
|
||||
reply_ingress: str = "capability"
|
||||
recurrence_supported: bool = False
|
||||
degraded_reasons: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalendarInvitationCalendarRef:
|
||||
id: str
|
||||
name: str
|
||||
color: str | None = None
|
||||
timezone: str = "UTC"
|
||||
source_kind: str = "local"
|
||||
writable: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalendarExternalProfileRequest:
|
||||
"""Connector-neutral request for a Calendar-owned external profile."""
|
||||
|
||||
profile_kind: str
|
||||
calendar_id: str
|
||||
endpoint_url: str
|
||||
display_name: str | None = None
|
||||
auth_type: str = "none"
|
||||
username: str | None = None
|
||||
credential_ref: str | None = None
|
||||
sync_enabled: bool = True
|
||||
sync_interval_seconds: int = 900
|
||||
sync_direction: str = "two_way"
|
||||
conflict_policy: str = "etag"
|
||||
connector_profile_ref: str | None = None
|
||||
identity_mapping_ref: str | None = None
|
||||
resource_calendar_ref: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalendarExternalProfileRef:
|
||||
source_id: str
|
||||
calendar_id: str
|
||||
profile_kind: str
|
||||
transport_kind: str
|
||||
connector_profile_ref: str | None = None
|
||||
identity_mapping_ref: str | None = None
|
||||
resource_calendar_ref: str | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CalendarSchedulingProvider(Protocol):
|
||||
def list_freebusy(
|
||||
@@ -80,6 +170,108 @@ class CalendarOutboxProvider(Protocol):
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CalendarInvitationProvider(Protocol):
|
||||
"""Correlation-aware invitation boundary for Campaign and Mail adapters."""
|
||||
|
||||
def list_calendars(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None = None,
|
||||
group_ids: Sequence[str] = (),
|
||||
can_admin: bool = False,
|
||||
) -> Sequence[CalendarInvitationCalendarRef]:
|
||||
...
|
||||
|
||||
def render_invitation(self, request: CalendarInvitationRequest) -> str:
|
||||
...
|
||||
|
||||
def upsert_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
request: CalendarInvitationRequest,
|
||||
) -> CalendarInvitationRef:
|
||||
...
|
||||
|
||||
def get_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
correlation_id: str,
|
||||
) -> CalendarInvitationRef | None:
|
||||
...
|
||||
|
||||
def get_invitations(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
correlation_ids: Sequence[str],
|
||||
) -> Mapping[str, CalendarInvitationRef]:
|
||||
...
|
||||
|
||||
def summarize_invitations(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source_module: str,
|
||||
source_resource_type: str,
|
||||
source_resource_id: str | None,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
def record_response(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
attendee_address: str,
|
||||
participation_status: str,
|
||||
correlation_id: str | None = None,
|
||||
uid: str | None = None,
|
||||
responded_at: datetime | None = None,
|
||||
evidence: Mapping[str, object] | None = None,
|
||||
) -> CalendarInvitationRef:
|
||||
...
|
||||
|
||||
def record_icalendar_reply(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
icalendar: str,
|
||||
received_at: datetime | None = None,
|
||||
evidence: Mapping[str, object] | None = None,
|
||||
) -> Sequence[CalendarInvitationRef]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CalendarExternalProfileProvider(Protocol):
|
||||
"""Optional connector route for Calendar-owned groupware adapters."""
|
||||
|
||||
def supported_profiles(self) -> Sequence[Mapping[str, object]]:
|
||||
...
|
||||
|
||||
def configure_profile(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
request: CalendarExternalProfileRequest,
|
||||
) -> CalendarExternalProfileRef:
|
||||
...
|
||||
|
||||
|
||||
def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None:
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
@@ -96,3 +288,29 @@ def calendar_outbox_provider(registry: object | None) -> CalendarOutboxProvider
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX)
|
||||
return capability if isinstance(capability, CalendarOutboxProvider) else None
|
||||
|
||||
|
||||
def calendar_invitation_provider(
|
||||
registry: object | None,
|
||||
) -> CalendarInvitationProvider | None:
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_CALENDAR_INVITATIONS):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_CALENDAR_INVITATIONS)
|
||||
return capability if isinstance(capability, CalendarInvitationProvider) else None
|
||||
|
||||
|
||||
def calendar_external_profile_provider(
|
||||
registry: object | None,
|
||||
) -> CalendarExternalProfileProvider | None:
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_CALENDAR_EXTERNAL_PROFILES):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_CALENDAR_EXTERNAL_PROFILES)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, CalendarExternalProfileProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -95,6 +95,9 @@ class CampaignPolicyContextProvider(Protocol):
|
||||
|
||||
@runtime_checkable
|
||||
class CampaignDeliveryTaskProvider(Protocol):
|
||||
def tenant_id_for_job(self, session: object, *, job_id: str) -> str | None:
|
||||
...
|
||||
|
||||
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any, Iterable, Sequence
|
||||
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, publish_platform_event
|
||||
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, emit_platform_event
|
||||
from govoplan_core.db.base import Base, utcnow
|
||||
|
||||
WATERMARK_PREFIX = "seq:"
|
||||
@@ -96,13 +96,14 @@ def record_change(
|
||||
payload=payload or {},
|
||||
)
|
||||
session.add(entry)
|
||||
_publish_change_event(entry)
|
||||
_publish_change_event(session, entry)
|
||||
return entry
|
||||
|
||||
|
||||
def _publish_change_event(entry: ChangeSequenceEntry) -> None:
|
||||
def _publish_change_event(session: Session, entry: ChangeSequenceEntry) -> None:
|
||||
event_type = _change_event_type(entry.module_id, entry.resource_type, entry.operation)
|
||||
publish_platform_event(
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
type=event_type,
|
||||
module_id=entry.module_id,
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Iterable, Mapping, Sequence
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
class ConcurrencyError(RuntimeError):
|
||||
"""Base class for mutation precondition and compare-and-set failures."""
|
||||
|
||||
|
||||
class MissingPreconditionError(ConcurrencyError):
|
||||
def __init__(self, *, resource_type: str, resource_id: str) -> None:
|
||||
self.resource_type = resource_type
|
||||
self.resource_id = resource_id
|
||||
super().__init__("A strong If-Match precondition is required for this mutation.")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"code": "precondition_required",
|
||||
"resource": {
|
||||
"type": self.resource_type,
|
||||
"id": self.resource_id,
|
||||
},
|
||||
"retryable": True,
|
||||
}
|
||||
|
||||
|
||||
class RevisionConflictError(ConcurrencyError):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
current_revision: int,
|
||||
submitted_base_revision: int,
|
||||
refresh_path: str | None = None,
|
||||
conflicts: Sequence["MergeConflict"] = (),
|
||||
merge_candidate: Any = None,
|
||||
current_etag: str | None = None,
|
||||
) -> None:
|
||||
self.resource_type = resource_type
|
||||
self.resource_id = resource_id
|
||||
self.current_revision = int(current_revision)
|
||||
self.submitted_base_revision = int(submitted_base_revision)
|
||||
self.refresh_path = refresh_path
|
||||
self.conflicts = tuple(conflicts)
|
||||
self.merge_candidate = merge_candidate
|
||||
self.current_etag = current_etag
|
||||
super().__init__(
|
||||
f"{resource_type} {resource_id} changed from revision "
|
||||
f"{submitted_base_revision} to {current_revision}"
|
||||
)
|
||||
|
||||
@property
|
||||
def safe_merge_available(self) -> bool:
|
||||
return self.merge_candidate is not None and not self.conflicts
|
||||
|
||||
def as_dict(
|
||||
self,
|
||||
*,
|
||||
include_values: bool = False,
|
||||
include_merge_candidate: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {
|
||||
"code": "revision_conflict",
|
||||
"resource": {
|
||||
"type": self.resource_type,
|
||||
"id": self.resource_id,
|
||||
},
|
||||
"current_revision": self.current_revision,
|
||||
"submitted_base_revision": self.submitted_base_revision,
|
||||
"retryable": True,
|
||||
"safe_merge_available": self.safe_merge_available,
|
||||
"conflicts": [
|
||||
conflict.as_dict(include_values=include_values)
|
||||
for conflict in self.conflicts[:100]
|
||||
],
|
||||
}
|
||||
if self.refresh_path:
|
||||
result["refresh_path"] = self.refresh_path
|
||||
if self.current_etag:
|
||||
result["current_etag"] = self.current_etag
|
||||
if include_merge_candidate and self.safe_merge_available:
|
||||
result["merge_candidate"] = copy.deepcopy(self.merge_candidate)
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MergeConflict:
|
||||
path: str
|
||||
kind: str
|
||||
base_value: Any = None
|
||||
local_value: Any = None
|
||||
current_value: Any = None
|
||||
|
||||
def as_dict(self, *, include_values: bool = False) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {
|
||||
"path": self.path or "/",
|
||||
"kind": self.kind,
|
||||
}
|
||||
if include_values:
|
||||
result.update(
|
||||
{
|
||||
"base_value": _bounded_json_value(self.base_value),
|
||||
"local_value": _bounded_json_value(self.local_value),
|
||||
"current_value": _bounded_json_value(self.current_value),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ThreeWayMergeResult:
|
||||
value: Any
|
||||
conflicts: list[MergeConflict] = field(default_factory=list)
|
||||
applied_paths: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def merged(self) -> bool:
|
||||
return not self.conflicts
|
||||
|
||||
|
||||
ProtectedPath = str | Callable[[str], bool]
|
||||
|
||||
|
||||
def strong_resource_etag(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
revision: int,
|
||||
) -> str:
|
||||
"""Return an opaque strong ETag for one mutable aggregate revision."""
|
||||
|
||||
normalized_revision = int(revision)
|
||||
if normalized_revision < 1:
|
||||
raise ValueError("Resource revisions must be positive integers")
|
||||
digest = hashlib.sha256(
|
||||
"\x00".join(
|
||||
(
|
||||
"govoplan-strong-revision-v1",
|
||||
str(resource_type),
|
||||
str(resource_id),
|
||||
str(normalized_revision),
|
||||
)
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
return f'"sha256-{digest}"'
|
||||
|
||||
|
||||
def if_match_matches(header_value: str | None, expected_etag: str) -> bool:
|
||||
"""Apply strong comparison semantics to an If-Match header."""
|
||||
|
||||
if not header_value:
|
||||
return False
|
||||
for raw_candidate in header_value.split(","):
|
||||
candidate = raw_candidate.strip()
|
||||
if candidate == "*":
|
||||
return True
|
||||
if candidate.startswith("W/"):
|
||||
continue
|
||||
if candidate == expected_etag:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def assert_revision_precondition(
|
||||
if_match: str | None,
|
||||
*,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
submitted_base_revision: int,
|
||||
) -> None:
|
||||
if not if_match:
|
||||
raise MissingPreconditionError(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
)
|
||||
submitted_etag = strong_resource_etag(
|
||||
resource_type,
|
||||
resource_id,
|
||||
submitted_base_revision,
|
||||
)
|
||||
if not if_match_matches(if_match, submitted_etag):
|
||||
raise ConcurrencyError(
|
||||
"If-Match does not identify the submitted base revision."
|
||||
)
|
||||
|
||||
|
||||
def claim_revision(
|
||||
session: Session,
|
||||
*,
|
||||
model: type[Any],
|
||||
filters: Iterable[Any],
|
||||
revision_attribute: str,
|
||||
expected_revision: int,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
refresh_path: str | None = None,
|
||||
) -> int:
|
||||
"""Atomically claim the next revision in the caller's transaction."""
|
||||
|
||||
revision_column = getattr(model, revision_attribute)
|
||||
expected = int(expected_revision)
|
||||
normalized_filters = tuple(filters)
|
||||
query = session.query(model).filter(*normalized_filters)
|
||||
updated = query.filter(revision_column == expected).update(
|
||||
{revision_column: revision_column + 1},
|
||||
synchronize_session=False,
|
||||
)
|
||||
if updated == 1:
|
||||
session.flush()
|
||||
return expected + 1
|
||||
|
||||
current = (
|
||||
session.query(revision_column)
|
||||
.filter(*normalized_filters)
|
||||
.scalar()
|
||||
)
|
||||
if current is None:
|
||||
raise LookupError(f"{resource_type} {resource_id} was not found")
|
||||
raise RevisionConflictError(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
current_revision=int(current),
|
||||
submitted_base_revision=expected,
|
||||
refresh_path=refresh_path,
|
||||
current_etag=strong_resource_etag(
|
||||
resource_type,
|
||||
resource_id,
|
||||
int(current),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def three_way_merge(
|
||||
base: Any,
|
||||
local: Any,
|
||||
current: Any,
|
||||
*,
|
||||
protected_paths: Sequence[ProtectedPath] = (),
|
||||
stable_id_fields: Sequence[str] = ("id",),
|
||||
) -> ThreeWayMergeResult:
|
||||
"""Conservatively merge local changes onto a concurrently changed value."""
|
||||
|
||||
return _merge_value(
|
||||
copy.deepcopy(base),
|
||||
copy.deepcopy(local),
|
||||
copy.deepcopy(current),
|
||||
path="",
|
||||
protected_paths=tuple(protected_paths),
|
||||
stable_id_fields=tuple(stable_id_fields),
|
||||
)
|
||||
|
||||
|
||||
def _merge_value(
|
||||
base: Any,
|
||||
local: Any,
|
||||
current: Any,
|
||||
*,
|
||||
path: str,
|
||||
protected_paths: Sequence[ProtectedPath],
|
||||
stable_id_fields: Sequence[str],
|
||||
) -> ThreeWayMergeResult:
|
||||
local_changed = local != base
|
||||
current_changed = current != base
|
||||
if not local_changed:
|
||||
return ThreeWayMergeResult(value=current)
|
||||
if local == current:
|
||||
return ThreeWayMergeResult(value=current)
|
||||
if _is_protected(path, protected_paths):
|
||||
return _conflict(path, "protected_path", base, local, current)
|
||||
if not current_changed:
|
||||
return ThreeWayMergeResult(
|
||||
value=local,
|
||||
applied_paths=[path or "/"],
|
||||
)
|
||||
if isinstance(base, Mapping) and isinstance(local, Mapping) and isinstance(current, Mapping):
|
||||
return _merge_mapping(
|
||||
base,
|
||||
local,
|
||||
current,
|
||||
path=path,
|
||||
protected_paths=protected_paths,
|
||||
stable_id_fields=stable_id_fields,
|
||||
)
|
||||
if isinstance(base, list) and isinstance(local, list) and isinstance(current, list):
|
||||
return _merge_list(
|
||||
base,
|
||||
local,
|
||||
current,
|
||||
path=path,
|
||||
protected_paths=protected_paths,
|
||||
stable_id_fields=stable_id_fields,
|
||||
)
|
||||
return _conflict(path, "same_path_changed", base, local, current)
|
||||
|
||||
|
||||
def _merge_mapping(
|
||||
base: Mapping[str, Any],
|
||||
local: Mapping[str, Any],
|
||||
current: Mapping[str, Any],
|
||||
*,
|
||||
path: str,
|
||||
protected_paths: Sequence[ProtectedPath],
|
||||
stable_id_fields: Sequence[str],
|
||||
) -> ThreeWayMergeResult:
|
||||
missing = object()
|
||||
result: dict[str, Any] = {}
|
||||
conflicts: list[MergeConflict] = []
|
||||
applied_paths: list[str] = []
|
||||
keys = list(dict.fromkeys((*current.keys(), *local.keys(), *base.keys())))
|
||||
for key in keys:
|
||||
child_path = _join_path(path, str(key))
|
||||
base_value = base.get(key, missing)
|
||||
local_value = local.get(key, missing)
|
||||
current_value = current.get(key, missing)
|
||||
merged = _merge_presence(
|
||||
base_value,
|
||||
local_value,
|
||||
current_value,
|
||||
missing=missing,
|
||||
path=child_path,
|
||||
protected_paths=protected_paths,
|
||||
stable_id_fields=stable_id_fields,
|
||||
)
|
||||
conflicts.extend(merged.conflicts)
|
||||
applied_paths.extend(merged.applied_paths)
|
||||
if merged.value is not missing:
|
||||
result[str(key)] = merged.value
|
||||
return ThreeWayMergeResult(
|
||||
value=result,
|
||||
conflicts=conflicts,
|
||||
applied_paths=applied_paths,
|
||||
)
|
||||
|
||||
|
||||
def _merge_presence(
|
||||
base: Any,
|
||||
local: Any,
|
||||
current: Any,
|
||||
*,
|
||||
missing: object,
|
||||
path: str,
|
||||
protected_paths: Sequence[ProtectedPath],
|
||||
stable_id_fields: Sequence[str],
|
||||
) -> ThreeWayMergeResult:
|
||||
if local is missing and current is missing:
|
||||
return ThreeWayMergeResult(value=missing)
|
||||
if base is missing:
|
||||
if local is missing:
|
||||
return ThreeWayMergeResult(value=current)
|
||||
if current is missing:
|
||||
if _is_protected(path, protected_paths):
|
||||
return _conflict(path, "protected_path", None, local, None)
|
||||
return ThreeWayMergeResult(value=local, applied_paths=[path])
|
||||
if local == current:
|
||||
return ThreeWayMergeResult(value=current)
|
||||
return _conflict(path, "concurrent_add", None, local, current)
|
||||
if local is missing:
|
||||
if current == base:
|
||||
if _is_protected(path, protected_paths):
|
||||
return _conflict(path, "protected_path", base, None, current)
|
||||
return ThreeWayMergeResult(value=missing, applied_paths=[path])
|
||||
return _conflict(path, "delete_vs_edit", base, None, current)
|
||||
if current is missing:
|
||||
if local == base:
|
||||
return ThreeWayMergeResult(value=missing)
|
||||
return _conflict(path, "edit_vs_delete", base, local, None)
|
||||
return _merge_value(
|
||||
base,
|
||||
local,
|
||||
current,
|
||||
path=path,
|
||||
protected_paths=protected_paths,
|
||||
stable_id_fields=stable_id_fields,
|
||||
)
|
||||
|
||||
|
||||
def _merge_list(
|
||||
base: list[Any],
|
||||
local: list[Any],
|
||||
current: list[Any],
|
||||
*,
|
||||
path: str,
|
||||
protected_paths: Sequence[ProtectedPath],
|
||||
stable_id_fields: Sequence[str],
|
||||
) -> ThreeWayMergeResult:
|
||||
identity_field = _stable_identity_field(
|
||||
(base, local, current),
|
||||
stable_id_fields,
|
||||
)
|
||||
if identity_field is None:
|
||||
return _conflict(path, "unkeyed_collection", base, local, current)
|
||||
|
||||
base_by_id = {str(item[identity_field]): item for item in base}
|
||||
local_by_id = {str(item[identity_field]): item for item in local}
|
||||
current_by_id = {str(item[identity_field]): item for item in current}
|
||||
base_order = list(base_by_id)
|
||||
local_order = list(local_by_id)
|
||||
current_order = list(current_by_id)
|
||||
|
||||
local_reordered = _common_order(local_order, base_order) != _common_order(
|
||||
base_order,
|
||||
local_order,
|
||||
)
|
||||
current_reordered = _common_order(current_order, base_order) != _common_order(
|
||||
base_order,
|
||||
current_order,
|
||||
)
|
||||
if local_reordered and current_reordered and local_order != current_order:
|
||||
return _conflict(path, "collection_reorder", base_order, local_order, current_order)
|
||||
|
||||
result_by_id: dict[str, Any] = {}
|
||||
conflicts: list[MergeConflict] = []
|
||||
applied_paths: list[str] = []
|
||||
identities = list(dict.fromkeys((*current_order, *local_order, *base_order)))
|
||||
missing = object()
|
||||
for identity in identities:
|
||||
item_path = _join_path(path, f"{identity_field}={identity}")
|
||||
merged = _merge_presence(
|
||||
base_by_id.get(identity, missing),
|
||||
local_by_id.get(identity, missing),
|
||||
current_by_id.get(identity, missing),
|
||||
missing=missing,
|
||||
path=item_path,
|
||||
protected_paths=protected_paths,
|
||||
stable_id_fields=stable_id_fields,
|
||||
)
|
||||
conflicts.extend(merged.conflicts)
|
||||
applied_paths.extend(merged.applied_paths)
|
||||
if merged.value is not missing:
|
||||
result_by_id[identity] = merged.value
|
||||
|
||||
order_source = local_order if local_reordered and not current_reordered else current_order
|
||||
merged_order = [identity for identity in order_source if identity in result_by_id]
|
||||
for identity in identities:
|
||||
if identity in result_by_id and identity not in merged_order:
|
||||
merged_order.append(identity)
|
||||
return ThreeWayMergeResult(
|
||||
value=[result_by_id[identity] for identity in merged_order],
|
||||
conflicts=conflicts,
|
||||
applied_paths=applied_paths,
|
||||
)
|
||||
|
||||
|
||||
def _stable_identity_field(
|
||||
values: Sequence[list[Any]],
|
||||
candidates: Sequence[str],
|
||||
) -> str | None:
|
||||
all_items = [item for value in values for item in value]
|
||||
if not all_items or not all(isinstance(item, Mapping) for item in all_items):
|
||||
return None
|
||||
for candidate in candidates:
|
||||
valid = True
|
||||
for value in values:
|
||||
identities = [
|
||||
str(item.get(candidate, "")).strip()
|
||||
for item in value
|
||||
if isinstance(item, Mapping)
|
||||
]
|
||||
if any(not identity for identity in identities) or len(identities) != len(
|
||||
set(identities)
|
||||
):
|
||||
valid = False
|
||||
break
|
||||
if valid:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _common_order(left: Sequence[str], right: Sequence[str]) -> list[str]:
|
||||
right_set = set(right)
|
||||
return [item for item in left if item in right_set]
|
||||
|
||||
|
||||
def _is_protected(path: str, protected_paths: Sequence[ProtectedPath]) -> bool:
|
||||
normalized = path or "/"
|
||||
for protected in protected_paths:
|
||||
if callable(protected):
|
||||
if protected(normalized):
|
||||
return True
|
||||
continue
|
||||
prefix = protected.rstrip("/") or "/"
|
||||
if normalized == prefix or normalized.startswith(f"{prefix}/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _join_path(parent: str, segment: str) -> str:
|
||||
escaped = segment.replace("~", "~0").replace("/", "~1")
|
||||
return f"{parent}/{escaped}" if parent else f"/{escaped}"
|
||||
|
||||
|
||||
def _conflict(
|
||||
path: str,
|
||||
kind: str,
|
||||
base: Any,
|
||||
local: Any,
|
||||
current: Any,
|
||||
) -> ThreeWayMergeResult:
|
||||
return ThreeWayMergeResult(
|
||||
value=current,
|
||||
conflicts=[
|
||||
MergeConflict(
|
||||
path=path or "/",
|
||||
kind=kind,
|
||||
base_value=base,
|
||||
local_value=local,
|
||||
current_value=current,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _bounded_json_value(value: Any, *, depth: int = 0) -> Any:
|
||||
if depth >= 4:
|
||||
return {"summary": type(value).__name__}
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value[:500]
|
||||
if isinstance(value, Mapping):
|
||||
result = {
|
||||
str(key)[:100]: _bounded_json_value(item, depth=depth + 1)
|
||||
for key, item in list(value.items())[:20]
|
||||
}
|
||||
if len(value) > 20:
|
||||
result["_truncated_items"] = len(value) - 20
|
||||
return result
|
||||
if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)):
|
||||
result = [
|
||||
_bounded_json_value(item, depth=depth + 1)
|
||||
for item in list(value)[:20]
|
||||
]
|
||||
if len(value) > 20:
|
||||
result.append({"_truncated_items": len(value) - 20})
|
||||
return result
|
||||
try:
|
||||
return json.loads(json.dumps(value))
|
||||
except (TypeError, ValueError):
|
||||
return {"summary": type(value).__name__}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ConcurrencyError",
|
||||
"MergeConflict",
|
||||
"MissingPreconditionError",
|
||||
"RevisionConflictError",
|
||||
"ThreeWayMergeResult",
|
||||
"assert_revision_precondition",
|
||||
"claim_revision",
|
||||
"if_match_matches",
|
||||
"strong_resource_etag",
|
||||
"three_way_merge",
|
||||
]
|
||||
@@ -3,11 +3,11 @@ from __future__ import annotations
|
||||
import base64
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Literal, Protocol, runtime_checkable
|
||||
import re
|
||||
from typing import Any, Literal, Protocol, cast, runtime_checkable
|
||||
|
||||
from govoplan_core.core.module_package_catalog import (
|
||||
_canonical_catalog_bytes,
|
||||
@@ -20,6 +20,13 @@ from govoplan_core.core.module_package_catalog import (
|
||||
_is_http_url,
|
||||
_load_private_key,
|
||||
_parse_trusted_keys,
|
||||
_record_catalog_acceptance,
|
||||
)
|
||||
from govoplan_core.core.external_references import (
|
||||
IntegrationMaturity,
|
||||
SOURCE_AUTHORITY_MODES,
|
||||
SourceAuthorityMode,
|
||||
integration_maturity_rank,
|
||||
)
|
||||
from govoplan_core.security.http_fetch import fetch_http_text
|
||||
|
||||
@@ -28,6 +35,166 @@ CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
|
||||
|
||||
DiagnosticSeverity = Literal["blocker", "warning", "info"]
|
||||
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
|
||||
ConfigurationPackageClass = Literal[
|
||||
"reference",
|
||||
"product",
|
||||
"sector",
|
||||
"deployment",
|
||||
"integration",
|
||||
]
|
||||
ConfigurationPackageEvidenceKind = Literal[
|
||||
"target_test",
|
||||
"migration",
|
||||
"upgrade",
|
||||
"recovery",
|
||||
"security",
|
||||
"operations",
|
||||
"accessibility",
|
||||
"privacy",
|
||||
"documentation",
|
||||
]
|
||||
|
||||
CONFIGURATION_PACKAGE_CLASSES: tuple[ConfigurationPackageClass, ...] = (
|
||||
"reference",
|
||||
"product",
|
||||
"sector",
|
||||
"deployment",
|
||||
"integration",
|
||||
)
|
||||
CONFIGURATION_PACKAGE_EVIDENCE_KINDS: tuple[
|
||||
ConfigurationPackageEvidenceKind, ...
|
||||
] = (
|
||||
"target_test",
|
||||
"migration",
|
||||
"upgrade",
|
||||
"recovery",
|
||||
"security",
|
||||
"operations",
|
||||
"accessibility",
|
||||
"privacy",
|
||||
"documentation",
|
||||
)
|
||||
_SHA256_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationPackageParent:
|
||||
package_id: str
|
||||
version: str
|
||||
relation: Literal["derived_from", "specializes", "extends"] = "derived_from"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.package_id.strip() or not self.version.strip():
|
||||
raise ValueError("Configuration package parent id and version are required.")
|
||||
if self.relation not in {"derived_from", "specializes", "extends"}:
|
||||
raise ValueError(
|
||||
f"Unsupported configuration package parent relation: {self.relation!r}."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageParent":
|
||||
relation = _optional_str(value, "relation") or "derived_from"
|
||||
if relation not in {"derived_from", "specializes", "extends"}:
|
||||
raise ValueError(f"Unsupported configuration package parent relation: {relation!r}.")
|
||||
return cls(
|
||||
package_id=_required_str(value, "package_id"),
|
||||
version=_required_str(value, "version"),
|
||||
relation=relation,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
return {
|
||||
"package_id": self.package_id,
|
||||
"version": self.version,
|
||||
"relation": self.relation,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationPackageEvidence:
|
||||
kind: ConfigurationPackageEvidenceKind
|
||||
reference: str
|
||||
summary: str
|
||||
checksum: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.kind not in CONFIGURATION_PACKAGE_EVIDENCE_KINDS:
|
||||
raise ValueError(
|
||||
f"Unsupported configuration package evidence kind: {self.kind!r}."
|
||||
)
|
||||
if not self.reference.strip() or not self.summary.strip():
|
||||
raise ValueError(
|
||||
"Configuration package evidence reference and summary are required."
|
||||
)
|
||||
if self.checksum is not None and not _SHA256_RE.fullmatch(self.checksum):
|
||||
raise ValueError(
|
||||
"Configuration package evidence checksum must use sha256:<64 lowercase hex>."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageEvidence":
|
||||
kind = _required_str(value, "kind")
|
||||
if kind not in CONFIGURATION_PACKAGE_EVIDENCE_KINDS:
|
||||
raise ValueError(f"Unsupported configuration package evidence kind: {kind!r}.")
|
||||
return cls(
|
||||
kind=kind,
|
||||
reference=_required_str(value, "reference"),
|
||||
summary=_required_str(value, "summary"),
|
||||
checksum=_optional_str(value, "checksum"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"kind": self.kind,
|
||||
"reference": self.reference,
|
||||
"summary": self.summary,
|
||||
"checksum": self.checksum,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationProviderExpectation:
|
||||
provider_id: str
|
||||
authority_mode: SourceAuthorityMode
|
||||
minimum_maturity: IntegrationMaturity
|
||||
binding_ref: str | None = None
|
||||
health_expectation: str = "healthy"
|
||||
freshness_expectation: str | None = None
|
||||
recovery_expectation: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.provider_id.strip():
|
||||
raise ValueError("Configuration provider expectation id is required.")
|
||||
if self.authority_mode not in SOURCE_AUTHORITY_MODES:
|
||||
raise ValueError(
|
||||
f"Unsupported provider authority mode: {self.authority_mode!r}."
|
||||
)
|
||||
integration_maturity_rank(self.minimum_maturity)
|
||||
if not self.health_expectation.strip():
|
||||
raise ValueError("Provider health expectation is required.")
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationProviderExpectation":
|
||||
return cls(
|
||||
provider_id=_required_str(value, "provider_id"),
|
||||
authority_mode=_required_str(value, "authority_mode"),
|
||||
minimum_maturity=_required_str(value, "minimum_maturity"),
|
||||
binding_ref=_optional_str(value, "binding_ref"),
|
||||
health_expectation=_optional_str(value, "health_expectation") or "healthy",
|
||||
freshness_expectation=_optional_str(value, "freshness_expectation"),
|
||||
recovery_expectation=_optional_str(value, "recovery_expectation"),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"provider_id": self.provider_id,
|
||||
"authority_mode": self.authority_mode,
|
||||
"minimum_maturity": self.minimum_maturity,
|
||||
"binding_ref": self.binding_ref,
|
||||
"health_expectation": self.health_expectation,
|
||||
"freshness_expectation": self.freshness_expectation,
|
||||
"recovery_expectation": self.recovery_expectation,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -75,6 +242,7 @@ class ConfigurationPackageManifest:
|
||||
package_id: str
|
||||
name: str
|
||||
version: str
|
||||
package_class: ConfigurationPackageClass = "product"
|
||||
description: str | None = None
|
||||
publisher: str | None = None
|
||||
category: str | None = None
|
||||
@@ -88,6 +256,29 @@ class ConfigurationPackageManifest:
|
||||
artifact_ref: str | None = None
|
||||
artifact_sha256: str | None = None
|
||||
signature: Mapping[str, Any] | None = None
|
||||
parents: tuple[ConfigurationPackageParent, ...] = ()
|
||||
evidence: tuple[ConfigurationPackageEvidence, ...] = ()
|
||||
provider_expectations: tuple[ConfigurationProviderExpectation, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.package_class not in CONFIGURATION_PACKAGE_CLASSES:
|
||||
raise ValueError(
|
||||
f"Unsupported configuration package class: {self.package_class!r}."
|
||||
)
|
||||
parent_keys = {(item.package_id, item.version) for item in self.parents}
|
||||
if len(parent_keys) != len(self.parents):
|
||||
raise ValueError("Configuration package parents must be unique.")
|
||||
evidence_keys = {(item.kind, item.reference) for item in self.evidence}
|
||||
if len(evidence_keys) != len(self.evidence):
|
||||
raise ValueError("Configuration package evidence must be unique.")
|
||||
provider_ids = [item.provider_id for item in self.provider_expectations]
|
||||
if len(provider_ids) != len(set(provider_ids)):
|
||||
raise ValueError("Configuration package provider expectations must be unique.")
|
||||
for expectation in self.provider_expectations:
|
||||
integration_maturity_rank(expectation.minimum_maturity)
|
||||
issues = configuration_package_claim_issues(self)
|
||||
if issues:
|
||||
raise ValueError("Invalid configuration package claim: " + "; ".join(issues))
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ConfigurationPackageManifest":
|
||||
@@ -95,6 +286,7 @@ class ConfigurationPackageManifest:
|
||||
package_id=_required_str(value, "package_id"),
|
||||
name=_required_str(value, "name"),
|
||||
version=_required_str(value, "version"),
|
||||
package_class=_optional_str(value, "package_class") or "product",
|
||||
description=_optional_str(value, "description"),
|
||||
publisher=_optional_str(value, "publisher"),
|
||||
category=_optional_str(value, "category"),
|
||||
@@ -108,6 +300,21 @@ class ConfigurationPackageManifest:
|
||||
artifact_ref=_optional_str(value, "artifact_ref"),
|
||||
artifact_sha256=_optional_str(value, "artifact_sha256"),
|
||||
signature=value.get("signature") if isinstance(value.get("signature"), Mapping) else None,
|
||||
parents=tuple(
|
||||
ConfigurationPackageParent.from_mapping(item)
|
||||
for item in _object_list(value.get("parents"), field_name="parents")
|
||||
),
|
||||
evidence=tuple(
|
||||
ConfigurationPackageEvidence.from_mapping(item)
|
||||
for item in _object_list(value.get("evidence"), field_name="evidence")
|
||||
),
|
||||
provider_expectations=tuple(
|
||||
ConfigurationProviderExpectation.from_mapping(item)
|
||||
for item in _object_list(
|
||||
value.get("provider_expectations"),
|
||||
field_name="provider_expectations",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
@@ -115,12 +322,18 @@ class ConfigurationPackageManifest:
|
||||
"package_id": self.package_id,
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"package_class": self.package_class,
|
||||
"required_modules": [item.to_dict() for item in self.required_modules],
|
||||
"required_capabilities": list(self.required_capabilities),
|
||||
"optional_modules": [item.to_dict() for item in self.optional_modules],
|
||||
"fragments": [item.to_dict() for item in self.fragments],
|
||||
"data_requirements": [dict(item) for item in self.data_requirements],
|
||||
"tags": list(self.tags),
|
||||
"parents": [item.to_dict() for item in self.parents],
|
||||
"evidence": [item.to_dict() for item in self.evidence],
|
||||
"provider_expectations": [
|
||||
item.to_dict() for item in self.provider_expectations
|
||||
],
|
||||
}
|
||||
for key, value in (
|
||||
("description", self.description),
|
||||
@@ -221,6 +434,12 @@ class ConfigurationPreflightContext:
|
||||
supplied_data: Mapping[str, Any] = field(default_factory=dict)
|
||||
installed_modules: Mapping[str, str] = field(default_factory=dict)
|
||||
capabilities: frozenset[str] = frozenset()
|
||||
external_provider_declarations: Mapping[str, Mapping[str, Any]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
external_provider_states: Mapping[str, Mapping[str, Any]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
dry_run: bool = True
|
||||
|
||||
|
||||
@@ -286,6 +505,7 @@ def dry_run_configuration_package(
|
||||
|
||||
diagnostics.extend(_module_requirement_diagnostics(manifest, context))
|
||||
diagnostics.extend(_capability_requirement_diagnostics(manifest, context))
|
||||
diagnostics.extend(_provider_expectation_diagnostics(manifest, context))
|
||||
for item in manifest.data_requirements:
|
||||
requirement = ConfigurationRequiredData.from_mapping(item)
|
||||
required_data.append(requirement)
|
||||
@@ -369,6 +589,8 @@ def apply_configuration_package(
|
||||
supplied_data=supplied_data if supplied_data is not None else context.supplied_data,
|
||||
installed_modules=context.installed_modules,
|
||||
capabilities=context.capabilities,
|
||||
external_provider_declarations=context.external_provider_declarations,
|
||||
external_provider_states=context.external_provider_states,
|
||||
dry_run=False,
|
||||
)
|
||||
preflight = dry_run_configuration_package(manifest, providers, apply_context)
|
||||
@@ -636,33 +858,158 @@ def sign_configuration_package_catalog(*, path: Path, key_id: str, private_key_p
|
||||
|
||||
|
||||
def record_configuration_package_catalog_acceptance(validation: dict[str, object]) -> None:
|
||||
state_path = _configured_sequence_state_path()
|
||||
if state_path is None or validation.get("valid") is not True:
|
||||
return
|
||||
channel = validation.get("channel")
|
||||
sequence = validation.get("sequence")
|
||||
if not isinstance(channel, str) or not isinstance(sequence, int):
|
||||
return
|
||||
try:
|
||||
state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
|
||||
except json.JSONDecodeError:
|
||||
state = {}
|
||||
if not isinstance(state, dict):
|
||||
state = {}
|
||||
channels = state.get("channels")
|
||||
if not isinstance(channels, dict):
|
||||
channels = {}
|
||||
channel_state = channels.get(channel)
|
||||
if not isinstance(channel_state, dict):
|
||||
channel_state = {}
|
||||
channel_state["last_sequence"] = max(int(channel_state.get("last_sequence") or 0), sequence)
|
||||
channel_state["accepted_at"] = datetime.now(tz=UTC).isoformat().replace("+00:00", "Z")
|
||||
channel_state["key_id"] = validation.get("key_id")
|
||||
channel_state["source"] = validation.get("source") or validation.get("path")
|
||||
channels[channel] = channel_state
|
||||
state["channels"] = channels
|
||||
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
state_path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
_record_catalog_acceptance(
|
||||
validation,
|
||||
state_path=_configured_sequence_state_path(),
|
||||
)
|
||||
|
||||
|
||||
def configuration_package_claim_issues(
|
||||
manifest: ConfigurationPackageManifest,
|
||||
) -> tuple[str, ...]:
|
||||
evidence_kinds = {item.kind for item in manifest.evidence}
|
||||
required_evidence: dict[str, frozenset[str]] = {
|
||||
"reference": frozenset(
|
||||
{
|
||||
"target_test",
|
||||
"recovery",
|
||||
"security",
|
||||
"operations",
|
||||
"accessibility",
|
||||
"privacy",
|
||||
"documentation",
|
||||
}
|
||||
),
|
||||
"product": frozenset(),
|
||||
"sector": frozenset({"documentation"}),
|
||||
"deployment": frozenset(
|
||||
{"target_test", "recovery", "security", "operations"}
|
||||
),
|
||||
"integration": frozenset(
|
||||
{"target_test", "recovery", "operations", "documentation"}
|
||||
),
|
||||
}
|
||||
issues: list[str] = []
|
||||
missing = sorted(required_evidence[manifest.package_class] - evidence_kinds)
|
||||
if missing:
|
||||
issues.append(
|
||||
f"{manifest.package_class} package is missing evidence: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
if manifest.package_class in {"reference", "deployment", "integration"}:
|
||||
unbound = sorted(
|
||||
item.kind
|
||||
for item in manifest.evidence
|
||||
if item.kind != "documentation" and item.checksum is None
|
||||
)
|
||||
if unbound:
|
||||
issues.append(
|
||||
f"{manifest.package_class} package has evidence without checksums: "
|
||||
+ ", ".join(unbound)
|
||||
)
|
||||
if manifest.package_class == "sector" and not manifest.parents:
|
||||
issues.append("sector packages must declare a parent package/version")
|
||||
if manifest.package_class == "integration" and not manifest.provider_expectations:
|
||||
issues.append("integration packages must declare external provider expectations")
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def validate_configuration_package_derivation(
|
||||
child: ConfigurationPackageManifest,
|
||||
parent: ConfigurationPackageManifest,
|
||||
) -> tuple[ConfigurationDiagnostic, ...]:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
if not any(
|
||||
item.package_id == parent.package_id and item.version == parent.version
|
||||
for item in child.parents
|
||||
):
|
||||
diagnostics.append(
|
||||
ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="package_parent_provenance_missing",
|
||||
message=(
|
||||
f"Package {child.package_id!r} does not declare parent "
|
||||
f"{parent.package_id}@{parent.version}."
|
||||
),
|
||||
object_ref=parent.package_id,
|
||||
)
|
||||
)
|
||||
child_modules = {item.module_id: item for item in child.required_modules}
|
||||
for requirement in parent.required_modules:
|
||||
candidate = child_modules.get(requirement.module_id)
|
||||
if candidate is None or (
|
||||
requirement.version is not None
|
||||
and candidate.version != requirement.version
|
||||
):
|
||||
diagnostics.append(
|
||||
ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="package_parent_module_constraint_loosened",
|
||||
message=(
|
||||
f"Derived package loosens parent module requirement "
|
||||
f"{requirement.module_id!r}."
|
||||
),
|
||||
module_id=requirement.module_id,
|
||||
)
|
||||
)
|
||||
for capability in set(parent.required_capabilities) - set(
|
||||
child.required_capabilities
|
||||
):
|
||||
diagnostics.append(
|
||||
ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="package_parent_capability_constraint_loosened",
|
||||
message=(
|
||||
f"Derived package removes required capability {capability!r}."
|
||||
),
|
||||
object_ref=capability,
|
||||
)
|
||||
)
|
||||
child_providers = {
|
||||
item.provider_id: item for item in child.provider_expectations
|
||||
}
|
||||
for expectation in parent.provider_expectations:
|
||||
candidate = child_providers.get(expectation.provider_id)
|
||||
if candidate is None:
|
||||
diagnostics.append(
|
||||
ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="package_parent_provider_constraint_removed",
|
||||
message=(
|
||||
f"Derived package removes provider expectation "
|
||||
f"{expectation.provider_id!r}."
|
||||
),
|
||||
object_ref=expectation.provider_id,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if candidate.authority_mode != expectation.authority_mode:
|
||||
diagnostics.append(
|
||||
ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="package_parent_authority_mode_changed",
|
||||
message=(
|
||||
f"Derived package changes authority mode for provider "
|
||||
f"{expectation.provider_id!r}."
|
||||
),
|
||||
object_ref=expectation.provider_id,
|
||||
)
|
||||
)
|
||||
if integration_maturity_rank(
|
||||
candidate.minimum_maturity
|
||||
) < integration_maturity_rank(expectation.minimum_maturity):
|
||||
diagnostics.append(
|
||||
ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="package_parent_provider_maturity_loosened",
|
||||
message=(
|
||||
f"Derived package lowers provider maturity for "
|
||||
f"{expectation.provider_id!r}."
|
||||
),
|
||||
object_ref=expectation.provider_id,
|
||||
)
|
||||
)
|
||||
return tuple(diagnostics)
|
||||
|
||||
|
||||
def _configuration_package_manifest(package: ConfigurationPackageManifest | Mapping[str, Any]) -> ConfigurationPackageManifest:
|
||||
@@ -716,6 +1063,194 @@ def _capability_requirement_diagnostics(manifest: ConfigurationPackageManifest,
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _provider_expectation_diagnostics(
|
||||
manifest: ConfigurationPackageManifest,
|
||||
context: ConfigurationPreflightContext,
|
||||
) -> list[ConfigurationDiagnostic]:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
for expectation in manifest.provider_expectations:
|
||||
declaration = context.external_provider_declarations.get(
|
||||
expectation.provider_id
|
||||
)
|
||||
if declaration is None:
|
||||
diagnostics.append(
|
||||
ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="external_provider_missing",
|
||||
message=(
|
||||
f"Required external provider {expectation.provider_id!r} "
|
||||
"is not installed or declared."
|
||||
),
|
||||
object_ref=expectation.provider_id,
|
||||
resolution=(
|
||||
"Install and enable a module exposing the declared provider."
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
supported_modes = {
|
||||
str(item)
|
||||
for item in declaration.get("authority_modes", ())
|
||||
if str(item).strip()
|
||||
}
|
||||
if expectation.authority_mode not in supported_modes:
|
||||
diagnostics.append(
|
||||
ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="external_provider_authority_mode_unsupported",
|
||||
message=(
|
||||
f"Provider {expectation.provider_id!r} does not support "
|
||||
f"authority mode {expectation.authority_mode!r}."
|
||||
),
|
||||
object_ref=expectation.provider_id,
|
||||
)
|
||||
)
|
||||
actual_maturity = str(declaration.get("maturity") or "discover")
|
||||
try:
|
||||
maturity_sufficient = integration_maturity_rank(
|
||||
cast(IntegrationMaturity, actual_maturity)
|
||||
) >= integration_maturity_rank(expectation.minimum_maturity)
|
||||
except ValueError:
|
||||
maturity_sufficient = False
|
||||
if not maturity_sufficient:
|
||||
diagnostics.append(
|
||||
ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="external_provider_maturity_insufficient",
|
||||
message=(
|
||||
f"Provider {expectation.provider_id!r} has maturity "
|
||||
f"{actual_maturity!r}; {expectation.minimum_maturity!r} is required."
|
||||
),
|
||||
object_ref=expectation.provider_id,
|
||||
)
|
||||
)
|
||||
provider_state = context.external_provider_states.get(
|
||||
expectation.provider_id
|
||||
)
|
||||
if provider_state is None:
|
||||
diagnostics.append(
|
||||
ConfigurationDiagnostic(
|
||||
severity="warning",
|
||||
code="external_provider_health_unverified",
|
||||
message=(
|
||||
f"Provider {expectation.provider_id!r} has no current "
|
||||
"health/freshness observation."
|
||||
),
|
||||
object_ref=expectation.provider_id,
|
||||
)
|
||||
)
|
||||
continue
|
||||
state = provider_state
|
||||
if expectation.binding_ref is not None:
|
||||
bindings = provider_state.get("bindings")
|
||||
matching_binding = next(
|
||||
(
|
||||
item
|
||||
for item in bindings
|
||||
if isinstance(item, Mapping)
|
||||
and str(item.get("binding_ref") or "")
|
||||
== expectation.binding_ref
|
||||
),
|
||||
None,
|
||||
) if isinstance(bindings, Sequence) and not isinstance(
|
||||
bindings, (str, bytes)
|
||||
) else None
|
||||
if matching_binding is None and str(
|
||||
provider_state.get("binding_ref") or ""
|
||||
) == expectation.binding_ref:
|
||||
matching_binding = provider_state
|
||||
if matching_binding is None:
|
||||
diagnostics.append(
|
||||
ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="external_provider_binding_mismatch",
|
||||
message=(
|
||||
f"Provider {expectation.provider_id!r} is not observed through "
|
||||
f"required binding {expectation.binding_ref!r}."
|
||||
),
|
||||
object_ref=expectation.provider_id,
|
||||
)
|
||||
)
|
||||
continue
|
||||
state = matching_binding
|
||||
health = str(state.get("health") or state.get("health_state") or "unknown")
|
||||
accepted_health = (
|
||||
{"ok", "healthy"}
|
||||
if expectation.health_expectation == "healthy"
|
||||
else {expectation.health_expectation}
|
||||
)
|
||||
if health not in accepted_health:
|
||||
diagnostics.append(
|
||||
ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="external_provider_unhealthy",
|
||||
message=(
|
||||
f"Provider {expectation.provider_id!r} health is {health!r}."
|
||||
),
|
||||
object_ref=expectation.provider_id,
|
||||
resolution="Restore provider health or use a documented degraded path.",
|
||||
)
|
||||
)
|
||||
observed_authority_mode = str(state.get("authority_mode") or "")
|
||||
if (
|
||||
observed_authority_mode
|
||||
and observed_authority_mode != expectation.authority_mode
|
||||
):
|
||||
diagnostics.append(
|
||||
ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="external_provider_binding_authority_mismatch",
|
||||
message=(
|
||||
f"Provider {expectation.provider_id!r} is configured as "
|
||||
f"{observed_authority_mode!r}; {expectation.authority_mode!r} "
|
||||
"is required."
|
||||
),
|
||||
object_ref=expectation.provider_id,
|
||||
)
|
||||
)
|
||||
if expectation.freshness_expectation is not None:
|
||||
freshness = str(
|
||||
state.get("freshness")
|
||||
or state.get("freshness_state")
|
||||
or "unknown"
|
||||
)
|
||||
if freshness != expectation.freshness_expectation:
|
||||
diagnostics.append(
|
||||
ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="external_provider_freshness_expectation_failed",
|
||||
message=(
|
||||
f"Provider {expectation.provider_id!r} freshness is "
|
||||
f"{freshness!r}; {expectation.freshness_expectation!r} is required."
|
||||
),
|
||||
object_ref=expectation.provider_id,
|
||||
)
|
||||
)
|
||||
if expectation.recovery_expectation is not None:
|
||||
behavior = declaration.get("behavior")
|
||||
declared_recovery = (
|
||||
behavior.get(expectation.recovery_expectation)
|
||||
if isinstance(behavior, Mapping)
|
||||
else None
|
||||
)
|
||||
observed_recovery = state.get("recovery") or state.get(
|
||||
"recovery_state"
|
||||
)
|
||||
if not declared_recovery and observed_recovery != expectation.recovery_expectation:
|
||||
diagnostics.append(
|
||||
ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="external_provider_recovery_expectation_failed",
|
||||
message=(
|
||||
f"Provider {expectation.provider_id!r} does not satisfy "
|
||||
f"recovery expectation {expectation.recovery_expectation!r}."
|
||||
),
|
||||
object_ref=expectation.provider_id,
|
||||
)
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _dedupe_diagnostics(items: Sequence[ConfigurationDiagnostic]) -> list[ConfigurationDiagnostic]:
|
||||
seen: set[tuple[object, ...]] = set()
|
||||
result: list[ConfigurationDiagnostic] = []
|
||||
|
||||
@@ -301,6 +301,18 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
maintenance_required=True,
|
||||
notes="This deployment-wide egress boundary remains out of band and applies to every connector worker.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
|
||||
label="Installer-managed Garage trust",
|
||||
owner_module="files",
|
||||
scope="system",
|
||||
storage="environment",
|
||||
ui_managed=False,
|
||||
risk="high",
|
||||
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
|
||||
maintenance_required=True,
|
||||
notes="Reserved for the exact installer-owned http://garage:3900 service; it must never authorize another S3 endpoint.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
||||
label="Structured connector response limit",
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
CONNECTOR_RUNTIME_CONTRACT_VERSION = "1.0"
|
||||
|
||||
ConnectorDiagnosticSeverity = Literal["info", "warning", "error"]
|
||||
ConnectorDiagnosticStage = Literal[
|
||||
"configuration",
|
||||
"authentication",
|
||||
"discovery",
|
||||
"read",
|
||||
"mapping",
|
||||
"planning",
|
||||
"apply",
|
||||
"reconciliation",
|
||||
]
|
||||
ConnectorEffectKind = Literal[
|
||||
"create",
|
||||
"update",
|
||||
"delete",
|
||||
"conflict",
|
||||
"unchanged",
|
||||
"ignored",
|
||||
]
|
||||
ConnectorOutcomeState = Literal[
|
||||
"preview",
|
||||
"accepted",
|
||||
"rejected",
|
||||
"outcome_unknown",
|
||||
]
|
||||
|
||||
|
||||
class ConnectorContractError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConnectorEndpoint:
|
||||
"""Sanitized endpoint identity. Credentials never belong in this value."""
|
||||
|
||||
url: str
|
||||
credential_ref: str | None = None
|
||||
tls_mode: Literal["required", "start_tls", "system", "disabled"] = "required"
|
||||
options: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
normalized = self.url.strip()
|
||||
parsed = urlsplit(normalized)
|
||||
if not parsed.scheme or not parsed.hostname:
|
||||
raise ConnectorContractError("Connector endpoints require an absolute URL.")
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise ConnectorContractError(
|
||||
"Connector endpoint URLs must not contain credentials."
|
||||
)
|
||||
object.__setattr__(self, "url", normalized)
|
||||
if self.credential_ref is not None:
|
||||
credential_ref = self.credential_ref.strip()
|
||||
if not credential_ref:
|
||||
raise ConnectorContractError("Credential references cannot be blank.")
|
||||
object.__setattr__(self, "credential_ref", credential_ref)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConnectorDiagnostic:
|
||||
severity: ConnectorDiagnosticSeverity
|
||||
code: str
|
||||
message: str
|
||||
stage: ConnectorDiagnosticStage
|
||||
retryable: bool = False
|
||||
source_ref: str | None = None
|
||||
object_ref: str | None = None
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.code.strip() or len(self.code) > 120:
|
||||
raise ConnectorContractError(
|
||||
"Connector diagnostic codes must contain 1 to 120 characters."
|
||||
)
|
||||
if not self.message.strip():
|
||||
raise ConnectorContractError("Connector diagnostic messages cannot be blank.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConnectorEffectPreview:
|
||||
effect: ConnectorEffectKind
|
||||
source_object_ref: str
|
||||
target_object_ref: str | None = None
|
||||
changed_fields: tuple[str, ...] = ()
|
||||
sample: Mapping[str, object] = field(default_factory=dict)
|
||||
reason_code: str | None = None
|
||||
outcome: ConnectorOutcomeState = "preview"
|
||||
revision: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.source_object_ref.strip():
|
||||
raise ConnectorContractError("Preview effects require a source object reference.")
|
||||
if self.outcome != "preview":
|
||||
raise ConnectorContractError("Dry-run effects must retain the preview outcome.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConnectorEffectSummary:
|
||||
creates: int = 0
|
||||
updates: int = 0
|
||||
deletes: int = 0
|
||||
conflicts: int = 0
|
||||
unchanged: int = 0
|
||||
ignored: int = 0
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return (
|
||||
self.creates
|
||||
+ self.updates
|
||||
+ self.deletes
|
||||
+ self.conflicts
|
||||
+ self.unchanged
|
||||
+ self.ignored
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConnectorDryRunRequest:
|
||||
tenant_id: str
|
||||
source_ref: str
|
||||
force_full: bool = False
|
||||
max_items: int = 1_000
|
||||
expected_source_revision: str | None = None
|
||||
context: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.tenant_id.strip() or not self.source_ref.strip():
|
||||
raise ConnectorContractError("Dry runs require tenant and source references.")
|
||||
if not 1 <= self.max_items <= 10_000:
|
||||
raise ConnectorContractError("Dry-run max_items must be between 1 and 10000.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConnectorDryRunResult:
|
||||
contract_version: str
|
||||
source_ref: str
|
||||
source_revision: str
|
||||
source_fingerprint: str
|
||||
input_hash: str
|
||||
generated_at: datetime
|
||||
summary: ConnectorEffectSummary
|
||||
effects: tuple[ConnectorEffectPreview, ...] = ()
|
||||
diagnostics: tuple[ConnectorDiagnostic, ...] = ()
|
||||
truncated: bool = False
|
||||
stale: bool = False
|
||||
apply_token: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.contract_version != CONNECTOR_RUNTIME_CONTRACT_VERSION:
|
||||
raise ConnectorContractError(
|
||||
f"Unsupported connector contract version: {self.contract_version!r}."
|
||||
)
|
||||
for name in ("source_ref", "source_revision", "source_fingerprint", "input_hash"):
|
||||
if not str(getattr(self, name)).strip():
|
||||
raise ConnectorContractError(f"Dry-run {name} cannot be blank.")
|
||||
if self.summary.total != len(self.effects):
|
||||
raise ConnectorContractError(
|
||||
"Dry-run summary counts must match the returned effect list."
|
||||
)
|
||||
|
||||
@property
|
||||
def can_apply(self) -> bool:
|
||||
return (
|
||||
self.apply_token is not None
|
||||
and not self.truncated
|
||||
and not self.stale
|
||||
and self.summary.conflicts == 0
|
||||
and not any(item.severity == "error" for item in self.diagnostics)
|
||||
)
|
||||
|
||||
|
||||
def summarize_connector_effects(
|
||||
effects: tuple[ConnectorEffectPreview, ...],
|
||||
) -> ConnectorEffectSummary:
|
||||
counts = Counter(item.effect for item in effects)
|
||||
return ConnectorEffectSummary(
|
||||
creates=counts["create"],
|
||||
updates=counts["update"],
|
||||
deletes=counts["delete"],
|
||||
conflicts=counts["conflict"],
|
||||
unchanged=counts["unchanged"],
|
||||
ignored=counts["ignored"],
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ConnectorDryRunProvider(Protocol):
|
||||
def preview(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ConnectorDryRunRequest,
|
||||
) -> ConnectorDryRunResult:
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CONNECTOR_RUNTIME_CONTRACT_VERSION",
|
||||
"ConnectorContractError",
|
||||
"ConnectorDiagnostic",
|
||||
"ConnectorDiagnosticSeverity",
|
||||
"ConnectorDiagnosticStage",
|
||||
"ConnectorDryRunProvider",
|
||||
"ConnectorDryRunRequest",
|
||||
"ConnectorDryRunResult",
|
||||
"ConnectorEffectKind",
|
||||
"ConnectorEffectPreview",
|
||||
"ConnectorEffectSummary",
|
||||
"ConnectorEndpoint",
|
||||
"ConnectorOutcomeState",
|
||||
"summarize_connector_effects",
|
||||
]
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.distribution_lists import (
|
||||
DistributionChannel,
|
||||
DistributionExplanation,
|
||||
DistributionOutcome,
|
||||
DistributionSourceReference,
|
||||
)
|
||||
|
||||
|
||||
CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION = "addresses.contact_point_resolution"
|
||||
CONTACT_POINT_CONTRACT_VERSION = "1.0"
|
||||
|
||||
ContactPointFallbackRule = Literal["none", "primary", "any"]
|
||||
PostalAddressFormat = Literal["domestic", "international"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactPointResolutionRequest:
|
||||
tenant_id: str
|
||||
subject: DistributionSourceReference
|
||||
effective_at: datetime
|
||||
purpose: str | None = None
|
||||
requested_channels: tuple[DistributionChannel, ...] = ()
|
||||
address_purpose: str | None = None
|
||||
fallback_rule: ContactPointFallbackRule = "primary"
|
||||
locale: str | None = None
|
||||
postal_format: PostalAddressFormat = "domestic"
|
||||
context: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactPointCandidate:
|
||||
channel: DistributionChannel
|
||||
target: str
|
||||
target_key: str
|
||||
status: DistributionOutcome
|
||||
contact_point_id: str | None = None
|
||||
address_purpose: str | None = None
|
||||
locale: str | None = None
|
||||
preferred: bool = False
|
||||
preference_rank: int | None = None
|
||||
reason_code: str | None = None
|
||||
explanation: str | None = None
|
||||
source: DistributionSourceReference | None = None
|
||||
source_revision: str | None = None
|
||||
preference_revision: str | None = None
|
||||
consent_revision: str | None = None
|
||||
value: Mapping[str, object] = field(default_factory=dict)
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactPointResolution:
|
||||
contract_version: str
|
||||
subject: DistributionSourceReference
|
||||
status: DistributionOutcome
|
||||
contact_id: str | None = None
|
||||
display_name: str | None = None
|
||||
candidates: tuple[ContactPointCandidate, ...] = ()
|
||||
excluded: tuple[ContactPointCandidate, ...] = ()
|
||||
explanations: tuple[DistributionExplanation, ...] = ()
|
||||
source_revision: str | None = None
|
||||
source_fingerprint: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactPointSourceRequest:
|
||||
tenant_id: str
|
||||
source_id: str
|
||||
effective_at: datetime
|
||||
purpose: str | None = None
|
||||
requested_channels: tuple[DistributionChannel, ...] = ()
|
||||
address_purpose: str | None = None
|
||||
fallback_rule: ContactPointFallbackRule = "primary"
|
||||
locale: str | None = None
|
||||
postal_format: PostalAddressFormat = "domestic"
|
||||
max_items: int = 5_000
|
||||
context: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactPointSourcePreview:
|
||||
contract_version: str
|
||||
source: DistributionSourceReference
|
||||
request: ContactPointSourceRequest
|
||||
resolutions: tuple[ContactPointResolution, ...]
|
||||
total_count: int
|
||||
usable_count: int
|
||||
excluded_count: int
|
||||
offset: int
|
||||
limit: int
|
||||
has_more: bool
|
||||
source_revision: str
|
||||
source_fingerprint: str
|
||||
generated_at: datetime
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactPointSnapshotRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
contract_version: str
|
||||
source: DistributionSourceReference
|
||||
request: ContactPointSourceRequest
|
||||
resolutions: tuple[ContactPointResolution, ...]
|
||||
recipient_count: int
|
||||
excluded_count: int
|
||||
source_revision: str
|
||||
source_fingerprint: str
|
||||
snapshot_hash: str
|
||||
generated_at: datetime
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ContactPointResolutionProvider(Protocol):
|
||||
def resolve_contact_points(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ContactPointResolutionRequest,
|
||||
) -> ContactPointResolution:
|
||||
...
|
||||
|
||||
def preview_source(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ContactPointSourceRequest,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
) -> ContactPointSourcePreview:
|
||||
...
|
||||
|
||||
def freeze_source(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ContactPointSourceRequest,
|
||||
) -> ContactPointSnapshotRef:
|
||||
...
|
||||
|
||||
def get_snapshot(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
snapshot_id: str,
|
||||
) -> ContactPointSnapshotRef | None:
|
||||
...
|
||||
|
||||
|
||||
def contact_point_resolution_provider(
|
||||
registry: object | None,
|
||||
) -> ContactPointResolutionProvider | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION)
|
||||
return capability if isinstance(capability, ContactPointResolutionProvider) else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION",
|
||||
"CONTACT_POINT_CONTRACT_VERSION",
|
||||
"ContactPointCandidate",
|
||||
"ContactPointFallbackRule",
|
||||
"ContactPointResolution",
|
||||
"ContactPointResolutionProvider",
|
||||
"ContactPointResolutionRequest",
|
||||
"ContactPointSnapshotRef",
|
||||
"ContactPointSourcePreview",
|
||||
"ContactPointSourceRequest",
|
||||
"PostalAddressFormat",
|
||||
"contact_point_resolution_provider",
|
||||
]
|
||||
@@ -0,0 +1,293 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.automation import AutomationInvocation
|
||||
from govoplan_core.core.events import PlatformEvent
|
||||
|
||||
|
||||
CAPABILITY_DATAFLOW_RUN_LIFECYCLE = "dataflow.runLifecycle"
|
||||
CAPABILITY_DATAFLOW_RUN_WORKER = "dataflow.runWorker"
|
||||
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER = "dataflow.triggerDispatcher"
|
||||
CAPABILITY_DATAFLOW_DATASET_OUTPUT = "dataflow.dataset_output"
|
||||
|
||||
|
||||
class DataflowRunError(ValueError):
|
||||
"""Stable base error for module-neutral Dataflow run operations."""
|
||||
|
||||
|
||||
class DataflowRunNotFoundError(DataflowRunError):
|
||||
pass
|
||||
|
||||
|
||||
class DataflowRunConflictError(DataflowRunError):
|
||||
pass
|
||||
|
||||
|
||||
class DataflowRunUnavailableError(DataflowRunError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DataflowDatasetDescriptor:
|
||||
pipeline_ref: str
|
||||
name: str
|
||||
revision: int
|
||||
definition_hash: str
|
||||
status: str
|
||||
description: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
parameters: Mapping[str, object] = field(default_factory=dict)
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DataflowDatasetRequest:
|
||||
pipeline_ref: str
|
||||
revision: int
|
||||
parameters: Mapping[str, object] = field(default_factory=dict)
|
||||
row_limit: int = 500
|
||||
expected_definition_hash: str | None = None
|
||||
expected_source_fingerprints: tuple[Mapping[str, object], ...] = ()
|
||||
run_ref: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DataflowDatasetResult:
|
||||
pipeline_ref: str
|
||||
revision: int
|
||||
definition_hash: str
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
total_rows: int
|
||||
truncated: bool
|
||||
output_hash: str
|
||||
executor_version: str
|
||||
run_ref: str | None = None
|
||||
source_fingerprints: tuple[Mapping[str, object], ...] = ()
|
||||
diagnostics: tuple[Mapping[str, object], ...] = ()
|
||||
generated_at: datetime | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DataflowDatasetOutputProvider(Protocol):
|
||||
def list_outputs(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str = "",
|
||||
limit: int = 100,
|
||||
) -> Sequence[DataflowDatasetDescriptor]: ...
|
||||
|
||||
def read_output(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: DataflowDatasetRequest,
|
||||
) -> DataflowDatasetResult: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DataflowPublicationTarget:
|
||||
target_datasource_ref: str | None = None
|
||||
name: str | None = None
|
||||
source_name: str | None = None
|
||||
description: str | None = None
|
||||
freeze: bool = False
|
||||
frozen_label: str | None = None
|
||||
set_current: bool = True
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DataflowRunRequest:
|
||||
pipeline_ref: str
|
||||
revision: int
|
||||
idempotency_key: str
|
||||
row_limit: int = 500
|
||||
execution_backend: str = "auto"
|
||||
environment: str = "development"
|
||||
max_attempts: int = 3
|
||||
retention_days: int = 30
|
||||
publication: DataflowPublicationTarget | None = None
|
||||
invocation: AutomationInvocation = field(
|
||||
default_factory=AutomationInvocation
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DataflowRunDescriptor:
|
||||
ref: str
|
||||
pipeline_ref: str
|
||||
revision: int
|
||||
status: str
|
||||
definition_hash: str
|
||||
executor_version: str
|
||||
input_row_count: int = 0
|
||||
output_row_count: int = 0
|
||||
output_publication_ref: str | None = None
|
||||
output_datasource_ref: str | None = None
|
||||
output_materialization_ref: str | None = None
|
||||
invocation_kind: str = "manual"
|
||||
trigger_ref: str | None = None
|
||||
delivery_ref: str | None = None
|
||||
error: str | None = None
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
replayed: bool = False
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DataflowRunLifecycleProvider(Protocol):
|
||||
def start_run(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: DataflowRunRequest,
|
||||
) -> DataflowRunDescriptor:
|
||||
...
|
||||
|
||||
def get_run(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
run_ref: str,
|
||||
) -> DataflowRunDescriptor | None:
|
||||
...
|
||||
|
||||
def cancel_run(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
run_ref: str,
|
||||
) -> DataflowRunDescriptor:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DataflowTriggerDispatcher(Protocol):
|
||||
def dispatch_due(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 50,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
def ingest_event(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
event: PlatformEvent,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DataflowRunWorker(Protocol):
|
||||
"""Durable worker boundary for queued Dataflow execution.
|
||||
|
||||
Implementations own claim transaction boundaries so a lease is committed
|
||||
before potentially long-running execution starts.
|
||||
"""
|
||||
|
||||
def dispatch_pending(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 10,
|
||||
worker_id: str | None = None,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
def purge_expired(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 500,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
|
||||
def dataflow_run_lifecycle(
|
||||
registry: object | None,
|
||||
) -> DataflowRunLifecycleProvider | None:
|
||||
capability = _capability(registry, CAPABILITY_DATAFLOW_RUN_LIFECYCLE)
|
||||
return capability if isinstance(capability, DataflowRunLifecycleProvider) else None
|
||||
|
||||
|
||||
def dataflow_run_worker(
|
||||
registry: object | None,
|
||||
) -> DataflowRunWorker | None:
|
||||
capability = _capability(registry, CAPABILITY_DATAFLOW_RUN_WORKER)
|
||||
return capability if isinstance(capability, DataflowRunWorker) else None
|
||||
|
||||
|
||||
def dataflow_trigger_dispatcher(
|
||||
registry: object | None,
|
||||
) -> DataflowTriggerDispatcher | None:
|
||||
capability = _capability(registry, CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, DataflowTriggerDispatcher)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def dataflow_dataset_output(
|
||||
registry: object | None,
|
||||
) -> DataflowDatasetOutputProvider | None:
|
||||
capability = _capability(registry, CAPABILITY_DATAFLOW_DATASET_OUTPUT)
|
||||
return capability if isinstance(capability, DataflowDatasetOutputProvider) else None
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_DATAFLOW_RUN_LIFECYCLE",
|
||||
"CAPABILITY_DATAFLOW_RUN_WORKER",
|
||||
"CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER",
|
||||
"CAPABILITY_DATAFLOW_DATASET_OUTPUT",
|
||||
"DataflowDatasetDescriptor",
|
||||
"DataflowDatasetOutputProvider",
|
||||
"DataflowDatasetRequest",
|
||||
"DataflowDatasetResult",
|
||||
"DataflowPublicationTarget",
|
||||
"DataflowRunConflictError",
|
||||
"DataflowRunDescriptor",
|
||||
"DataflowRunError",
|
||||
"DataflowRunLifecycleProvider",
|
||||
"DataflowRunNotFoundError",
|
||||
"DataflowRunRequest",
|
||||
"DataflowRunUnavailableError",
|
||||
"DataflowRunWorker",
|
||||
"DataflowTriggerDispatcher",
|
||||
"dataflow_run_lifecycle",
|
||||
"dataflow_run_worker",
|
||||
"dataflow_trigger_dispatcher",
|
||||
"dataflow_dataset_output",
|
||||
]
|
||||
@@ -0,0 +1,644 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.external_references import (
|
||||
SOURCE_AUTHORITY_MODES,
|
||||
SourceAuthorityMode,
|
||||
)
|
||||
from govoplan_core.core.tabular_sources import (
|
||||
DEFAULT_PREVIEW_BYTES,
|
||||
DEFAULT_PREVIEW_TIMEOUT_MS,
|
||||
TabularPreviewDiagnostic,
|
||||
TabularPushdown,
|
||||
TabularSourceHealth,
|
||||
TabularSourceMode,
|
||||
)
|
||||
|
||||
|
||||
CAPABILITY_DATASOURCE_CATALOGUE = "datasources.catalogue"
|
||||
CAPABILITY_DATASOURCE_LIFECYCLE = "datasources.lifecycle"
|
||||
CAPABILITY_DATASOURCE_PUBLICATION = "datasources.publication"
|
||||
CAPABILITY_DATASOURCE_ORIGINS = "connectors.datasourceOrigins"
|
||||
|
||||
DatasourceMode = Literal["live", "cached", "static"]
|
||||
DatasourceKind = Literal[
|
||||
"upload",
|
||||
"database",
|
||||
"http",
|
||||
"rest",
|
||||
"directory",
|
||||
"file",
|
||||
"feed",
|
||||
"custom",
|
||||
]
|
||||
DatasourceShape = Literal["tabular", "document", "binary", "directory", "stream"]
|
||||
DatasourceConsistency = Literal["current", "live", "frozen"]
|
||||
|
||||
|
||||
class DatasourceError(ValueError):
|
||||
"""Stable base error for provider-neutral datasource operations."""
|
||||
|
||||
|
||||
class DatasourceNotFoundError(DatasourceError):
|
||||
pass
|
||||
|
||||
|
||||
class DatasourceAccessError(DatasourceError):
|
||||
pass
|
||||
|
||||
|
||||
class DatasourceValidationError(DatasourceError):
|
||||
pass
|
||||
|
||||
|
||||
class DatasourceUnavailableError(DatasourceError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceField:
|
||||
name: str
|
||||
data_type: str
|
||||
nullable: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceGovernance:
|
||||
"""Provider-neutral governance facts attached to a datasource revision."""
|
||||
|
||||
owner_ref: str | None = None
|
||||
steward_ref: str | None = None
|
||||
responsible_organization_ref: str | None = None
|
||||
responsible_function_ref: str | None = None
|
||||
authoritative_source_ref: str | None = None
|
||||
authority_mode: SourceAuthorityMode = "linked_reference"
|
||||
legal_basis_refs: tuple[str, ...] = ()
|
||||
purposes: tuple[str, ...] = ()
|
||||
semantic_definition: str | None = None
|
||||
schema_owner_ref: str | None = None
|
||||
official_keys: tuple[str, ...] = ()
|
||||
classification: str = "internal"
|
||||
privacy_profile_ref: str | None = None
|
||||
retention_policy_ref: str | None = None
|
||||
hold_refs: tuple[str, ...] = ()
|
||||
publication_state: str = "draft"
|
||||
transfer_agreement_ref: str | None = None
|
||||
freshness_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
quality_policy: Mapping[str, object] = field(default_factory=dict)
|
||||
known_limits: tuple[str, ...] = ()
|
||||
correction_procedure_ref: str | None = None
|
||||
affected_refs: tuple[str, ...] = ()
|
||||
dependency_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.authority_mode not in SOURCE_AUTHORITY_MODES:
|
||||
raise DatasourceValidationError(
|
||||
f"Unsupported datasource authority mode: {self.authority_mode!r}."
|
||||
)
|
||||
if not self.classification.strip():
|
||||
raise DatasourceValidationError("Datasource classification is required.")
|
||||
if not self.publication_state.strip():
|
||||
raise DatasourceValidationError("Datasource publication state is required.")
|
||||
for field_name in (
|
||||
"legal_basis_refs",
|
||||
"purposes",
|
||||
"official_keys",
|
||||
"hold_refs",
|
||||
"known_limits",
|
||||
"affected_refs",
|
||||
"dependency_refs",
|
||||
):
|
||||
values = getattr(self, field_name)
|
||||
if any(not value.strip() for value in values):
|
||||
raise DatasourceValidationError(
|
||||
f"Datasource governance {field_name} cannot contain empty values."
|
||||
)
|
||||
if len(values) != len(set(values)):
|
||||
raise DatasourceValidationError(
|
||||
f"Datasource governance {field_name} cannot contain duplicates."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, object] | None) -> "DatasourceGovernance":
|
||||
source = value or {}
|
||||
return cls(
|
||||
owner_ref=_optional_governance_text(source.get("owner_ref")),
|
||||
steward_ref=_optional_governance_text(source.get("steward_ref")),
|
||||
responsible_organization_ref=_optional_governance_text(
|
||||
source.get("responsible_organization_ref")
|
||||
),
|
||||
responsible_function_ref=_optional_governance_text(
|
||||
source.get("responsible_function_ref")
|
||||
),
|
||||
authoritative_source_ref=_optional_governance_text(
|
||||
source.get("authoritative_source_ref")
|
||||
),
|
||||
authority_mode=str(
|
||||
source.get("authority_mode") or "linked_reference"
|
||||
), # type: ignore[arg-type]
|
||||
legal_basis_refs=_governance_texts(source.get("legal_basis_refs")),
|
||||
purposes=_governance_texts(source.get("purposes")),
|
||||
semantic_definition=_optional_governance_text(
|
||||
source.get("semantic_definition")
|
||||
),
|
||||
schema_owner_ref=_optional_governance_text(source.get("schema_owner_ref")),
|
||||
official_keys=_governance_texts(source.get("official_keys")),
|
||||
classification=str(source.get("classification") or "internal"),
|
||||
privacy_profile_ref=_optional_governance_text(
|
||||
source.get("privacy_profile_ref")
|
||||
),
|
||||
retention_policy_ref=_optional_governance_text(
|
||||
source.get("retention_policy_ref")
|
||||
),
|
||||
hold_refs=_governance_texts(source.get("hold_refs")),
|
||||
publication_state=str(source.get("publication_state") or "draft"),
|
||||
transfer_agreement_ref=_optional_governance_text(
|
||||
source.get("transfer_agreement_ref")
|
||||
),
|
||||
freshness_policy=_governance_mapping(source.get("freshness_policy")),
|
||||
quality_policy=_governance_mapping(source.get("quality_policy")),
|
||||
known_limits=_governance_texts(source.get("known_limits")),
|
||||
correction_procedure_ref=_optional_governance_text(
|
||||
source.get("correction_procedure_ref")
|
||||
),
|
||||
affected_refs=_governance_texts(source.get("affected_refs")),
|
||||
dependency_refs=_governance_texts(source.get("dependency_refs")),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"owner_ref": self.owner_ref,
|
||||
"steward_ref": self.steward_ref,
|
||||
"responsible_organization_ref": self.responsible_organization_ref,
|
||||
"responsible_function_ref": self.responsible_function_ref,
|
||||
"authoritative_source_ref": self.authoritative_source_ref,
|
||||
"authority_mode": self.authority_mode,
|
||||
"legal_basis_refs": list(self.legal_basis_refs),
|
||||
"purposes": list(self.purposes),
|
||||
"semantic_definition": self.semantic_definition,
|
||||
"schema_owner_ref": self.schema_owner_ref,
|
||||
"official_keys": list(self.official_keys),
|
||||
"classification": self.classification,
|
||||
"privacy_profile_ref": self.privacy_profile_ref,
|
||||
"retention_policy_ref": self.retention_policy_ref,
|
||||
"hold_refs": list(self.hold_refs),
|
||||
"publication_state": self.publication_state,
|
||||
"transfer_agreement_ref": self.transfer_agreement_ref,
|
||||
"freshness_policy": dict(self.freshness_policy),
|
||||
"quality_policy": dict(self.quality_policy),
|
||||
"known_limits": list(self.known_limits),
|
||||
"correction_procedure_ref": self.correction_procedure_ref,
|
||||
"affected_refs": list(self.affected_refs),
|
||||
"dependency_refs": list(self.dependency_refs),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceDescriptor:
|
||||
ref: str
|
||||
source_name: str
|
||||
name: str
|
||||
kind: DatasourceKind
|
||||
mode: DatasourceMode
|
||||
shape: DatasourceShape
|
||||
status: str = "active"
|
||||
description: str | None = None
|
||||
provider: str | None = None
|
||||
provider_ref: str | None = None
|
||||
schema: tuple[DatasourceField, ...] = ()
|
||||
schema_version: str = "1"
|
||||
fingerprint: str = ""
|
||||
current_materialization_ref: str | None = None
|
||||
row_count: int | None = None
|
||||
byte_count: int | None = None
|
||||
updated_at: datetime | None = None
|
||||
capabilities: tuple[str, ...] = ("read",)
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceMaterialization:
|
||||
ref: str
|
||||
datasource_ref: str
|
||||
revision: int
|
||||
state: str
|
||||
fingerprint: str
|
||||
schema: tuple[DatasourceField, ...] = ()
|
||||
row_count: int | None = None
|
||||
byte_count: int | None = None
|
||||
frozen_at: datetime | None = None
|
||||
frozen_label: str | None = None
|
||||
source_timestamp: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceStage:
|
||||
ref: str
|
||||
name: str
|
||||
source_name: str
|
||||
kind: DatasourceKind
|
||||
mode: DatasourceMode
|
||||
shape: DatasourceShape
|
||||
state: str
|
||||
target_datasource_ref: str | None = None
|
||||
fingerprint: str = ""
|
||||
schema: tuple[DatasourceField, ...] = ()
|
||||
row_count: int | None = None
|
||||
byte_count: int | None = None
|
||||
validation: Mapping[str, object] = field(default_factory=dict)
|
||||
created_at: datetime | None = None
|
||||
promoted_at: datetime | None = None
|
||||
promoted_materialization_ref: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceReadRequest:
|
||||
datasource_ref: str
|
||||
materialization_ref: str | None = None
|
||||
consistency: DatasourceConsistency = "current"
|
||||
limit: int = 250
|
||||
offset: int = 0
|
||||
columns: tuple[str, ...] = ()
|
||||
expected_fingerprint: str | None = None
|
||||
max_bytes: int = DEFAULT_PREVIEW_BYTES
|
||||
timeout_ms: int = DEFAULT_PREVIEW_TIMEOUT_MS
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceReadResult:
|
||||
datasource: DatasourceDescriptor
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
total_rows: int
|
||||
truncated: bool
|
||||
materialization: DatasourceMaterialization | None = None
|
||||
returned_bytes: int = 0
|
||||
elapsed_ms: int = 0
|
||||
effective_row_limit: int = 0
|
||||
effective_byte_limit: int = 0
|
||||
effective_timeout_ms: int = 0
|
||||
diagnostics: tuple[TabularPreviewDiagnostic, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceStageInput:
|
||||
name: str
|
||||
source_name: str
|
||||
kind: DatasourceKind
|
||||
mode: DatasourceMode
|
||||
shape: DatasourceShape
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
description: str | None = None
|
||||
target_datasource_ref: str | None = None
|
||||
provider: str | None = None
|
||||
provider_ref: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
governance: DatasourceGovernance | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourcePublicationRequest:
|
||||
producer_module: str
|
||||
producer_run_ref: str
|
||||
idempotency_key: str
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
target_datasource_ref: str | None = None
|
||||
name: str | None = None
|
||||
source_name: str | None = None
|
||||
description: str | None = None
|
||||
freeze: bool = False
|
||||
frozen_label: str | None = None
|
||||
set_current: bool = True
|
||||
source_timestamp: datetime | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
governance: DatasourceGovernance | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourcePublicationResult:
|
||||
ref: str
|
||||
status: str
|
||||
datasource: DatasourceDescriptor
|
||||
materialization: DatasourceMaterialization
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceOrigin:
|
||||
ref: str
|
||||
source_name: str
|
||||
name: str
|
||||
kind: DatasourceKind
|
||||
shape: DatasourceShape
|
||||
supported_modes: tuple[DatasourceMode, ...]
|
||||
provider: str
|
||||
description: str | None = None
|
||||
schema: tuple[DatasourceField, ...] = ()
|
||||
schema_version: str = "1"
|
||||
fingerprint: str = ""
|
||||
row_count: int | None = None
|
||||
byte_count: int | None = None
|
||||
updated_at: datetime | None = None
|
||||
capabilities: tuple[str, ...] = ("read",)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
source_mode: TabularSourceMode = "cached"
|
||||
pushdown: TabularPushdown = field(default_factory=TabularPushdown)
|
||||
health: TabularSourceHealth = field(default_factory=TabularSourceHealth)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceOriginReadRequest:
|
||||
origin_ref: str
|
||||
limit: int = 250
|
||||
offset: int = 0
|
||||
columns: tuple[str, ...] = ()
|
||||
expected_fingerprint: str | None = None
|
||||
max_bytes: int = DEFAULT_PREVIEW_BYTES
|
||||
timeout_ms: int = DEFAULT_PREVIEW_TIMEOUT_MS
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasourceOriginReadResult:
|
||||
origin: DatasourceOrigin
|
||||
rows: tuple[Mapping[str, object], ...]
|
||||
total_rows: int
|
||||
truncated: bool
|
||||
returned_bytes: int = 0
|
||||
elapsed_ms: int = 0
|
||||
effective_row_limit: int = 0
|
||||
effective_byte_limit: int = 0
|
||||
effective_timeout_ms: int = 0
|
||||
diagnostics: tuple[TabularPreviewDiagnostic, ...] = ()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DatasourceCatalogueProvider(Protocol):
|
||||
def list_datasources(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str = "",
|
||||
limit: int = 100,
|
||||
authority_mode: str | None = None,
|
||||
classification: str | None = None,
|
||||
publication_state: str | None = None,
|
||||
owner_ref: str | None = None,
|
||||
responsible_organization_ref: str | None = None,
|
||||
affected_ref: str | None = None,
|
||||
dependency_ref: str | None = None,
|
||||
) -> Sequence[DatasourceDescriptor]:
|
||||
...
|
||||
|
||||
def get_datasource(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
datasource_ref: str,
|
||||
) -> DatasourceDescriptor | None:
|
||||
...
|
||||
|
||||
def read_datasource(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: DatasourceReadRequest,
|
||||
) -> DatasourceReadResult:
|
||||
...
|
||||
|
||||
def list_materializations(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
datasource_ref: str,
|
||||
) -> Sequence[DatasourceMaterialization]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DatasourceLifecycleProvider(Protocol):
|
||||
def list_stages(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
limit: int = 100,
|
||||
) -> Sequence[DatasourceStage]:
|
||||
...
|
||||
|
||||
def create_stage(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
stage: DatasourceStageInput,
|
||||
) -> DatasourceStage:
|
||||
...
|
||||
|
||||
def promote_stage(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
stage_ref: str,
|
||||
freeze: bool = False,
|
||||
frozen_label: str | None = None,
|
||||
) -> tuple[DatasourceDescriptor, DatasourceMaterialization]:
|
||||
...
|
||||
|
||||
def register_origin(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
origin_ref: str,
|
||||
name: str,
|
||||
source_name: str,
|
||||
mode: DatasourceMode,
|
||||
description: str | None = None,
|
||||
governance: DatasourceGovernance | None = None,
|
||||
) -> DatasourceDescriptor:
|
||||
...
|
||||
|
||||
def update_datasource_governance(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
datasource_ref: str,
|
||||
governance: DatasourceGovernance,
|
||||
) -> DatasourceDescriptor:
|
||||
...
|
||||
|
||||
def refresh_datasource(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
datasource_ref: str,
|
||||
) -> tuple[DatasourceDescriptor, DatasourceMaterialization]:
|
||||
...
|
||||
|
||||
def freeze_datasource(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
datasource_ref: str,
|
||||
label: str | None = None,
|
||||
) -> DatasourceMaterialization:
|
||||
...
|
||||
|
||||
def retire_datasource(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
datasource_ref: str,
|
||||
) -> DatasourceDescriptor:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DatasourcePublicationProvider(Protocol):
|
||||
def publish_rows(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: DatasourcePublicationRequest,
|
||||
) -> DatasourcePublicationResult:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DatasourceOriginProvider(Protocol):
|
||||
def list_origins(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str = "",
|
||||
limit: int = 100,
|
||||
) -> Sequence[DatasourceOrigin]:
|
||||
...
|
||||
|
||||
def get_origin(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
origin_ref: str,
|
||||
) -> DatasourceOrigin | None:
|
||||
...
|
||||
|
||||
def read_origin(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: DatasourceOriginReadRequest,
|
||||
) -> DatasourceOriginReadResult:
|
||||
...
|
||||
|
||||
|
||||
def datasource_catalogue(registry: object | None) -> DatasourceCatalogueProvider | None:
|
||||
capability = _capability(registry, CAPABILITY_DATASOURCE_CATALOGUE)
|
||||
return capability if isinstance(capability, DatasourceCatalogueProvider) else None
|
||||
|
||||
|
||||
def datasource_lifecycle(registry: object | None) -> DatasourceLifecycleProvider | None:
|
||||
capability = _capability(registry, CAPABILITY_DATASOURCE_LIFECYCLE)
|
||||
return capability if isinstance(capability, DatasourceLifecycleProvider) else None
|
||||
|
||||
|
||||
def datasource_publication(
|
||||
registry: object | None,
|
||||
) -> DatasourcePublicationProvider | None:
|
||||
capability = _capability(registry, CAPABILITY_DATASOURCE_PUBLICATION)
|
||||
return capability if isinstance(capability, DatasourcePublicationProvider) else None
|
||||
|
||||
|
||||
def datasource_origins(registry: object | None) -> DatasourceOriginProvider | None:
|
||||
capability = _capability(registry, CAPABILITY_DATASOURCE_ORIGINS)
|
||||
return capability if isinstance(capability, DatasourceOriginProvider) else None
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
def _optional_governance_text(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
cleaned = str(value).strip()
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _governance_texts(value: object) -> tuple[str, ...]:
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
return ()
|
||||
return tuple(str(item).strip() for item in value)
|
||||
|
||||
|
||||
def _governance_mapping(value: object) -> Mapping[str, object]:
|
||||
if not isinstance(value, Mapping):
|
||||
return {}
|
||||
return {str(key): item for key, item in value.items()}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_DATASOURCE_CATALOGUE",
|
||||
"CAPABILITY_DATASOURCE_LIFECYCLE",
|
||||
"CAPABILITY_DATASOURCE_ORIGINS",
|
||||
"DatasourceAccessError",
|
||||
"DatasourceCatalogueProvider",
|
||||
"DatasourceConsistency",
|
||||
"DatasourceDescriptor",
|
||||
"DatasourceError",
|
||||
"DatasourceField",
|
||||
"DatasourceGovernance",
|
||||
"DatasourceKind",
|
||||
"DatasourceLifecycleProvider",
|
||||
"DatasourceMaterialization",
|
||||
"DatasourceMode",
|
||||
"DatasourceNotFoundError",
|
||||
"DatasourceOrigin",
|
||||
"DatasourceOriginProvider",
|
||||
"DatasourceOriginReadRequest",
|
||||
"DatasourceOriginReadResult",
|
||||
"DatasourceReadRequest",
|
||||
"DatasourceReadResult",
|
||||
"DatasourceShape",
|
||||
"DatasourceStage",
|
||||
"DatasourceStageInput",
|
||||
"DatasourceUnavailableError",
|
||||
"DatasourceValidationError",
|
||||
"datasource_catalogue",
|
||||
"datasource_lifecycle",
|
||||
"datasource_origins",
|
||||
]
|
||||
@@ -0,0 +1,455 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionPort:
|
||||
id: str
|
||||
label: str
|
||||
required: bool = True
|
||||
multiple: bool = False
|
||||
minimum_connections: int = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionConfigField:
|
||||
id: str
|
||||
label: str
|
||||
kind: str
|
||||
required: bool = False
|
||||
description: str | None = None
|
||||
options: tuple[tuple[str, str], ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionNodeType:
|
||||
type: str
|
||||
category: str
|
||||
label: str
|
||||
description: str
|
||||
icon: str
|
||||
input_ports: tuple[DefinitionPort, ...] = ()
|
||||
output_ports: tuple[DefinitionPort, ...] = (
|
||||
DefinitionPort(id="output", label="Output"),
|
||||
)
|
||||
config_fields: tuple[DefinitionConfigField, ...] = ()
|
||||
default_config: Mapping[str, Any] = field(default_factory=dict)
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionNode:
|
||||
id: str
|
||||
type: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionEdge:
|
||||
id: str
|
||||
source: str
|
||||
target: str
|
||||
source_port: str = "output"
|
||||
target_port: str = "input"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionNodeCountConstraint:
|
||||
code: str
|
||||
label: str
|
||||
minimum: int = 0
|
||||
maximum: int | None = None
|
||||
node_types: tuple[str, ...] = ()
|
||||
type_prefixes: tuple[str, ...] = ()
|
||||
categories: tuple[str, ...] = ()
|
||||
|
||||
def matches(self, node: DefinitionNode, node_type: DefinitionNodeType | None) -> bool:
|
||||
return (
|
||||
node.type in self.node_types
|
||||
or any(node.type.startswith(prefix) for prefix in self.type_prefixes)
|
||||
or node_type is not None
|
||||
and node_type.category in self.categories
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionGraphConstraints:
|
||||
max_nodes: int = 100
|
||||
max_edges: int = 200
|
||||
allow_cycles: bool = False
|
||||
require_connected: bool = True
|
||||
node_counts: tuple[DefinitionNodeCountConstraint, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionGraphLibrary:
|
||||
id: str
|
||||
version: str
|
||||
category_labels: Mapping[str, str]
|
||||
node_types: tuple[DefinitionNodeType, ...]
|
||||
constraints: DefinitionGraphConstraints = field(default_factory=DefinitionGraphConstraints)
|
||||
|
||||
def node_type(self, type_id: str) -> DefinitionNodeType | None:
|
||||
return next((item for item in self.node_types if item.type == type_id), None)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionDiagnostic:
|
||||
severity: str
|
||||
code: str
|
||||
message: str
|
||||
node_id: str | None = None
|
||||
field: str | None = None
|
||||
|
||||
|
||||
def validate_definition_graph(
|
||||
library: DefinitionGraphLibrary,
|
||||
*,
|
||||
nodes: Sequence[DefinitionNode],
|
||||
edges: Sequence[DefinitionEdge],
|
||||
) -> tuple[DefinitionDiagnostic, ...]:
|
||||
if not nodes:
|
||||
return (_error("graph.empty", "Add nodes before saving the definition."),)
|
||||
constraints = library.constraints
|
||||
diagnostics = _graph_size_diagnostics(
|
||||
node_count=len(nodes),
|
||||
edge_count=len(edges),
|
||||
constraints=constraints,
|
||||
)
|
||||
node_by_id = {node.id: node for node in nodes}
|
||||
definitions: dict[str, DefinitionNodeType] = {}
|
||||
for item in library.node_types:
|
||||
definitions.setdefault(item.type, item)
|
||||
diagnostics.extend(_identifier_diagnostics(nodes=nodes, edges=edges))
|
||||
incoming, undirected, topology_edges, edge_diagnostics = (
|
||||
_validate_definition_edges(
|
||||
edges=edges,
|
||||
node_by_id=node_by_id,
|
||||
definitions=definitions,
|
||||
)
|
||||
)
|
||||
diagnostics.extend(edge_diagnostics)
|
||||
diagnostics.extend(
|
||||
_node_port_diagnostics(
|
||||
nodes=nodes,
|
||||
definitions=definitions,
|
||||
incoming=incoming,
|
||||
library_id=library.id,
|
||||
)
|
||||
)
|
||||
diagnostics.extend(
|
||||
_node_count_diagnostics(
|
||||
nodes=nodes,
|
||||
definitions=definitions,
|
||||
constraints=constraints,
|
||||
)
|
||||
)
|
||||
diagnostics.extend(
|
||||
_topology_diagnostics(
|
||||
nodes=nodes,
|
||||
node_by_id=node_by_id,
|
||||
topology_edges=topology_edges,
|
||||
undirected=undirected,
|
||||
constraints=constraints,
|
||||
)
|
||||
)
|
||||
return tuple(diagnostics)
|
||||
|
||||
|
||||
def _graph_size_diagnostics(
|
||||
*,
|
||||
node_count: int,
|
||||
edge_count: int,
|
||||
constraints: DefinitionGraphConstraints,
|
||||
) -> list[DefinitionDiagnostic]:
|
||||
diagnostics: list[DefinitionDiagnostic] = []
|
||||
if node_count > constraints.max_nodes:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"graph.node_limit",
|
||||
f"Definitions are limited to {constraints.max_nodes:,} nodes.",
|
||||
)
|
||||
)
|
||||
if edge_count > constraints.max_edges:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"graph.edge_limit",
|
||||
f"Definitions are limited to {constraints.max_edges:,} edges.",
|
||||
)
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _identifier_diagnostics(
|
||||
*,
|
||||
nodes: Sequence[DefinitionNode],
|
||||
edges: Sequence[DefinitionEdge],
|
||||
) -> list[DefinitionDiagnostic]:
|
||||
diagnostics: list[DefinitionDiagnostic] = []
|
||||
if len({node.id for node in nodes}) != len(nodes):
|
||||
diagnostics.append(_error("graph.duplicate_node", "Node identifiers must be unique."))
|
||||
if len({edge.id for edge in edges}) != len(edges):
|
||||
diagnostics.append(_error("graph.duplicate_edge", "Edge identifiers must be unique."))
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _validate_definition_edges(
|
||||
*,
|
||||
edges: Sequence[DefinitionEdge],
|
||||
node_by_id: Mapping[str, DefinitionNode],
|
||||
definitions: Mapping[str, DefinitionNodeType],
|
||||
) -> tuple[
|
||||
dict[str, list[DefinitionEdge]],
|
||||
dict[str, set[str]],
|
||||
list[DefinitionEdge],
|
||||
list[DefinitionDiagnostic],
|
||||
]:
|
||||
incoming: dict[str, list[DefinitionEdge]] = {node_id: [] for node_id in node_by_id}
|
||||
undirected: dict[str, set[str]] = {node_id: set() for node_id in node_by_id}
|
||||
topology_edges: list[DefinitionEdge] = []
|
||||
diagnostics: list[DefinitionDiagnostic] = []
|
||||
for edge in edges:
|
||||
structural_error = _edge_structure_diagnostic(edge, node_by_id)
|
||||
if structural_error is not None:
|
||||
diagnostics.append(structural_error)
|
||||
continue
|
||||
topology_edges.append(edge)
|
||||
port_error = _edge_port_diagnostic(
|
||||
edge,
|
||||
source_definition=definitions.get(node_by_id[edge.source].type),
|
||||
target_definition=definitions.get(node_by_id[edge.target].type),
|
||||
)
|
||||
if port_error is not None:
|
||||
diagnostics.append(port_error)
|
||||
continue
|
||||
incoming[edge.target].append(edge)
|
||||
undirected[edge.source].add(edge.target)
|
||||
undirected[edge.target].add(edge.source)
|
||||
return incoming, undirected, topology_edges, diagnostics
|
||||
|
||||
|
||||
def _edge_structure_diagnostic(
|
||||
edge: DefinitionEdge,
|
||||
node_by_id: Mapping[str, DefinitionNode],
|
||||
) -> DefinitionDiagnostic | None:
|
||||
if edge.source not in node_by_id:
|
||||
return _error(
|
||||
"edge.unknown_source",
|
||||
f"Edge {edge.id!r} references an unknown source node.",
|
||||
)
|
||||
if edge.target not in node_by_id:
|
||||
return _error(
|
||||
"edge.unknown_target",
|
||||
f"Edge {edge.id!r} references an unknown target node.",
|
||||
)
|
||||
if edge.source == edge.target:
|
||||
return _error(
|
||||
"edge.self_reference",
|
||||
"A node cannot connect to itself.",
|
||||
node_id=edge.source,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _edge_port_diagnostic(
|
||||
edge: DefinitionEdge,
|
||||
*,
|
||||
source_definition: DefinitionNodeType | None,
|
||||
target_definition: DefinitionNodeType | None,
|
||||
) -> DefinitionDiagnostic | None:
|
||||
if source_definition is not None and edge.source_port not in {
|
||||
port.id for port in source_definition.output_ports
|
||||
}:
|
||||
return _error(
|
||||
"edge.unknown_source_port",
|
||||
f"Node {edge.source!r} has no output port {edge.source_port!r}.",
|
||||
node_id=edge.source,
|
||||
)
|
||||
if target_definition is not None and edge.target_port not in {
|
||||
port.id for port in target_definition.input_ports
|
||||
}:
|
||||
return _error(
|
||||
"edge.unknown_target_port",
|
||||
f"Node {edge.target!r} has no input port {edge.target_port!r}.",
|
||||
node_id=edge.target,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _node_port_diagnostics(
|
||||
*,
|
||||
nodes: Sequence[DefinitionNode],
|
||||
definitions: Mapping[str, DefinitionNodeType],
|
||||
incoming: Mapping[str, Sequence[DefinitionEdge]],
|
||||
library_id: str,
|
||||
) -> list[DefinitionDiagnostic]:
|
||||
diagnostics: list[DefinitionDiagnostic] = []
|
||||
for node in nodes:
|
||||
definition = definitions.get(node.type)
|
||||
if definition is None:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"node.unsupported_type",
|
||||
f"Node type {node.type!r} is not in the {library_id!r} library.",
|
||||
node_id=node.id,
|
||||
field="type",
|
||||
)
|
||||
)
|
||||
continue
|
||||
for port in definition.input_ports:
|
||||
connections = [
|
||||
edge for edge in incoming.get(node.id, ()) if edge.target_port == port.id
|
||||
]
|
||||
minimum = port.minimum_connections if port.required else 0
|
||||
if len(connections) < minimum:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"node.input_required",
|
||||
(
|
||||
f"{definition.label} requires {minimum} "
|
||||
f"{port.label.lower()} connection"
|
||||
f"{'' if minimum == 1 else 's'}."
|
||||
),
|
||||
node_id=node.id,
|
||||
)
|
||||
)
|
||||
if not port.multiple and len(connections) > 1:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"node.input_multiple",
|
||||
f"{port.label} accepts only one connection.",
|
||||
node_id=node.id,
|
||||
)
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _node_count_diagnostics(
|
||||
*,
|
||||
nodes: Sequence[DefinitionNode],
|
||||
definitions: Mapping[str, DefinitionNodeType],
|
||||
constraints: DefinitionGraphConstraints,
|
||||
) -> list[DefinitionDiagnostic]:
|
||||
diagnostics: list[DefinitionDiagnostic] = []
|
||||
for count_constraint in constraints.node_counts:
|
||||
count = sum(
|
||||
count_constraint.matches(node, definitions.get(node.type))
|
||||
for node in nodes
|
||||
)
|
||||
if count < count_constraint.minimum:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
count_constraint.code,
|
||||
(
|
||||
f"A definition needs at least {count_constraint.minimum} "
|
||||
f"{count_constraint.label} node"
|
||||
f"{'' if count_constraint.minimum == 1 else 's'}."
|
||||
),
|
||||
)
|
||||
)
|
||||
if count_constraint.maximum is not None and count > count_constraint.maximum:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
count_constraint.code,
|
||||
(
|
||||
f"A definition allows at most {count_constraint.maximum} "
|
||||
f"{count_constraint.label} node"
|
||||
f"{'' if count_constraint.maximum == 1 else 's'}."
|
||||
),
|
||||
)
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _topology_diagnostics(
|
||||
*,
|
||||
nodes: Sequence[DefinitionNode],
|
||||
node_by_id: Mapping[str, DefinitionNode],
|
||||
topology_edges: Sequence[DefinitionEdge],
|
||||
undirected: Mapping[str, set[str]],
|
||||
constraints: DefinitionGraphConstraints,
|
||||
) -> list[DefinitionDiagnostic]:
|
||||
diagnostics: list[DefinitionDiagnostic] = []
|
||||
_, cyclic = definition_topological_order(nodes, topology_edges)
|
||||
if cyclic and not constraints.allow_cycles:
|
||||
diagnostics.append(_error("graph.cycle", "Definition edges must form an acyclic graph."))
|
||||
if constraints.require_connected and len(node_by_id) > 1:
|
||||
connected = _connected_nodes(next(iter(node_by_id)), undirected)
|
||||
if len(connected) != len(node_by_id):
|
||||
diagnostics.append(
|
||||
_error("graph.disconnected", "Every node must belong to one connected definition.")
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def definition_topological_order(
|
||||
nodes: Sequence[DefinitionNode],
|
||||
edges: Sequence[DefinitionEdge],
|
||||
) -> tuple[tuple[str, ...], bool]:
|
||||
node_ids = [node.id for node in nodes]
|
||||
incoming_count = {node_id: 0 for node_id in node_ids}
|
||||
outgoing: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
|
||||
for edge in edges:
|
||||
if (
|
||||
edge.source in outgoing
|
||||
and edge.target in incoming_count
|
||||
and edge.source != edge.target
|
||||
):
|
||||
outgoing[edge.source].append(edge.target)
|
||||
incoming_count[edge.target] += 1
|
||||
ready = deque(node_id for node_id in node_ids if incoming_count[node_id] == 0)
|
||||
ordered: list[str] = []
|
||||
while ready:
|
||||
node_id = ready.popleft()
|
||||
ordered.append(node_id)
|
||||
for target in outgoing[node_id]:
|
||||
incoming_count[target] -= 1
|
||||
if incoming_count[target] == 0:
|
||||
ready.append(target)
|
||||
return tuple(ordered), len(ordered) != len(node_ids)
|
||||
|
||||
|
||||
def _connected_nodes(start: str, adjacency: Mapping[str, set[str]]) -> set[str]:
|
||||
seen: set[str] = set()
|
||||
pending = [start]
|
||||
while pending:
|
||||
node_id = pending.pop()
|
||||
if node_id in seen:
|
||||
continue
|
||||
seen.add(node_id)
|
||||
pending.extend(adjacency.get(node_id, ()))
|
||||
return seen
|
||||
|
||||
|
||||
def _error(
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
node_id: str | None = None,
|
||||
field: str | None = None,
|
||||
) -> DefinitionDiagnostic:
|
||||
return DefinitionDiagnostic(
|
||||
severity="error",
|
||||
code=code,
|
||||
message=message,
|
||||
node_id=node_id,
|
||||
field=field,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DefinitionConfigField",
|
||||
"DefinitionDiagnostic",
|
||||
"DefinitionEdge",
|
||||
"DefinitionGraphConstraints",
|
||||
"DefinitionGraphLibrary",
|
||||
"DefinitionNode",
|
||||
"DefinitionNodeCountConstraint",
|
||||
"DefinitionNodeType",
|
||||
"DefinitionPort",
|
||||
"definition_topological_order",
|
||||
"validate_definition_graph",
|
||||
]
|
||||
@@ -0,0 +1,402 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_DISTRIBUTION_LIST_SOURCE = "dist_lists.source"
|
||||
CAPABILITY_DISTRIBUTION_LIST_EXPAND = "dist_lists.expand"
|
||||
CAPABILITY_DISTRIBUTION_LIST_WRITER = "dist_lists.writer"
|
||||
CAPABILITY_RECIPIENT_CHANNEL_FACTS = "addresses.channel_facts"
|
||||
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS = "policy.distribution_channels"
|
||||
|
||||
DistributionDefinitionKind = Literal["static", "parameterized", "dynamic", "template"]
|
||||
DistributionEntryMode = Literal["include", "exclude", "override"]
|
||||
DistributionEntryKind = Literal[
|
||||
"address_contact",
|
||||
"address_list",
|
||||
"address_email",
|
||||
"raw_email",
|
||||
"raw_postal_address",
|
||||
"internal_mail",
|
||||
"portal",
|
||||
"idm_identity",
|
||||
"idm_group",
|
||||
"organization_unit",
|
||||
"function",
|
||||
"effective_function_incumbent",
|
||||
"dataflow_result",
|
||||
"distribution_list",
|
||||
]
|
||||
DistributionChannel = Literal["email", "postal", "internal_mail", "portal"]
|
||||
DistributionOutcome = Literal[
|
||||
"usable",
|
||||
"unresolved",
|
||||
"invalid",
|
||||
"suppressed",
|
||||
"ambiguous",
|
||||
"duplicate",
|
||||
"policy_blocked",
|
||||
"provider_unavailable",
|
||||
"stale",
|
||||
]
|
||||
DistributionExplanationSeverity = Literal["info", "warning", "error"]
|
||||
|
||||
|
||||
class DistributionListError(ValueError):
|
||||
"""Stable base error for provider-neutral distribution-list operations."""
|
||||
|
||||
|
||||
class DistributionListNotFoundError(DistributionListError):
|
||||
pass
|
||||
|
||||
|
||||
class DistributionListConflictError(DistributionListError):
|
||||
pass
|
||||
|
||||
|
||||
class DistributionListUnavailableError(DistributionListError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistributionSourceReference:
|
||||
provider: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
revision: str | None = None
|
||||
fingerprint: str | None = None
|
||||
label: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistributionExplanation:
|
||||
code: str
|
||||
message: str
|
||||
severity: DistributionExplanationSeverity = "warning"
|
||||
provider: str | None = None
|
||||
source: DistributionSourceReference | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistributionParameterDefinition:
|
||||
key: str
|
||||
value_type: Literal[
|
||||
"string",
|
||||
"integer",
|
||||
"number",
|
||||
"boolean",
|
||||
"date",
|
||||
"datetime",
|
||||
"string_list",
|
||||
]
|
||||
label: str | None = None
|
||||
required: bool = False
|
||||
default: object | None = None
|
||||
allowed_values: tuple[object, ...] = ()
|
||||
minimum: float | None = None
|
||||
maximum: float | None = None
|
||||
pattern: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistributionListEntryRef:
|
||||
id: str
|
||||
kind: DistributionEntryKind
|
||||
mode: DistributionEntryMode
|
||||
source: DistributionSourceReference
|
||||
label: str | None = None
|
||||
purpose: str | None = None
|
||||
requested_channels: tuple[DistributionChannel, ...] = ()
|
||||
effective_from: datetime | None = None
|
||||
effective_until: datetime | None = None
|
||||
order: int = 0
|
||||
configuration: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistributionListSourceRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
name: str
|
||||
revision_id: str
|
||||
revision: int
|
||||
definition_hash: str
|
||||
definition_kind: DistributionDefinitionKind = "static"
|
||||
description: str | None = None
|
||||
status: str = "active"
|
||||
entry_count: int = 0
|
||||
read_only: bool = False
|
||||
stale: bool = False
|
||||
parameters: tuple[DistributionParameterDefinition, ...] = ()
|
||||
updated_at: datetime | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistributionExpansionLimits:
|
||||
max_entries: int = 500
|
||||
max_results: int = 5_000
|
||||
max_depth: int = 8
|
||||
max_provider_results: int = 2_000
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistributionExpansionRequest:
|
||||
list_id: str
|
||||
revision: int | None = None
|
||||
effective_at: datetime | None = None
|
||||
purpose: str | None = None
|
||||
requested_channels: tuple[DistributionChannel, ...] = ()
|
||||
parameters: Mapping[str, object] = field(default_factory=dict)
|
||||
preview: bool = False
|
||||
freeze: bool = False
|
||||
idempotency_key: str | None = None
|
||||
limits: DistributionExpansionLimits = field(
|
||||
default_factory=DistributionExpansionLimits
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistributionChannelCandidate:
|
||||
channel: DistributionChannel
|
||||
target: str
|
||||
target_key: str
|
||||
status: DistributionOutcome = "usable"
|
||||
contact_point_id: str | None = None
|
||||
locale: str | None = None
|
||||
preferred: bool = False
|
||||
reason_code: str | None = None
|
||||
explanation: str | None = None
|
||||
source: DistributionSourceReference | None = None
|
||||
decision_provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistributionRecipientRef:
|
||||
recipient_key: str
|
||||
display_name: str
|
||||
status: DistributionOutcome
|
||||
channels: tuple[DistributionChannelCandidate, ...] = ()
|
||||
identity_id: str | None = None
|
||||
account_id: str | None = None
|
||||
contact_id: str | None = None
|
||||
organization_unit_id: str | None = None
|
||||
function_id: str | None = None
|
||||
source_entry_ids: tuple[str, ...] = ()
|
||||
explanations: tuple[DistributionExplanation, ...] = ()
|
||||
attributes: Mapping[str, object] = field(default_factory=dict)
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistributionProviderEvidence:
|
||||
provider: str
|
||||
source: DistributionSourceReference
|
||||
actual_revision: str | None = None
|
||||
actual_fingerprint: str | None = None
|
||||
stale: bool = False
|
||||
generated_at: datetime | None = None
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistributionExpansionResult:
|
||||
source: DistributionListSourceRef
|
||||
request: DistributionExpansionRequest
|
||||
recipients: tuple[DistributionRecipientRef, ...]
|
||||
excluded: tuple[DistributionRecipientRef, ...] = ()
|
||||
diagnostics: tuple[DistributionExplanation, ...] = ()
|
||||
provider_evidence: tuple[DistributionProviderEvidence, ...] = ()
|
||||
expansion_hash: str = ""
|
||||
generated_at: datetime | None = None
|
||||
snapshot_id: str | None = None
|
||||
stale: bool = False
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistributionSnapshotRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
list_id: str
|
||||
revision_id: str
|
||||
revision: int
|
||||
expansion_hash: str
|
||||
generated_at: datetime
|
||||
effective_at: datetime
|
||||
recipient_count: int
|
||||
excluded_count: int
|
||||
stale: bool
|
||||
truncated: bool
|
||||
request: Mapping[str, object] = field(default_factory=dict)
|
||||
recipients: tuple[DistributionRecipientRef, ...] = ()
|
||||
excluded: tuple[DistributionRecipientRef, ...] = ()
|
||||
diagnostics: tuple[DistributionExplanation, ...] = ()
|
||||
provider_evidence: tuple[DistributionProviderEvidence, ...] = ()
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistributionWriteDecision:
|
||||
list_id: str | None
|
||||
operation: str
|
||||
allowed: bool
|
||||
reason_code: str
|
||||
explanation: str
|
||||
read_only: bool = False
|
||||
required_scopes: tuple[str, ...] = ()
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecipientChannelFactsRequest:
|
||||
tenant_id: str
|
||||
source: DistributionSourceReference
|
||||
recipient_key: str
|
||||
effective_at: datetime
|
||||
purpose: str | None = None
|
||||
requested_channels: tuple[DistributionChannel, ...] = ()
|
||||
context: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecipientChannelFacts:
|
||||
candidates: tuple[DistributionChannelCandidate, ...]
|
||||
explanations: tuple[DistributionExplanation, ...] = ()
|
||||
source_revision: str | None = None
|
||||
source_fingerprint: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistributionChannelPolicyRequest:
|
||||
tenant_id: str
|
||||
list_id: str
|
||||
purpose: str | None
|
||||
effective_at: datetime
|
||||
recipient: DistributionRecipientRef
|
||||
candidate: DistributionChannelCandidate
|
||||
context: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DistributionChannelPolicyDecision:
|
||||
allowed: bool
|
||||
reason_code: str
|
||||
explanation: str
|
||||
source_path: tuple[Mapping[str, object], ...] = ()
|
||||
requirements: tuple[str, ...] = ()
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DistributionListSourceProvider(Protocol):
|
||||
def list_sources(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str = "",
|
||||
limit: int = 100,
|
||||
) -> Sequence[DistributionListSourceRef]: ...
|
||||
|
||||
def get_source(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
list_id: str,
|
||||
revision: int | None = None,
|
||||
) -> DistributionListSourceRef | None: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DistributionListExpansionProvider(Protocol):
|
||||
def expand(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: DistributionExpansionRequest,
|
||||
) -> DistributionExpansionResult: ...
|
||||
|
||||
def get_snapshot(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
snapshot_id: str,
|
||||
) -> DistributionSnapshotRef | None: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DistributionListWriter(Protocol):
|
||||
def explain_write(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
list_id: str | None,
|
||||
operation: str,
|
||||
) -> DistributionWriteDecision: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RecipientChannelFactsProvider(Protocol):
|
||||
def resolve_channel_facts(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: RecipientChannelFactsRequest,
|
||||
) -> RecipientChannelFacts: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DistributionChannelPolicyProvider(Protocol):
|
||||
def resolve_distribution_channel(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: DistributionChannelPolicyRequest,
|
||||
) -> DistributionChannelPolicyDecision: ...
|
||||
|
||||
|
||||
def distribution_list_source_provider(
|
||||
registry: object | None,
|
||||
) -> DistributionListSourceProvider | None:
|
||||
capability = _capability(registry, CAPABILITY_DISTRIBUTION_LIST_SOURCE)
|
||||
return capability if isinstance(capability, DistributionListSourceProvider) else None
|
||||
|
||||
|
||||
def distribution_list_expansion_provider(
|
||||
registry: object | None,
|
||||
) -> DistributionListExpansionProvider | None:
|
||||
capability = _capability(registry, CAPABILITY_DISTRIBUTION_LIST_EXPAND)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, DistributionListExpansionProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
__all__ = [name for name in globals() if name.startswith("CAPABILITY_") or name.startswith("Distribution") or name.startswith("Recipient") or name.startswith("distribution_")]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Mapping
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal, Protocol, runtime_checkable
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import event as sqlalchemy_event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.institutional import GovernedContextEnvelope
|
||||
|
||||
|
||||
_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
|
||||
_CONSUMER_ID_RE = re.compile(r"^[a-z][a-z0-9_.:-]{0,127}$")
|
||||
_PENDING_EVENTS_KEY = "govoplan.pending_platform_events"
|
||||
CAPABILITY_PLATFORM_EVENT_OUTBOX = "platform.eventOutbox"
|
||||
|
||||
|
||||
def new_event_id() -> str:
|
||||
@@ -81,6 +89,7 @@ class PlatformEvent:
|
||||
subject: EventObjectRef | None = None
|
||||
resource: EventObjectRef | None = None
|
||||
classification: EventClassification = "internal"
|
||||
institutional_context: GovernedContextEnvelope | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -96,10 +105,118 @@ class PlatformEvent:
|
||||
"subject": self.subject.to_dict() if self.subject else None,
|
||||
"resource": self.resource.to_dict() if self.resource else None,
|
||||
"classification": self.classification,
|
||||
"institutional_context": (
|
||||
self.institutional_context.to_dict()
|
||||
if self.institutional_context is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
EventHandler = Callable[[PlatformEvent], None]
|
||||
DurableEventHandler = Callable[[PlatformEvent, str], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DurableEventConsumer:
|
||||
"""Allowlisted durable consumer with an explicit disclosure boundary."""
|
||||
|
||||
consumer_id: str
|
||||
handler: DurableEventHandler
|
||||
event_types: frozenset[str] = field(
|
||||
default_factory=lambda: frozenset({"*"})
|
||||
)
|
||||
classifications: frozenset[EventClassification] = field(
|
||||
default_factory=lambda: frozenset({"public", "internal"})
|
||||
)
|
||||
policy_decision_ref: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not _CONSUMER_ID_RE.fullmatch(self.consumer_id):
|
||||
raise ValueError("Durable event consumer id is invalid")
|
||||
if not self.event_types or any(
|
||||
item != "*" and not _TRACE_ID_RE.fullmatch(item)
|
||||
for item in self.event_types
|
||||
):
|
||||
raise ValueError(
|
||||
"Durable event consumers require valid event-type allowlists"
|
||||
)
|
||||
invalid_classifications = set(self.classifications) - {
|
||||
"public",
|
||||
"internal",
|
||||
"confidential",
|
||||
"restricted",
|
||||
}
|
||||
if not self.classifications or invalid_classifications:
|
||||
raise ValueError(
|
||||
"Durable event consumer classifications are invalid"
|
||||
)
|
||||
if (
|
||||
self.classifications & {"confidential", "restricted"}
|
||||
and not normalize_trace_id(self.policy_decision_ref)
|
||||
):
|
||||
raise ValueError(
|
||||
"Confidential or restricted event subscriptions require "
|
||||
"an explicit policy decision reference"
|
||||
)
|
||||
|
||||
def accepts(self, event: PlatformEvent) -> bool:
|
||||
return (
|
||||
event.classification in self.classifications
|
||||
and (
|
||||
"*" in self.event_types
|
||||
or event.type in self.event_types
|
||||
)
|
||||
)
|
||||
|
||||
def delivery_key(self, event: PlatformEvent) -> str:
|
||||
return f"{event.event_id}:{self.consumer_id}"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PlatformEventOutbox(Protocol):
|
||||
def enqueue(self, session: object, event: PlatformEvent) -> object:
|
||||
...
|
||||
|
||||
def dispatch_pending(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
tenantless_only: bool = False,
|
||||
consumers: Sequence[DurableEventConsumer] = (),
|
||||
observer: EventHandler | None = None,
|
||||
limit: int = 100,
|
||||
) -> Mapping[str, int]:
|
||||
...
|
||||
|
||||
def replay_delivery(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
event_id: str,
|
||||
consumer_id: str,
|
||||
operator_id: str,
|
||||
reason: str,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
def purge_terminal(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
tenantless_only: bool = False,
|
||||
before: datetime,
|
||||
limit: int = 500,
|
||||
) -> Mapping[str, int]:
|
||||
...
|
||||
|
||||
def delivery_metrics(
|
||||
self,
|
||||
session: object,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
|
||||
def current_event_trace() -> EventTrace | None:
|
||||
@@ -149,6 +266,7 @@ def ensure_event_trace(event: PlatformEvent) -> PlatformEvent:
|
||||
subject=event.subject,
|
||||
resource=event.resource,
|
||||
classification=event.classification,
|
||||
institutional_context=event.institutional_context,
|
||||
)
|
||||
|
||||
|
||||
@@ -189,5 +307,85 @@ def publish_platform_event(event: PlatformEvent) -> None:
|
||||
platform_event_bus().publish(event)
|
||||
|
||||
|
||||
def platform_event_outbox(
|
||||
registry: object | None = None,
|
||||
) -> PlatformEventOutbox | None:
|
||||
if registry is None:
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
|
||||
registry = get_registry()
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
|
||||
return capability if isinstance(capability, PlatformEventOutbox) else None
|
||||
|
||||
|
||||
def emit_platform_event(
|
||||
session: Session,
|
||||
event: PlatformEvent,
|
||||
*,
|
||||
registry: object | None = None,
|
||||
) -> None:
|
||||
"""Persist an event with its transaction or publish it after commit.
|
||||
|
||||
The durable outbox is optional so reduced module combinations remain
|
||||
usable. Without it, the event is kept on the SQLAlchemy session and only
|
||||
delivered to the process-local bus after the outer transaction commits.
|
||||
"""
|
||||
|
||||
traced = ensure_event_trace(event)
|
||||
outbox = platform_event_outbox(registry)
|
||||
if outbox is not None:
|
||||
outbox.enqueue(session, traced)
|
||||
return
|
||||
transaction = (
|
||||
session.get_nested_transaction()
|
||||
or session.get_transaction()
|
||||
or session.begin()
|
||||
)
|
||||
pending_by_transaction = session.info.setdefault(
|
||||
_PENDING_EVENTS_KEY,
|
||||
{},
|
||||
)
|
||||
pending_by_transaction.setdefault(transaction, []).append(
|
||||
(platform_event_bus(), traced)
|
||||
)
|
||||
|
||||
|
||||
@sqlalchemy_event.listens_for(Session, "after_commit")
|
||||
def _publish_committed_events(session: Session) -> None:
|
||||
transaction = session.get_nested_transaction() or session.get_transaction()
|
||||
if transaction is None:
|
||||
return
|
||||
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
|
||||
if not isinstance(pending_by_transaction, dict):
|
||||
return
|
||||
pending = pending_by_transaction.pop(transaction, ())
|
||||
parent = transaction.parent
|
||||
if parent is not None:
|
||||
pending_by_transaction.setdefault(parent, []).extend(pending)
|
||||
else:
|
||||
for bus, event in pending:
|
||||
bus.publish(event)
|
||||
if not pending_by_transaction:
|
||||
session.info.pop(_PENDING_EVENTS_KEY, None)
|
||||
|
||||
|
||||
@sqlalchemy_event.listens_for(Session, "after_rollback")
|
||||
def _discard_rolled_back_events(session: Session) -> None:
|
||||
transaction = session.get_nested_transaction() or session.get_transaction()
|
||||
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
|
||||
if transaction is None or not isinstance(pending_by_transaction, dict):
|
||||
return
|
||||
pending_by_transaction.pop(transaction, None)
|
||||
if transaction.parent is None or not pending_by_transaction:
|
||||
session.info.pop(_PENDING_EVENTS_KEY, None)
|
||||
|
||||
|
||||
def _compact_dict(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {key: item for key, item in value.items() if item is not None}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
IntegrationMaturity = Literal[
|
||||
"discover",
|
||||
"link",
|
||||
"search",
|
||||
"read",
|
||||
"publish",
|
||||
"synchronize",
|
||||
"migrate",
|
||||
"replace",
|
||||
]
|
||||
SourceAuthorityMode = Literal[
|
||||
"native_authoritative",
|
||||
"external_authoritative",
|
||||
"external_mirror",
|
||||
"governed_sync",
|
||||
"governance_overlay",
|
||||
"linked_reference",
|
||||
]
|
||||
|
||||
INTEGRATION_MATURITY_ORDER: tuple[IntegrationMaturity, ...] = (
|
||||
"discover",
|
||||
"link",
|
||||
"search",
|
||||
"read",
|
||||
"publish",
|
||||
"synchronize",
|
||||
"migrate",
|
||||
"replace",
|
||||
)
|
||||
SOURCE_AUTHORITY_MODES: tuple[SourceAuthorityMode, ...] = (
|
||||
"native_authoritative",
|
||||
"external_authoritative",
|
||||
"external_mirror",
|
||||
"governed_sync",
|
||||
"governance_overlay",
|
||||
"linked_reference",
|
||||
)
|
||||
|
||||
|
||||
class ExternalReferenceValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExternalObjectReference:
|
||||
"""Stable identity and provenance for an object owned by another system."""
|
||||
|
||||
system: str
|
||||
object_type: str
|
||||
object_id: str
|
||||
maturity: IntegrationMaturity = "link"
|
||||
authority_mode: SourceAuthorityMode = "linked_reference"
|
||||
connector_id: str | None = None
|
||||
canonical_url: str | None = None
|
||||
version: str | None = None
|
||||
etag: str | None = None
|
||||
observed_at: datetime | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name in ("system", "object_type", "object_id"):
|
||||
value = str(getattr(self, field_name) or "").strip()
|
||||
if not value:
|
||||
raise ExternalReferenceValidationError(
|
||||
f"External reference {field_name} is required."
|
||||
)
|
||||
if len(value) > 255:
|
||||
raise ExternalReferenceValidationError(
|
||||
f"External reference {field_name} is limited to 255 characters."
|
||||
)
|
||||
object.__setattr__(self, field_name, value)
|
||||
if self.maturity not in INTEGRATION_MATURITY_ORDER:
|
||||
raise ExternalReferenceValidationError(
|
||||
f"Unsupported integration maturity: {self.maturity!r}."
|
||||
)
|
||||
if self.authority_mode not in SOURCE_AUTHORITY_MODES:
|
||||
raise ExternalReferenceValidationError(
|
||||
f"Unsupported source-authority mode: {self.authority_mode!r}."
|
||||
)
|
||||
if (
|
||||
self.authority_mode == "external_mirror"
|
||||
and not self.supports("read")
|
||||
):
|
||||
raise ExternalReferenceValidationError(
|
||||
"External-mirror references require read maturity or higher."
|
||||
)
|
||||
if (
|
||||
self.authority_mode == "governed_sync"
|
||||
and not self.supports("synchronize")
|
||||
):
|
||||
raise ExternalReferenceValidationError(
|
||||
"Governed-sync references require synchronize maturity or higher."
|
||||
)
|
||||
if self.connector_id is not None:
|
||||
connector_id = self.connector_id.strip()
|
||||
if not connector_id:
|
||||
raise ExternalReferenceValidationError(
|
||||
"External reference connector_id cannot be blank."
|
||||
)
|
||||
object.__setattr__(self, "connector_id", connector_id)
|
||||
if self.canonical_url is not None:
|
||||
object.__setattr__(
|
||||
self,
|
||||
"canonical_url",
|
||||
_validated_reference_url(self.canonical_url),
|
||||
)
|
||||
|
||||
@property
|
||||
def identity_key(self) -> str:
|
||||
return f"{self.system}:{self.object_type}:{self.object_id}"
|
||||
|
||||
def supports(self, maturity: IntegrationMaturity) -> bool:
|
||||
return integration_maturity_rank(self.maturity) >= integration_maturity_rank(
|
||||
maturity
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"system": self.system,
|
||||
"object_type": self.object_type,
|
||||
"object_id": self.object_id,
|
||||
"maturity": self.maturity,
|
||||
"authority_mode": self.authority_mode,
|
||||
"connector_id": self.connector_id,
|
||||
"canonical_url": self.canonical_url,
|
||||
"version": self.version,
|
||||
"etag": self.etag,
|
||||
"observed_at": (
|
||||
self.observed_at.isoformat() if self.observed_at is not None else None
|
||||
),
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
|
||||
def integration_maturity_rank(maturity: IntegrationMaturity) -> int:
|
||||
try:
|
||||
return INTEGRATION_MATURITY_ORDER.index(maturity)
|
||||
except ValueError as exc:
|
||||
raise ExternalReferenceValidationError(
|
||||
f"Unsupported integration maturity: {maturity!r}."
|
||||
) from exc
|
||||
|
||||
|
||||
def _validated_reference_url(value: str) -> str:
|
||||
normalized = value.strip()
|
||||
parsed = urlsplit(normalized)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise ExternalReferenceValidationError(
|
||||
"External reference URLs must use HTTP or HTTPS."
|
||||
)
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise ExternalReferenceValidationError(
|
||||
"External reference URLs must not contain credentials."
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ExternalObjectReference",
|
||||
"ExternalReferenceValidationError",
|
||||
"INTEGRATION_MATURITY_ORDER",
|
||||
"IntegrationMaturity",
|
||||
"SOURCE_AUTHORITY_MODES",
|
||||
"SourceAuthorityMode",
|
||||
"integration_maturity_rank",
|
||||
]
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_CONNECTORS_FEEDS = "connectors.feeds"
|
||||
FeedFormat = Literal["rss", "atom"]
|
||||
FeedVisibility = Literal["public", "tenant", "private"]
|
||||
|
||||
|
||||
class FeedCapabilityError(ValueError):
|
||||
"""Stable error raised by feed transport implementations."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FeedEntry:
|
||||
id: str
|
||||
title: str
|
||||
url: str | None = None
|
||||
summary: str | None = None
|
||||
content: str | None = None
|
||||
author: str | None = None
|
||||
published_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
categories: tuple[str, ...] = ()
|
||||
enclosures: tuple[Mapping[str, object], ...] = ()
|
||||
visibility: FeedVisibility = "public"
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FeedDocument:
|
||||
format: FeedFormat
|
||||
title: str
|
||||
source_url: str
|
||||
entries: tuple[FeedEntry, ...]
|
||||
description: str | None = None
|
||||
home_url: str | None = None
|
||||
language: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
acquired_at: datetime | None = None
|
||||
fresh_until: datetime | None = None
|
||||
etag: str | None = None
|
||||
last_modified: str | None = None
|
||||
content_type: str | None = None
|
||||
sha256: str = ""
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FeedRenderRequest:
|
||||
format: FeedFormat
|
||||
title: str
|
||||
feed_url: str
|
||||
home_url: str
|
||||
entries: tuple[FeedEntry, ...]
|
||||
description: str | None = None
|
||||
language: str | None = None
|
||||
allowed_visibilities: frozenset[FeedVisibility] = frozenset({"public"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FeedRenderResult:
|
||||
format: FeedFormat
|
||||
content_type: str
|
||||
body: bytes
|
||||
included_entries: int
|
||||
excluded_entries: int
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FeedProvider(Protocol):
|
||||
def fetch(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
timeout: float = 15,
|
||||
max_entries: int = 2_000,
|
||||
) -> FeedDocument:
|
||||
...
|
||||
|
||||
def parse(
|
||||
self,
|
||||
content: bytes,
|
||||
*,
|
||||
source_url: str,
|
||||
content_type: str | None = None,
|
||||
max_entries: int = 2_000,
|
||||
) -> FeedDocument:
|
||||
...
|
||||
|
||||
def render(self, request: FeedRenderRequest) -> FeedRenderResult:
|
||||
...
|
||||
|
||||
|
||||
def feed_provider(registry: object | None) -> FeedProvider | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(CAPABILITY_CONNECTORS_FEEDS)
|
||||
):
|
||||
return None
|
||||
provider = registry.capability(CAPABILITY_CONNECTORS_FEEDS)
|
||||
return provider if isinstance(provider, FeedProvider) else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_CONNECTORS_FEEDS",
|
||||
"FeedCapabilityError",
|
||||
"FeedDocument",
|
||||
"FeedEntry",
|
||||
"FeedFormat",
|
||||
"FeedProvider",
|
||||
"FeedRenderRequest",
|
||||
"FeedRenderResult",
|
||||
"FeedVisibility",
|
||||
"feed_provider",
|
||||
]
|
||||
@@ -1,13 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.access import ResourceAccessExplanationProvider
|
||||
|
||||
|
||||
CAPABILITY_FILES_ACCESS = "files.access"
|
||||
CAPABILITY_FILES_ARTIFACT_STORE = "files.artifact_store"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ManagedArtifactWriteRequest:
|
||||
filename: str
|
||||
payload: bytes
|
||||
content_type: str
|
||||
folder: str = "Generated"
|
||||
description: str | None = None
|
||||
idempotency_key: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ManagedArtifactRef:
|
||||
file_asset_id: str
|
||||
file_version_id: str
|
||||
filename: str
|
||||
display_path: str
|
||||
content_type: str
|
||||
size_bytes: int
|
||||
sha256: str
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FileAccessProvider(ResourceAccessExplanationProvider, Protocol):
|
||||
"""Resource-level access explanation provider for Files-owned resources."""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ManagedArtifactStore(Protocol):
|
||||
"""Store generated module artifacts without exposing Files internals."""
|
||||
|
||||
def store_artifact(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ManagedArtifactWriteRequest,
|
||||
) -> ManagedArtifactRef: ...
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import StrEnum
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, UniqueConstraint, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
from govoplan_core.core.access import (
|
||||
FirstAdminProvisioner,
|
||||
FirstAdminProvisioningError,
|
||||
FirstSystemAdministratorRef,
|
||||
)
|
||||
from govoplan_core.db.base import Base, TimestampMixin, utcnow
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
|
||||
_TENANT_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
|
||||
|
||||
class FirstAdminEnrollmentState(StrEnum):
|
||||
INACTIVE = "inactive"
|
||||
ACTIVE = "active"
|
||||
CONSUMED = "consumed"
|
||||
REVOKED = "revoked"
|
||||
|
||||
|
||||
class FirstAdminEnrollmentError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class FirstAdminEnrollmentUnavailable(FirstAdminEnrollmentError):
|
||||
pass
|
||||
|
||||
|
||||
class FirstAdminEnrollmentCredentialError(FirstAdminEnrollmentError):
|
||||
pass
|
||||
|
||||
|
||||
class FirstAdminEnrollmentConflict(FirstAdminEnrollmentError):
|
||||
pass
|
||||
|
||||
|
||||
class FirstAdminEnrollment(Base, TimestampMixin):
|
||||
__tablename__ = "core_first_admin_enrollments"
|
||||
|
||||
installation_id: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||
state: Mapped[str] = mapped_column(
|
||||
String(24),
|
||||
default=FirstAdminEnrollmentState.INACTIVE.value,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
generation: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
token_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||
token_fingerprint: Mapped[str | None] = mapped_column(String(16))
|
||||
issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
consumed_account_id: Mapped[str | None] = mapped_column(String(36))
|
||||
consumed_membership_id: Mapped[str | None] = mapped_column(String(36))
|
||||
consumed_tenant_id: Mapped[str | None] = mapped_column(String(36))
|
||||
consumed_email: Mapped[str | None] = mapped_column(String(320))
|
||||
consumed_display_name: Mapped[str | None] = mapped_column(String(255))
|
||||
consumed_request_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||
issue_reason: Mapped[str | None] = mapped_column(String(500))
|
||||
event_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
evidence_head_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class FirstAdminEnrollmentEvent(Base):
|
||||
__tablename__ = "core_first_admin_enrollment_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"installation_id",
|
||||
"sequence",
|
||||
name="uq_core_first_admin_enrollment_event_sequence",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
default=lambda: str(uuid4()),
|
||||
)
|
||||
installation_id: Mapped[str] = mapped_column(
|
||||
ForeignKey(
|
||||
"core_first_admin_enrollments.installation_id",
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
event_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
generation: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
previous_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||
event_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IssuedFirstAdminCredential:
|
||||
secret: str
|
||||
fingerprint: str
|
||||
generation: int
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FirstAdminEnrollmentStatus:
|
||||
enrollment_required: bool
|
||||
credential_active: bool
|
||||
state: str
|
||||
generation: int
|
||||
expires_at: datetime | None
|
||||
completed_account_id: str | None
|
||||
readiness: dict[str, bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FirstAdminEnrollmentResult:
|
||||
administrator: FirstSystemAdministratorRef
|
||||
replayed: bool
|
||||
|
||||
|
||||
def issue_first_admin_credential(
|
||||
session: Session,
|
||||
*,
|
||||
installation_id: str,
|
||||
provisioner: FirstAdminProvisioner,
|
||||
ttl_seconds: int,
|
||||
reason: str,
|
||||
replace_active: bool = False,
|
||||
now: datetime | None = None,
|
||||
) -> IssuedFirstAdminCredential:
|
||||
current_time = _utc(now)
|
||||
if ttl_seconds < 60 or ttl_seconds > 24 * 60 * 60:
|
||||
raise ValueError("First-admin enrollment expiry must be between 60 seconds and 24 hours.")
|
||||
if provisioner.has_durable_system_administrator(session):
|
||||
raise FirstAdminEnrollmentUnavailable(
|
||||
"A durable system administrator already exists. Bootstrap enrollment is disabled."
|
||||
)
|
||||
|
||||
enrollment = _locked_enrollment(session, installation_id)
|
||||
if (
|
||||
enrollment.state == FirstAdminEnrollmentState.ACTIVE.value
|
||||
and _is_future(enrollment.expires_at, current_time)
|
||||
and not replace_active
|
||||
):
|
||||
raise FirstAdminEnrollmentConflict(
|
||||
"An unexpired first-admin credential already exists. Use the recovery command to rotate it."
|
||||
)
|
||||
|
||||
secret = secrets.token_urlsafe(48)
|
||||
token_sha256 = _secret_sha256(secret)
|
||||
fingerprint = token_sha256[:12]
|
||||
expires_at = current_time + timedelta(seconds=ttl_seconds)
|
||||
generation = enrollment.generation + 1
|
||||
if enrollment.state == FirstAdminEnrollmentState.ACTIVE.value:
|
||||
_append_event(
|
||||
session,
|
||||
enrollment,
|
||||
event_type="credential_revoked",
|
||||
generation=enrollment.generation,
|
||||
created_at=current_time,
|
||||
evidence={"reason": "local_operator_recovery"},
|
||||
)
|
||||
enrollment.state = FirstAdminEnrollmentState.ACTIVE.value
|
||||
enrollment.generation = generation
|
||||
enrollment.token_sha256 = token_sha256
|
||||
enrollment.token_fingerprint = fingerprint
|
||||
enrollment.issued_at = current_time
|
||||
enrollment.expires_at = expires_at
|
||||
enrollment.consumed_at = None
|
||||
enrollment.consumed_account_id = None
|
||||
enrollment.consumed_membership_id = None
|
||||
enrollment.consumed_tenant_id = None
|
||||
enrollment.consumed_email = None
|
||||
enrollment.consumed_display_name = None
|
||||
enrollment.consumed_request_sha256 = None
|
||||
enrollment.issue_reason = _bounded_reason(reason)
|
||||
session.add(enrollment)
|
||||
_append_event(
|
||||
session,
|
||||
enrollment,
|
||||
event_type="credential_issued",
|
||||
generation=generation,
|
||||
created_at=current_time,
|
||||
evidence={
|
||||
"fingerprint": fingerprint,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"reason": enrollment.issue_reason,
|
||||
},
|
||||
)
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=None,
|
||||
scope="system",
|
||||
action="access.first_admin_enrollment.issued",
|
||||
object_type="first_admin_enrollment",
|
||||
object_id=installation_id,
|
||||
details={
|
||||
"generation": generation,
|
||||
"fingerprint": fingerprint,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"reason": enrollment.issue_reason,
|
||||
},
|
||||
)
|
||||
return IssuedFirstAdminCredential(
|
||||
secret=secret,
|
||||
fingerprint=fingerprint,
|
||||
generation=generation,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
|
||||
def first_admin_enrollment_status(
|
||||
session: Session,
|
||||
*,
|
||||
installation_id: str,
|
||||
provisioner: FirstAdminProvisioner,
|
||||
now: datetime | None = None,
|
||||
) -> FirstAdminEnrollmentStatus:
|
||||
current_time = _utc(now)
|
||||
administrator_exists = provisioner.has_durable_system_administrator(session)
|
||||
enrollment = session.get(FirstAdminEnrollment, installation_id)
|
||||
state = enrollment.state if enrollment is not None else FirstAdminEnrollmentState.INACTIVE.value
|
||||
active = bool(
|
||||
not administrator_exists
|
||||
and enrollment is not None
|
||||
and state == FirstAdminEnrollmentState.ACTIVE.value
|
||||
and enrollment.token_sha256
|
||||
and _is_future(enrollment.expires_at, current_time)
|
||||
)
|
||||
if (
|
||||
not administrator_exists
|
||||
and enrollment is not None
|
||||
and state == FirstAdminEnrollmentState.ACTIVE.value
|
||||
and not active
|
||||
):
|
||||
state = "expired"
|
||||
return FirstAdminEnrollmentStatus(
|
||||
enrollment_required=not administrator_exists,
|
||||
credential_active=active,
|
||||
state="completed" if administrator_exists else state,
|
||||
generation=enrollment.generation if enrollment is not None else 0,
|
||||
expires_at=enrollment.expires_at if enrollment is not None else None,
|
||||
completed_account_id=(
|
||||
enrollment.consumed_account_id if enrollment is not None else None
|
||||
),
|
||||
readiness={
|
||||
"database": True,
|
||||
"access_capability": True,
|
||||
"administrator_absent": not administrator_exists,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def consume_first_admin_credential(
|
||||
session: Session,
|
||||
*,
|
||||
installation_id: str,
|
||||
provisioner: FirstAdminProvisioner,
|
||||
secret: str,
|
||||
email: str,
|
||||
display_name: str | None,
|
||||
password: str,
|
||||
tenant_slug: str,
|
||||
tenant_name: str,
|
||||
now: datetime | None = None,
|
||||
) -> FirstAdminEnrollmentResult:
|
||||
current_time = _utc(now)
|
||||
normalized_email = email.strip().casefold()
|
||||
clean_display_name = display_name.strip() if display_name and display_name.strip() else None
|
||||
clean_tenant_slug = tenant_slug.strip().casefold()
|
||||
clean_tenant_name = tenant_name.strip()
|
||||
if not normalized_email or "@" not in normalized_email:
|
||||
raise FirstAdminEnrollmentConflict("Enter a valid administrator email address.")
|
||||
if len(password) < 12:
|
||||
raise FirstAdminEnrollmentConflict("The administrator password must contain at least 12 characters.")
|
||||
if not _TENANT_SLUG_RE.fullmatch(clean_tenant_slug):
|
||||
raise FirstAdminEnrollmentConflict(
|
||||
"The initial tenant slug may contain lowercase letters, numbers, and single hyphens."
|
||||
)
|
||||
if not clean_tenant_name:
|
||||
raise FirstAdminEnrollmentConflict("Enter a name for the initial tenant.")
|
||||
|
||||
request_sha256 = _request_sha256(
|
||||
email=normalized_email,
|
||||
display_name=clean_display_name,
|
||||
tenant_slug=clean_tenant_slug,
|
||||
tenant_name=clean_tenant_name,
|
||||
)
|
||||
supplied_sha256 = _secret_sha256(secret)
|
||||
enrollment = session.execute(
|
||||
select(FirstAdminEnrollment)
|
||||
.where(FirstAdminEnrollment.installation_id == installation_id)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if enrollment is None:
|
||||
raise FirstAdminEnrollmentCredentialError("First-admin enrollment is not active.")
|
||||
|
||||
if enrollment.state == FirstAdminEnrollmentState.CONSUMED.value:
|
||||
if (
|
||||
enrollment.token_sha256
|
||||
and hmac.compare_digest(enrollment.token_sha256, supplied_sha256)
|
||||
and enrollment.consumed_request_sha256 == request_sha256
|
||||
and enrollment.consumed_account_id
|
||||
and enrollment.consumed_email
|
||||
):
|
||||
return FirstAdminEnrollmentResult(
|
||||
administrator=FirstSystemAdministratorRef(
|
||||
account_id=enrollment.consumed_account_id,
|
||||
email=enrollment.consumed_email,
|
||||
display_name=enrollment.consumed_display_name,
|
||||
membership_id=enrollment.consumed_membership_id,
|
||||
tenant_id=enrollment.consumed_tenant_id,
|
||||
),
|
||||
replayed=True,
|
||||
)
|
||||
raise FirstAdminEnrollmentCredentialError("The first-admin credential has already been used.")
|
||||
|
||||
if enrollment.state != FirstAdminEnrollmentState.ACTIVE.value or not enrollment.token_sha256:
|
||||
raise FirstAdminEnrollmentCredentialError("First-admin enrollment is not active.")
|
||||
if not _is_future(enrollment.expires_at, current_time):
|
||||
raise FirstAdminEnrollmentCredentialError(
|
||||
"The first-admin credential has expired. A local operator must issue a replacement."
|
||||
)
|
||||
if not hmac.compare_digest(enrollment.token_sha256, supplied_sha256):
|
||||
raise FirstAdminEnrollmentCredentialError("The first-admin credential is invalid.")
|
||||
if provisioner.has_durable_system_administrator(session):
|
||||
raise FirstAdminEnrollmentUnavailable(
|
||||
"A durable system administrator already exists. Bootstrap enrollment is disabled."
|
||||
)
|
||||
|
||||
tenant = session.execute(
|
||||
select(Tenant).where(Tenant.slug == clean_tenant_slug).with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if tenant is None:
|
||||
tenant = Tenant(
|
||||
slug=clean_tenant_slug,
|
||||
name=clean_tenant_name,
|
||||
default_locale="en",
|
||||
settings={},
|
||||
is_active=True,
|
||||
)
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
elif not tenant.is_active:
|
||||
raise FirstAdminEnrollmentConflict("The selected initial tenant is inactive.")
|
||||
|
||||
try:
|
||||
administrator = provisioner.create_first_system_administrator(
|
||||
session,
|
||||
tenant=tenant,
|
||||
email=normalized_email,
|
||||
display_name=clean_display_name,
|
||||
password=password,
|
||||
)
|
||||
except FirstAdminProvisioningError as exc:
|
||||
raise FirstAdminEnrollmentConflict(str(exc)) from exc
|
||||
enrollment.state = FirstAdminEnrollmentState.CONSUMED.value
|
||||
enrollment.consumed_at = current_time
|
||||
enrollment.consumed_account_id = administrator.account_id
|
||||
enrollment.consumed_membership_id = administrator.membership_id
|
||||
enrollment.consumed_tenant_id = administrator.tenant_id
|
||||
enrollment.consumed_email = administrator.email
|
||||
enrollment.consumed_display_name = administrator.display_name
|
||||
enrollment.consumed_request_sha256 = request_sha256
|
||||
session.add(enrollment)
|
||||
_append_event(
|
||||
session,
|
||||
enrollment,
|
||||
event_type="administrator_created",
|
||||
generation=enrollment.generation,
|
||||
created_at=current_time,
|
||||
evidence={
|
||||
"account_id": administrator.account_id,
|
||||
"membership_id": administrator.membership_id,
|
||||
"tenant_id": administrator.tenant_id,
|
||||
"email_sha256": hashlib.sha256(normalized_email.encode("utf-8")).hexdigest(),
|
||||
},
|
||||
)
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=None,
|
||||
scope="system",
|
||||
action="access.first_admin_enrollment.completed",
|
||||
object_type="access_account",
|
||||
object_id=administrator.account_id,
|
||||
details={
|
||||
"generation": enrollment.generation,
|
||||
"membership_id": administrator.membership_id,
|
||||
"tenant_id": administrator.tenant_id,
|
||||
"credential_invalidated": True,
|
||||
},
|
||||
)
|
||||
return FirstAdminEnrollmentResult(administrator=administrator, replayed=False)
|
||||
|
||||
|
||||
def _locked_enrollment(session: Session, installation_id: str) -> FirstAdminEnrollment:
|
||||
enrollment = session.execute(
|
||||
select(FirstAdminEnrollment)
|
||||
.where(FirstAdminEnrollment.installation_id == installation_id)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if enrollment is not None:
|
||||
return enrollment
|
||||
enrollment = FirstAdminEnrollment(installation_id=installation_id)
|
||||
try:
|
||||
with session.begin_nested():
|
||||
session.add(enrollment)
|
||||
session.flush()
|
||||
except IntegrityError:
|
||||
enrollment = session.execute(
|
||||
select(FirstAdminEnrollment)
|
||||
.where(FirstAdminEnrollment.installation_id == installation_id)
|
||||
.with_for_update()
|
||||
).scalar_one()
|
||||
return enrollment
|
||||
|
||||
|
||||
def _append_event(
|
||||
session: Session,
|
||||
enrollment: FirstAdminEnrollment,
|
||||
*,
|
||||
event_type: str,
|
||||
generation: int,
|
||||
created_at: datetime,
|
||||
evidence: dict[str, Any],
|
||||
) -> None:
|
||||
sequence = enrollment.event_count + 1
|
||||
payload = {
|
||||
"installation_id": enrollment.installation_id,
|
||||
"sequence": sequence,
|
||||
"event_type": event_type,
|
||||
"generation": generation,
|
||||
"created_at": created_at.isoformat(),
|
||||
"evidence": evidence,
|
||||
"previous_sha256": enrollment.evidence_head_sha256,
|
||||
}
|
||||
event_sha256 = hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
session.add(
|
||||
FirstAdminEnrollmentEvent(
|
||||
installation_id=enrollment.installation_id,
|
||||
sequence=sequence,
|
||||
event_type=event_type,
|
||||
generation=generation,
|
||||
evidence=evidence,
|
||||
previous_sha256=enrollment.evidence_head_sha256,
|
||||
event_sha256=event_sha256,
|
||||
created_at=created_at,
|
||||
)
|
||||
)
|
||||
enrollment.event_count = sequence
|
||||
enrollment.evidence_head_sha256 = event_sha256
|
||||
session.add(enrollment)
|
||||
|
||||
|
||||
def _request_sha256(
|
||||
*,
|
||||
email: str,
|
||||
display_name: str | None,
|
||||
tenant_slug: str,
|
||||
tenant_name: str,
|
||||
) -> str:
|
||||
payload = {
|
||||
"email": email,
|
||||
"display_name": display_name,
|
||||
"tenant_slug": tenant_slug,
|
||||
"tenant_name": tenant_name,
|
||||
}
|
||||
return hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _secret_sha256(secret: str) -> str:
|
||||
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _utc(value: datetime | None) -> datetime:
|
||||
candidate = value or datetime.now(timezone.utc)
|
||||
if candidate.tzinfo is None:
|
||||
return candidate.replace(tzinfo=timezone.utc)
|
||||
return candidate.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _is_future(value: datetime | None, now: datetime) -> bool:
|
||||
return value is not None and _utc(value) > now
|
||||
|
||||
|
||||
def _bounded_reason(value: str) -> str:
|
||||
clean = value.strip()
|
||||
if not clean:
|
||||
raise ValueError("A local operator reason is required.")
|
||||
return clean[:500]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FirstAdminEnrollment",
|
||||
"FirstAdminEnrollmentConflict",
|
||||
"FirstAdminEnrollmentCredentialError",
|
||||
"FirstAdminEnrollmentError",
|
||||
"FirstAdminEnrollmentEvent",
|
||||
"FirstAdminEnrollmentResult",
|
||||
"FirstAdminEnrollmentState",
|
||||
"FirstAdminEnrollmentStatus",
|
||||
"FirstAdminEnrollmentUnavailable",
|
||||
"IssuedFirstAdminCredential",
|
||||
"consume_first_admin_credential",
|
||||
"first_admin_enrollment_status",
|
||||
"issue_first_admin_credential",
|
||||
]
|
||||
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_IDENTITY_TRUST_DIRECTORY = "identity_trust.directory"
|
||||
CAPABILITY_IDENTITY_TRUST_ASSURANCE = "identity_trust.assurance"
|
||||
IDENTITY_TRUST_CONTRACT_VERSION = "1"
|
||||
|
||||
DeviceKeyPurpose = Literal["encryption", "signing", "encryption_and_signing"]
|
||||
DeviceKeyStatus = Literal["active", "revoked", "expired"]
|
||||
TrustSubjectKind = Literal[
|
||||
"identity",
|
||||
"account",
|
||||
"function",
|
||||
"postbox",
|
||||
"external_recipient",
|
||||
]
|
||||
|
||||
_PRIVATE_JWK_FIELDS = frozenset({"d", "p", "q", "dp", "dq", "qi", "oth", "k"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeviceKeyRegistration:
|
||||
tenant_id: str
|
||||
identity_id: str
|
||||
account_id: str
|
||||
device_id: str
|
||||
key_id: str
|
||||
algorithm: str
|
||||
public_jwk: Mapping[str, object]
|
||||
purpose: DeviceKeyPurpose = "encryption"
|
||||
assurance_level: str = "software"
|
||||
attestation_ref: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
idempotency_key: str = ""
|
||||
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_contract(self.contract_version)
|
||||
for label, value in (
|
||||
("tenant id", self.tenant_id),
|
||||
("identity id", self.identity_id),
|
||||
("account id", self.account_id),
|
||||
("device id", self.device_id),
|
||||
("key id", self.key_id),
|
||||
("algorithm", self.algorithm),
|
||||
("idempotency key", self.idempotency_key),
|
||||
):
|
||||
_require_text(value, label)
|
||||
if not self.public_jwk or _PRIVATE_JWK_FIELDS & set(self.public_jwk):
|
||||
raise ValueError("Only a bounded public JWK may be registered")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeviceKeyRef:
|
||||
tenant_id: str
|
||||
identity_id: str
|
||||
account_id: str
|
||||
device_id: str
|
||||
key_id: str
|
||||
algorithm: str
|
||||
public_jwk: Mapping[str, object]
|
||||
purpose: DeviceKeyPurpose
|
||||
assurance_level: str
|
||||
status: DeviceKeyStatus
|
||||
epoch: int
|
||||
registered_at: datetime
|
||||
attestation_ref: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
revocation_reason: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class KeyEpochRotationRequest:
|
||||
tenant_id: str
|
||||
subject_kind: TrustSubjectKind
|
||||
subject_id: str
|
||||
reason: str
|
||||
access_decision_ref: str
|
||||
idempotency_key: str
|
||||
history_policy: str = "all_retained"
|
||||
previous_epoch: int | None = None
|
||||
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_contract(self.contract_version)
|
||||
for label, value in (
|
||||
("tenant id", self.tenant_id),
|
||||
("subject id", self.subject_id),
|
||||
("reason", self.reason),
|
||||
("access decision reference", self.access_decision_ref),
|
||||
("idempotency key", self.idempotency_key),
|
||||
):
|
||||
_require_text(value, label)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class KeyEpochRef:
|
||||
tenant_id: str
|
||||
subject_kind: TrustSubjectKind
|
||||
subject_id: str
|
||||
epoch: int
|
||||
state: Literal["active", "superseded", "revoked"]
|
||||
history_policy: str
|
||||
effective_at: datetime
|
||||
previous_epoch: int | None = None
|
||||
reason: str | None = None
|
||||
access_decision_ref: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class KeyAccessRequest:
|
||||
tenant_id: str
|
||||
account_id: str
|
||||
device_key_id: str
|
||||
subject_kind: TrustSubjectKind
|
||||
subject_id: str
|
||||
key_epoch: int
|
||||
access_decision_ref: str
|
||||
purpose: str
|
||||
requested_at: datetime
|
||||
function_assignment_id: str | None = None
|
||||
delegation_id: str | None = None
|
||||
resource_ref: str | None = None
|
||||
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_contract(self.contract_version)
|
||||
if self.key_epoch < 1:
|
||||
raise ValueError("Key epoch must be positive")
|
||||
for label, value in (
|
||||
("tenant id", self.tenant_id),
|
||||
("account id", self.account_id),
|
||||
("device key id", self.device_key_id),
|
||||
("subject id", self.subject_id),
|
||||
("access decision reference", self.access_decision_ref),
|
||||
("purpose", self.purpose),
|
||||
):
|
||||
_require_text(value, label)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class KeyAccessDecision:
|
||||
allowed: bool
|
||||
decision_ref: str
|
||||
reason: str
|
||||
device_key: DeviceKeyRef | None = None
|
||||
epoch: KeyEpochRef | None = None
|
||||
audit_event_ref: str | None = None
|
||||
requirements: tuple[str, ...] = ()
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AssuranceCheckRequest:
|
||||
tenant_id: str
|
||||
account_id: str
|
||||
purpose: str
|
||||
minimum_level: str
|
||||
evidence_ref: str
|
||||
evaluated_at: datetime
|
||||
maximum_age_seconds: int = 300
|
||||
device_key_id: str | None = None
|
||||
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_contract(self.contract_version)
|
||||
if self.maximum_age_seconds < 1:
|
||||
raise ValueError("Assurance maximum age must be positive")
|
||||
for label, value in (
|
||||
("tenant id", self.tenant_id),
|
||||
("account id", self.account_id),
|
||||
("purpose", self.purpose),
|
||||
("minimum level", self.minimum_level),
|
||||
("evidence reference", self.evidence_ref),
|
||||
):
|
||||
_require_text(value, label)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AssuranceDecision:
|
||||
allowed: bool
|
||||
reason: str
|
||||
assurance_level: str | None = None
|
||||
evidence_ref: str | None = None
|
||||
verified_at: datetime | None = None
|
||||
expires_at: datetime | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
contract_version: str = IDENTITY_TRUST_CONTRACT_VERSION
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdentityTrustDirectory(Protocol):
|
||||
def register_device_key(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: DeviceKeyRegistration,
|
||||
) -> DeviceKeyRef: ...
|
||||
|
||||
def revoke_device_key(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
key_id: str,
|
||||
expected_epoch: int,
|
||||
reason: str,
|
||||
) -> DeviceKeyRef: ...
|
||||
|
||||
def list_device_keys(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
active_only: bool = True,
|
||||
) -> tuple[DeviceKeyRef, ...]: ...
|
||||
|
||||
def rotate_epoch(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: KeyEpochRotationRequest,
|
||||
) -> KeyEpochRef: ...
|
||||
|
||||
def resolve_epoch(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject_kind: TrustSubjectKind,
|
||||
subject_id: str,
|
||||
epoch: int | None = None,
|
||||
) -> KeyEpochRef | None: ...
|
||||
|
||||
def decide_key_access(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: KeyAccessRequest,
|
||||
) -> KeyAccessDecision: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdentityTrustAssurance(Protocol):
|
||||
def verify_assurance(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: AssuranceCheckRequest,
|
||||
) -> AssuranceDecision: ...
|
||||
|
||||
|
||||
def identity_trust_directory(
|
||||
registry: object | None,
|
||||
) -> IdentityTrustDirectory | None:
|
||||
capability = _capability(registry, CAPABILITY_IDENTITY_TRUST_DIRECTORY)
|
||||
return capability if isinstance(capability, IdentityTrustDirectory) else None
|
||||
|
||||
|
||||
def identity_trust_assurance(
|
||||
registry: object | None,
|
||||
) -> IdentityTrustAssurance | None:
|
||||
capability = _capability(registry, CAPABILITY_IDENTITY_TRUST_ASSURANCE)
|
||||
return capability if isinstance(capability, IdentityTrustAssurance) else None
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
def _validate_contract(value: str) -> None:
|
||||
if value != IDENTITY_TRUST_CONTRACT_VERSION:
|
||||
raise ValueError("Unsupported identity-trust contract version")
|
||||
|
||||
|
||||
def _require_text(value: str, label: str) -> None:
|
||||
if not value.strip():
|
||||
raise ValueError(f"{label.capitalize()} is required")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AssuranceCheckRequest",
|
||||
"AssuranceDecision",
|
||||
"CAPABILITY_IDENTITY_TRUST_ASSURANCE",
|
||||
"CAPABILITY_IDENTITY_TRUST_DIRECTORY",
|
||||
"DeviceKeyRef",
|
||||
"DeviceKeyRegistration",
|
||||
"IdentityTrustAssurance",
|
||||
"IdentityTrustDirectory",
|
||||
"KeyAccessDecision",
|
||||
"KeyAccessRequest",
|
||||
"KeyEpochRef",
|
||||
"KeyEpochRotationRequest",
|
||||
"identity_trust_assurance",
|
||||
"identity_trust_directory",
|
||||
]
|
||||
@@ -1,16 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
IDM_MODULE_ID = "idm"
|
||||
CAPABILITY_IDM_DIRECTORY = "idm.directory"
|
||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS = "idm.function_assignments"
|
||||
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE = "idm.assignment_lifecycle"
|
||||
CAPABILITY_IDM_RELATIONSHIPS = "idm.relationships"
|
||||
|
||||
IdmStatus = Literal["active", "inactive", "suspended"]
|
||||
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
|
||||
TypedGroupStatus = Literal["active", "inactive"]
|
||||
IdentityRelationshipStatus = Literal["active", "revoked"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -30,6 +35,90 @@ class OrganizationFunctionAssignmentRef:
|
||||
status: IdmStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationFunctionIncumbencyRef:
|
||||
tenant_id: str
|
||||
function_id: str
|
||||
assignments: tuple[OrganizationFunctionAssignmentRef, ...] = ()
|
||||
function_active: bool = True
|
||||
|
||||
@property
|
||||
def vacant(self) -> bool:
|
||||
return not self.assignments
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TypedGroupRef:
|
||||
"""Provider-neutral IDM group fact scoped to one tenant."""
|
||||
|
||||
id: str
|
||||
tenant_id: str
|
||||
key: str
|
||||
name: str
|
||||
group_type: str
|
||||
description: str | None = None
|
||||
status: TypedGroupStatus = "active"
|
||||
source_provider: str = "local"
|
||||
source_resource_type: str | None = None
|
||||
source_resource_id: str | None = None
|
||||
source_revision: str | None = None
|
||||
properties: Mapping[str, object] = field(default_factory=dict)
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
revision: int = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IdentityRelationshipRef:
|
||||
"""An effective-dated relationship from an identity to a typed target."""
|
||||
|
||||
id: str
|
||||
tenant_id: str
|
||||
relationship_kind: str
|
||||
subject_identity_id: str
|
||||
target_group_id: str | None = None
|
||||
related_identity_id: str | None = None
|
||||
role: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
status: IdentityRelationshipStatus = "active"
|
||||
revoked_at: datetime | None = None
|
||||
revoked_by: str | None = None
|
||||
revocation_reason: str | None = None
|
||||
source_provider: str = "local"
|
||||
source_resource_type: str | None = None
|
||||
source_resource_id: str | None = None
|
||||
source_revision: str | None = None
|
||||
properties: Mapping[str, object] = field(default_factory=dict)
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
revision: int = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IdentityRelationshipDecisionRef:
|
||||
relationship: IdentityRelationshipRef
|
||||
included: bool
|
||||
code: str
|
||||
explanation: str
|
||||
identity_status: IdmStatus | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TypedGroupMembershipResolutionRef:
|
||||
group: TypedGroupRef
|
||||
effective_at: datetime
|
||||
decisions: tuple[IdentityRelationshipDecisionRef, ...] = ()
|
||||
|
||||
@property
|
||||
def identity_ids(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
item.relationship.subject_identity_id
|
||||
for item in self.decisions
|
||||
if item.included
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdmDirectory(Protocol):
|
||||
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
||||
@@ -40,6 +129,7 @@ class IdmDirectory(Protocol):
|
||||
identity_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||
...
|
||||
|
||||
@@ -48,5 +138,153 @@ class IdmDirectory(Protocol):
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||
...
|
||||
|
||||
def organization_function_assignments_for_identities(
|
||||
self,
|
||||
identity_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
|
||||
...
|
||||
|
||||
def organization_function_assignments_for_accounts(
|
||||
self,
|
||||
account_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdmFunctionAssignmentDirectory(Protocol):
|
||||
"""Reverse lookup for effective incumbency and vacancy decisions."""
|
||||
|
||||
def organization_function_assignments_for_function(
|
||||
self,
|
||||
function_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||
...
|
||||
|
||||
def organization_function_incumbencies(
|
||||
self,
|
||||
function_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Mapping[str, OrganizationFunctionIncumbencyRef]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdmRelationshipDirectory(Protocol):
|
||||
"""Tenant-safe forward/reverse lookup for typed IDM relationships."""
|
||||
|
||||
def get_typed_group(
|
||||
self,
|
||||
group_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> TypedGroupRef | None:
|
||||
...
|
||||
|
||||
def list_typed_groups(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
query: str | None = None,
|
||||
group_types: Sequence[str] = (),
|
||||
include_inactive: bool = False,
|
||||
limit: int = 100,
|
||||
) -> Sequence[TypedGroupRef]:
|
||||
...
|
||||
|
||||
def identity_relationships_for_identity(
|
||||
self,
|
||||
identity_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
relationship_kinds: Sequence[str] = (),
|
||||
) -> Sequence[IdentityRelationshipRef]:
|
||||
...
|
||||
|
||||
def identity_relationships_for_identities(
|
||||
self,
|
||||
identity_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
relationship_kinds: Sequence[str] = (),
|
||||
) -> Mapping[str, Sequence[IdentityRelationshipRef]]:
|
||||
...
|
||||
|
||||
def identity_relationships_for_group(
|
||||
self,
|
||||
group_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
relationship_kinds: Sequence[str] = (),
|
||||
) -> Sequence[IdentityRelationshipRef]:
|
||||
...
|
||||
|
||||
def identity_relationships_for_groups(
|
||||
self,
|
||||
group_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
relationship_kinds: Sequence[str] = (),
|
||||
) -> Mapping[str, Sequence[IdentityRelationshipRef]]:
|
||||
...
|
||||
|
||||
def resolve_typed_group_memberships(
|
||||
self,
|
||||
group_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
relationship_kinds: Sequence[str] = ("member",),
|
||||
) -> Mapping[str, TypedGroupMembershipResolutionRef]:
|
||||
...
|
||||
|
||||
@runtime_checkable
|
||||
class IdmAssignmentLifecycle(Protocol):
|
||||
"""Worker boundary for time-driven function-assignment transitions."""
|
||||
|
||||
def process_expired(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
limit: int = 100,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
|
||||
def idm_assignment_lifecycle(
|
||||
registry: object | None,
|
||||
) -> IdmAssignmentLifecycle | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, IdmAssignmentLifecycle)
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -90,7 +90,9 @@ class _ConfigIssueCollector:
|
||||
def add(self, level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
|
||||
if self.strict and level == "warning":
|
||||
level = "error"
|
||||
self.issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
|
||||
self.issues.append(
|
||||
ConfigIssue(level=level, key=key, message=message, action=action)
|
||||
)
|
||||
|
||||
|
||||
_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
|
||||
@@ -120,9 +122,15 @@ def generate_master_key() -> str:
|
||||
return Fernet.generate_key().decode("ascii")
|
||||
|
||||
|
||||
def env_template(*, profile: str = "self-hosted", generate_secrets: bool = False) -> str:
|
||||
def env_template(
|
||||
*, profile: str = "self-hosted", generate_secrets: bool = False
|
||||
) -> str:
|
||||
clean_profile = normalize_install_profile(profile)
|
||||
master_key = generate_master_key() if generate_secrets else "<generate-with-govoplan-config-env-template-generate-secrets>"
|
||||
master_key = (
|
||||
generate_master_key()
|
||||
if generate_secrets
|
||||
else "<generate-with-govoplan-config-env-template-generate-secrets>"
|
||||
)
|
||||
if clean_profile == "production-like":
|
||||
return _production_like_env_template(master_key)
|
||||
return _self_hosted_env_template(master_key)
|
||||
@@ -144,14 +152,19 @@ def validate_runtime_configuration(
|
||||
_validate_async_and_auth_settings(env, runtime, collector)
|
||||
_validate_cors_settings(env, runtime, collector)
|
||||
_validate_file_storage_settings(env, runtime, collector)
|
||||
_validate_shared_state_settings(env, collector)
|
||||
_validate_outbound_connector_policy(env, runtime, collector)
|
||||
_validate_module_catalog_trust(env, runtime, collector)
|
||||
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
|
||||
|
||||
|
||||
def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _RuntimeProfile:
|
||||
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV"))
|
||||
production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"}
|
||||
clean_profile = normalize_install_profile(
|
||||
profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV")
|
||||
)
|
||||
production = clean_profile in _PRODUCTION_PROFILES or env.get(
|
||||
"APP_ENV", ""
|
||||
).strip().lower() in {"prod", "production"}
|
||||
production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES
|
||||
return _RuntimeProfile(
|
||||
name=clean_profile,
|
||||
@@ -161,103 +174,441 @@ def _runtime_profile(env: Mapping[str, str], *, profile: str | None) -> _Runtime
|
||||
)
|
||||
|
||||
|
||||
def _validate_app_env(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
def _validate_app_env(
|
||||
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||
) -> None:
|
||||
app_env = _clean(env.get("APP_ENV"))
|
||||
if not app_env and runtime.production_like:
|
||||
collector.add("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.")
|
||||
collector.add(
|
||||
"error",
|
||||
"APP_ENV",
|
||||
"APP_ENV is missing for a production-like install.",
|
||||
"Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.",
|
||||
)
|
||||
elif app_env.lower() in {"dev", "test", "local"} and runtime.production_like:
|
||||
collector.add("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
|
||||
collector.add(
|
||||
"error",
|
||||
"APP_ENV",
|
||||
f"APP_ENV={app_env!r} is not valid for profile {runtime.name!r}.",
|
||||
"Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_database_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
def _validate_database_settings(
|
||||
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||
) -> None:
|
||||
database_url = _clean(env.get("DATABASE_URL"))
|
||||
if not database_url:
|
||||
collector.add("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
|
||||
collector.add(
|
||||
"error",
|
||||
"DATABASE_URL",
|
||||
"DATABASE_URL is missing.",
|
||||
"Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.",
|
||||
)
|
||||
return
|
||||
backend = _database_backend(database_url)
|
||||
if backend is None:
|
||||
collector.add("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
|
||||
collector.add(
|
||||
"error",
|
||||
"DATABASE_URL",
|
||||
"DATABASE_URL is not a valid SQLAlchemy URL.",
|
||||
"Use a value like postgresql+psycopg://user:password@host:5432/database.",
|
||||
)
|
||||
return
|
||||
if backend == "sqlite" and runtime.production_like:
|
||||
collector.add("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
|
||||
collector.add(
|
||||
"error",
|
||||
"DATABASE_URL",
|
||||
"SQLite is only supported for disposable local development.",
|
||||
"Use PostgreSQL for production-like and self-hosted installs.",
|
||||
)
|
||||
elif backend != "postgresql" and runtime.production:
|
||||
collector.add("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.")
|
||||
collector.add(
|
||||
"warning",
|
||||
"DATABASE_URL",
|
||||
f"Database backend {backend!r} is not the preferred production target.",
|
||||
"Use PostgreSQL unless this deployment has an explicit support decision.",
|
||||
)
|
||||
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
|
||||
collector.add("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.")
|
||||
collector.add(
|
||||
"warning",
|
||||
"GOVOPLAN_DATABASE_URL_PGTOOLS",
|
||||
"PostgreSQL backup/restore tools URL is missing.",
|
||||
"Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_master_key(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
def _validate_master_key(
|
||||
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||
) -> None:
|
||||
master_key = _clean(env.get("MASTER_KEY_B64"))
|
||||
if not master_key and not runtime.local:
|
||||
collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.")
|
||||
collector.add(
|
||||
"error",
|
||||
"MASTER_KEY_B64",
|
||||
"MASTER_KEY_B64 is required outside local dev/test.",
|
||||
"Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.",
|
||||
)
|
||||
return
|
||||
if not master_key:
|
||||
return
|
||||
error = _master_key_error(master_key)
|
||||
if error:
|
||||
collector.add("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.")
|
||||
collector.add(
|
||||
"error",
|
||||
"MASTER_KEY_B64",
|
||||
error,
|
||||
"Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.",
|
||||
)
|
||||
elif "change-me" in master_key.lower() or "generate" in master_key.lower():
|
||||
collector.add("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
|
||||
collector.add(
|
||||
"error",
|
||||
"MASTER_KEY_B64",
|
||||
"MASTER_KEY_B64 still looks like a placeholder.",
|
||||
"Generate a real deployment key and store it outside git.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_enabled_modules(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
def _validate_enabled_modules(
|
||||
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||
) -> None:
|
||||
enabled_modules = _csv(env.get("ENABLED_MODULES"))
|
||||
if not enabled_modules and runtime.production_like:
|
||||
collector.add("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
|
||||
collector.add(
|
||||
"error",
|
||||
"ENABLED_MODULES",
|
||||
"ENABLED_MODULES is missing.",
|
||||
"Set ENABLED_MODULES explicitly so startup module composition is intentional.",
|
||||
)
|
||||
elif "access" not in enabled_modules and runtime.production_like:
|
||||
collector.add("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.")
|
||||
collector.add(
|
||||
"error",
|
||||
"ENABLED_MODULES",
|
||||
"The access module is not enabled.",
|
||||
"Include `access` unless this deployment has a replacement auth/principal provider.",
|
||||
)
|
||||
elif enabled_modules and "admin" not in enabled_modules and runtime.production_like:
|
||||
collector.add("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.")
|
||||
collector.add(
|
||||
"warning",
|
||||
"ENABLED_MODULES",
|
||||
"The admin module is not enabled.",
|
||||
"Keep `admin` enabled for operator UI unless this is a deliberately headless install.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_async_and_auth_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
def _validate_async_and_auth_settings(
|
||||
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||
) -> None:
|
||||
if _truthy(env.get("CELERY_ENABLED")) and not _clean(env.get("REDIS_URL")):
|
||||
collector.add("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
|
||||
collector.add(
|
||||
"error",
|
||||
"REDIS_URL",
|
||||
"CELERY_ENABLED=true but REDIS_URL is missing.",
|
||||
"Set REDIS_URL to the Redis broker/result backend used by workers.",
|
||||
)
|
||||
if runtime.production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
|
||||
collector.add("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.")
|
||||
collector.add(
|
||||
"error",
|
||||
"DEV_BOOTSTRAP_ENABLED",
|
||||
"Development bootstrap is enabled in production.",
|
||||
"Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.",
|
||||
)
|
||||
if runtime.production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
|
||||
collector.add("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
|
||||
collector.add(
|
||||
"error",
|
||||
"AUTH_COOKIE_SECURE",
|
||||
"Secure auth cookies are disabled for production.",
|
||||
"Set AUTH_COOKIE_SECURE=true behind HTTPS.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
def _validate_cors_settings(
|
||||
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||
) -> None:
|
||||
cors_origins = _csv(env.get("CORS_ORIGINS"))
|
||||
if runtime.production_like and not cors_origins:
|
||||
collector.add("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
|
||||
collector.add(
|
||||
"error",
|
||||
"CORS_ORIGINS",
|
||||
"CORS_ORIGINS is missing.",
|
||||
"Set CORS_ORIGINS to the exact WebUI origin or origins.",
|
||||
)
|
||||
elif "*" in cors_origins and runtime.production_like:
|
||||
collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
|
||||
collector.add(
|
||||
"error",
|
||||
"CORS_ORIGINS",
|
||||
"Wildcard CORS is not allowed for production-like installs.",
|
||||
"Replace `*` with exact HTTPS/WebUI origins.",
|
||||
)
|
||||
elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
|
||||
collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
|
||||
collector.add(
|
||||
"warning",
|
||||
"CORS_ORIGINS",
|
||||
"CORS_ORIGINS still contains only local development origins.",
|
||||
"Set CORS_ORIGINS to the deployed WebUI origin.",
|
||||
)
|
||||
trusted_hosts = _csv(env.get("GOVOPLAN_TRUSTED_HOSTS"))
|
||||
if runtime.production_like and not trusted_hosts:
|
||||
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Trusted HTTP hosts are not configured.", "Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.")
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_TRUSTED_HOSTS",
|
||||
"Trusted HTTP hosts are not configured.",
|
||||
"Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.",
|
||||
)
|
||||
elif "*" in trusted_hosts and runtime.production_like:
|
||||
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Wildcard trusted hosts are not allowed for production-like installs.", "Replace `*` with exact host names or narrowly scoped `*.example.org` entries.")
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_TRUSTED_HOSTS",
|
||||
"Wildcard trusted hosts are not allowed for production-like installs.",
|
||||
"Replace `*` with exact host names or narrowly scoped `*.example.org` entries.",
|
||||
)
|
||||
forwarded_allow_ips = _csv(env.get("FORWARDED_ALLOW_IPS"))
|
||||
if runtime.production_like and "*" in forwarded_allow_ips:
|
||||
collector.add("error", "FORWARDED_ALLOW_IPS", "Proxy headers must not be trusted from every address.", "Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.")
|
||||
collector.add(
|
||||
"error",
|
||||
"FORWARDED_ALLOW_IPS",
|
||||
"Proxy headers must not be trusted from every address.",
|
||||
"Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
def _validate_file_storage_settings(
|
||||
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||
) -> None:
|
||||
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
|
||||
deployment_managed_raw = _clean(
|
||||
env.get("FILE_STORAGE_S3_DEPLOYMENT_MANAGED")
|
||||
).lower()
|
||||
endpoint_trusted_raw = _clean(env.get("FILE_STORAGE_S3_ENDPOINT_TRUSTED")).lower()
|
||||
if deployment_managed_raw and deployment_managed_raw not in {
|
||||
"true",
|
||||
"false",
|
||||
"1",
|
||||
"0",
|
||||
"yes",
|
||||
"no",
|
||||
"on",
|
||||
"off",
|
||||
}:
|
||||
collector.add(
|
||||
"error",
|
||||
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
|
||||
"Managed S3 trust must be an explicit boolean.",
|
||||
"Set FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false, or let the supported installer manage Garage.",
|
||||
)
|
||||
deployment_managed = _truthy(deployment_managed_raw)
|
||||
if endpoint_trusted_raw and endpoint_trusted_raw not in {
|
||||
"true",
|
||||
"false",
|
||||
"1",
|
||||
"0",
|
||||
"yes",
|
||||
"no",
|
||||
"on",
|
||||
"off",
|
||||
}:
|
||||
collector.add(
|
||||
"error",
|
||||
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
|
||||
"External S3 endpoint trust must be an explicit boolean.",
|
||||
"Set it only for a deployment-controlled HTTPS storage origin.",
|
||||
)
|
||||
endpoint_trusted = _truthy(endpoint_trusted_raw)
|
||||
if storage_backend == "local":
|
||||
if deployment_managed or endpoint_trusted:
|
||||
collector.add(
|
||||
"error",
|
||||
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
|
||||
"S3 endpoint trust cannot be enabled for local file storage.",
|
||||
"Disable both S3 trust settings while FILE_STORAGE_BACKEND=local.",
|
||||
)
|
||||
_validate_local_file_storage(env, runtime, collector)
|
||||
elif storage_backend == "s3":
|
||||
_validate_s3_file_storage(env, collector)
|
||||
_validate_s3_file_storage(
|
||||
env,
|
||||
collector,
|
||||
deployment_managed=deployment_managed,
|
||||
endpoint_trusted=endpoint_trusted,
|
||||
)
|
||||
else:
|
||||
collector.add("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
|
||||
collector.add(
|
||||
"error",
|
||||
"FILE_STORAGE_BACKEND",
|
||||
f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.",
|
||||
"Use `local` or `s3`.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_local_file_storage(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
def _validate_local_file_storage(
|
||||
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||
) -> None:
|
||||
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and runtime.production_like:
|
||||
collector.add("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
|
||||
collector.add(
|
||||
"error",
|
||||
"FILE_STORAGE_LOCAL_ROOT",
|
||||
"Local file storage root is missing.",
|
||||
"Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.",
|
||||
)
|
||||
elif runtime.production:
|
||||
collector.add("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
|
||||
collector.add(
|
||||
"warning",
|
||||
"FILE_STORAGE_BACKEND",
|
||||
"Production is configured for local file storage.",
|
||||
"Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCollector) -> None:
|
||||
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"):
|
||||
def _validate_s3_file_storage(
|
||||
env: Mapping[str, str],
|
||||
collector: _ConfigIssueCollector,
|
||||
*,
|
||||
deployment_managed: bool,
|
||||
endpoint_trusted: bool,
|
||||
) -> None:
|
||||
for key in (
|
||||
"FILE_STORAGE_S3_ENDPOINT_URL",
|
||||
"FILE_STORAGE_S3_REGION",
|
||||
"FILE_STORAGE_S3_ACCESS_KEY_ID",
|
||||
"FILE_STORAGE_S3_SECRET_ACCESS_KEY",
|
||||
"FILE_STORAGE_S3_BUCKET",
|
||||
):
|
||||
if not _clean(env.get(key)):
|
||||
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
|
||||
collector.add(
|
||||
"error",
|
||||
key,
|
||||
f"{key} is required when FILE_STORAGE_BACKEND=s3.",
|
||||
"Configure all FILE_STORAGE_S3_* settings through deployment secrets.",
|
||||
)
|
||||
if (
|
||||
deployment_managed
|
||||
and _clean(env.get("FILE_STORAGE_S3_ENDPOINT_URL")) != "http://garage:3900"
|
||||
):
|
||||
collector.add(
|
||||
"error",
|
||||
"FILE_STORAGE_S3_ENDPOINT_URL",
|
||||
"Installer-managed S3 trust is restricted to http://garage:3900.",
|
||||
"Use the exact managed Garage endpoint or disable FILE_STORAGE_S3_DEPLOYMENT_MANAGED.",
|
||||
)
|
||||
if deployment_managed and endpoint_trusted:
|
||||
collector.add(
|
||||
"error",
|
||||
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
|
||||
"Managed Garage trust and external endpoint trust are mutually exclusive.",
|
||||
"Use installer-managed Garage trust or one explicit external endpoint.",
|
||||
)
|
||||
if not deployment_managed and not endpoint_trusted:
|
||||
collector.add(
|
||||
"error",
|
||||
"FILE_STORAGE_S3_ENDPOINT_TRUSTED",
|
||||
"External S3 storage requires an explicit deployment trust decision.",
|
||||
"Set FILE_STORAGE_S3_ENDPOINT_TRUSTED=true only for a deployment-controlled HTTPS origin.",
|
||||
)
|
||||
endpoint = _clean(env.get("FILE_STORAGE_S3_ENDPOINT_URL"))
|
||||
if endpoint_trusted and not endpoint.lower().startswith("https://"):
|
||||
collector.add(
|
||||
"error",
|
||||
"FILE_STORAGE_S3_ENDPOINT_URL",
|
||||
"Deployment-trusted external S3 storage must use HTTPS.",
|
||||
"Use an HTTPS storage origin with certificate verification.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_shared_state_settings(
|
||||
env: Mapping[str, str],
|
||||
collector: _ConfigIssueCollector,
|
||||
) -> None:
|
||||
state_profile = (_clean(env.get("GOVOPLAN_STATE_PROFILE")) or "local").lower()
|
||||
if state_profile not in {"local", "host-shared", "shared"}:
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_STATE_PROFILE",
|
||||
f"Unsupported state profile {state_profile!r}.",
|
||||
"Use `local` for one process per role, `host-shared` for one Compose host, or `shared` for a multi-host stateless tier.",
|
||||
)
|
||||
return
|
||||
try:
|
||||
api_replicas = int(_clean(env.get("GOVOPLAN_EXPECTED_API_REPLICAS")) or "1")
|
||||
worker_replicas = int(
|
||||
_clean(env.get("GOVOPLAN_EXPECTED_WORKER_REPLICAS")) or "0"
|
||||
)
|
||||
except ValueError:
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_EXPECTED_API_REPLICAS",
|
||||
"Expected replica counts must be integers.",
|
||||
"Set GOVOPLAN_EXPECTED_API_REPLICAS and GOVOPLAN_EXPECTED_WORKER_REPLICAS to non-negative integers.",
|
||||
)
|
||||
return
|
||||
if api_replicas < 1 or worker_replicas < 0:
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_EXPECTED_API_REPLICAS",
|
||||
"Expected replica counts are outside their supported range.",
|
||||
"Configure at least one API replica and zero or more worker replicas.",
|
||||
)
|
||||
if state_profile == "local":
|
||||
if api_replicas > 1 or worker_replicas > 1:
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_STATE_PROFILE",
|
||||
"A local-state profile cannot safely run replicated API or worker nodes.",
|
||||
"Use `host-shared` with one shared host volume, or `shared` with PostgreSQL, Redis, and S3-compatible object storage.",
|
||||
)
|
||||
return
|
||||
installation_id = _clean(env.get("GOVOPLAN_INSTALLATION_ID"))
|
||||
if not installation_id or (
|
||||
state_profile == "shared" and installation_id == "govoplan-local"
|
||||
):
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_INSTALLATION_ID",
|
||||
"Shared-state deployments require a stable installation identifier.",
|
||||
"Set one immutable deployment-wide GOVOPLAN_INSTALLATION_ID on every node.",
|
||||
)
|
||||
if _database_backend(_clean(env.get("DATABASE_URL"))) != "postgresql":
|
||||
collector.add(
|
||||
"error",
|
||||
"DATABASE_URL",
|
||||
"Shared-state deployments require PostgreSQL.",
|
||||
"Point every API, scheduler, and worker node at the same logical PostgreSQL service.",
|
||||
)
|
||||
if not _clean(env.get("REDIS_URL")):
|
||||
collector.add(
|
||||
"error",
|
||||
"REDIS_URL",
|
||||
"Shared-state deployments require a common Redis service.",
|
||||
"Configure the same Redis endpoint for all API and worker nodes.",
|
||||
)
|
||||
if (
|
||||
state_profile == "shared"
|
||||
and (_clean(env.get("FILE_STORAGE_BACKEND")) or "local").lower() != "s3"
|
||||
):
|
||||
collector.add(
|
||||
"error",
|
||||
"FILE_STORAGE_BACKEND",
|
||||
"Shared-state deployments cannot use node-local object storage.",
|
||||
"Set FILE_STORAGE_BACKEND=s3 and configure one shared S3-compatible bucket.",
|
||||
)
|
||||
try:
|
||||
heartbeat = int(_clean(env.get("GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS")) or "15")
|
||||
stale_after = int(
|
||||
_clean(env.get("GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS")) or "60"
|
||||
)
|
||||
except ValueError:
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS",
|
||||
"Runtime heartbeat and stale intervals must be integers.",
|
||||
"Use a heartbeat interval shorter than one third of the stale interval.",
|
||||
)
|
||||
else:
|
||||
if heartbeat < 2 or stale_after < max(10, heartbeat * 3):
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS",
|
||||
"Runtime stale detection leaves insufficient room for missed heartbeats.",
|
||||
"Set stale-after to at least three heartbeat intervals and at least ten seconds.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_outbound_connector_policy(
|
||||
@@ -265,8 +616,19 @@ def _validate_outbound_connector_policy(
|
||||
runtime: _RuntimeProfile,
|
||||
collector: _ConfigIssueCollector,
|
||||
) -> None:
|
||||
private_networks = _clean(env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")).lower()
|
||||
if runtime.production_like and private_networks not in {"true", "false", "1", "0", "yes", "no", "on", "off"}:
|
||||
private_networks = _clean(
|
||||
env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")
|
||||
).lower()
|
||||
if runtime.production_like and private_networks not in {
|
||||
"true",
|
||||
"false",
|
||||
"1",
|
||||
"0",
|
||||
"yes",
|
||||
"no",
|
||||
"on",
|
||||
"off",
|
||||
}:
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
|
||||
@@ -286,13 +648,21 @@ def _validate_outbound_connector_policy(
|
||||
except ValueError:
|
||||
parsed = 0
|
||||
if parsed <= 0:
|
||||
collector.add("error", key, f"{key} must be a positive byte count.", "Use a positive integer byte limit.")
|
||||
collector.add(
|
||||
"error",
|
||||
key,
|
||||
f"{key} must be a positive byte count.",
|
||||
"Use a positive integer byte limit.",
|
||||
)
|
||||
secret_env_names = [
|
||||
item.strip()
|
||||
for item in env.get("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", "").split(",")
|
||||
if item.strip()
|
||||
]
|
||||
if any(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None for item in secret_env_names):
|
||||
if any(
|
||||
re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None
|
||||
for item in secret_env_names
|
||||
):
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
|
||||
@@ -313,14 +683,28 @@ def _validate_outbound_connector_policy(
|
||||
)
|
||||
|
||||
|
||||
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
|
||||
def _validate_module_catalog_trust(
|
||||
env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector
|
||||
) -> None:
|
||||
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(
|
||||
env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG")
|
||||
)
|
||||
if not runtime.production or not catalog_source:
|
||||
return
|
||||
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
|
||||
collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.")
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE",
|
||||
"A module catalog source is configured without a trusted keyring file.",
|
||||
"Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.",
|
||||
)
|
||||
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
|
||||
collector.add("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.")
|
||||
collector.add(
|
||||
"error",
|
||||
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL",
|
||||
"A module catalog source is configured without an approved release channel.",
|
||||
"Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.",
|
||||
)
|
||||
|
||||
|
||||
def _self_hosted_env_template(master_key: str) -> str:
|
||||
@@ -329,6 +713,13 @@ def _self_hosted_env_template(master_key: str) -> str:
|
||||
|
||||
APP_ENV=production
|
||||
GOVOPLAN_INSTALL_PROFILE=self-hosted
|
||||
GOVOPLAN_INSTALLATION_ID=govoplan-production
|
||||
GOVOPLAN_STATE_PROFILE=local
|
||||
GOVOPLAN_RUNTIME_ROLE=api
|
||||
GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS=15
|
||||
GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS=60
|
||||
GOVOPLAN_EXPECTED_API_REPLICAS=1
|
||||
GOVOPLAN_EXPECTED_WORKER_REPLICAS=1
|
||||
MASTER_KEY_B64={master_key}
|
||||
|
||||
DATABASE_URL=postgresql+psycopg://govoplan:change-me@127.0.0.1:5432/govoplan
|
||||
@@ -338,8 +729,11 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
|
||||
|
||||
CELERY_ENABLED=true
|
||||
REDIS_URL=redis://127.0.0.1:6379/0
|
||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||
CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default
|
||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
|
||||
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||
|
||||
# Deployment-wide connector egress policy. Enable private networks only when
|
||||
# this installation intentionally integrates with internal services.
|
||||
@@ -370,6 +764,12 @@ AUTH_COOKIE_DOMAIN=
|
||||
FILE_STORAGE_BACKEND=local
|
||||
FILE_STORAGE_LOCAL_ROOT=/var/lib/govoplan/files
|
||||
FILE_STORAGE_LOCAL_FALLBACK_ROOTS=
|
||||
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
|
||||
FILE_STORAGE_S3_ENDPOINT_TRUSTED=false
|
||||
FILE_ARCHIVE_MAX_ENTRIES=10000
|
||||
FILE_ARCHIVE_MAX_EXPANDED_BYTES=2147483648
|
||||
FILE_ARCHIVE_MAX_EXPANSION_RATIO=100
|
||||
FILE_ARCHIVE_PREVIEW_TTL_SECONDS=1800
|
||||
|
||||
DEV_AUTO_MIGRATE_ENABLED=false
|
||||
DEV_BOOTSTRAP_ENABLED=false
|
||||
@@ -387,6 +787,13 @@ def _production_like_env_template(master_key: str) -> str:
|
||||
|
||||
APP_ENV=staging
|
||||
GOVOPLAN_INSTALL_PROFILE=production-like
|
||||
GOVOPLAN_INSTALLATION_ID=govoplan-production-like
|
||||
GOVOPLAN_STATE_PROFILE=local
|
||||
GOVOPLAN_RUNTIME_ROLE=api
|
||||
GOVOPLAN_RUNTIME_HEARTBEAT_SECONDS=15
|
||||
GOVOPLAN_RUNTIME_STALE_AFTER_SECONDS=60
|
||||
GOVOPLAN_EXPECTED_API_REPLICAS=1
|
||||
GOVOPLAN_EXPECTED_WORKER_REPLICAS=1
|
||||
MASTER_KEY_B64={master_key}
|
||||
|
||||
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_DB=govoplan
|
||||
@@ -403,8 +810,11 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
||||
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
||||
REDIS_URL=redis://127.0.0.1:56379/0
|
||||
CELERY_ENABLED=true
|
||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||
CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default
|
||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
|
||||
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||
|
||||
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
|
||||
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||
@@ -427,6 +837,12 @@ FORWARDED_ALLOW_IPS=127.0.0.1
|
||||
AUTH_COOKIE_SECURE=false
|
||||
FILE_STORAGE_BACKEND=local
|
||||
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
|
||||
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
|
||||
FILE_STORAGE_S3_ENDPOINT_TRUSTED=false
|
||||
FILE_ARCHIVE_MAX_ENTRIES=10000
|
||||
FILE_ARCHIVE_MAX_EXPANDED_BYTES=2147483648
|
||||
FILE_ARCHIVE_MAX_EXPANSION_RATIO=100
|
||||
FILE_ARCHIVE_PREVIEW_TTL_SECONDS=1800
|
||||
DEV_AUTO_MIGRATE_ENABLED=false
|
||||
DEV_BOOTSTRAP_ENABLED=true
|
||||
"""
|
||||
@@ -437,7 +853,11 @@ def _clean(value: str | None) -> str:
|
||||
|
||||
|
||||
def _csv(value: str | None) -> tuple[str, ...]:
|
||||
return tuple(dict.fromkeys(item.strip() for item in str(value or "").split(",") if item.strip()))
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
item.strip() for item in str(value or "").split(",") if item.strip()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _truthy(value: str | None) -> bool:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from threading import RLock
|
||||
|
||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Request, status
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.module_management import ModuleManagementError, REQUIRED_PLATFORM_MODULES, plan_desired_enabled_modules
|
||||
from govoplan_core.core.module_entitlements import (
|
||||
ModuleEntitlementResolutionError,
|
||||
TenantModuleUnavailable,
|
||||
tenant_execution_scope,
|
||||
)
|
||||
from govoplan_core.core.module_lifecycle_recovery import (
|
||||
ModuleLifecycleRecovery,
|
||||
begin_runtime_graph_recovery,
|
||||
canonical_sha256,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.runtime import configure_runtime
|
||||
from govoplan_core.core.workflows import (
|
||||
workflow_definition_contribution_provider,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.server.route_validation import validate_router_can_mount
|
||||
|
||||
|
||||
@@ -23,12 +40,75 @@ class ModuleLifecycleResult:
|
||||
|
||||
|
||||
def require_module_active(module_id: str):
|
||||
def dependency(request: Request) -> None:
|
||||
async def dependency(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
authorization: str | None = Header(default=None),
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
) -> AsyncIterator[None]:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
if isinstance(registry, PlatformRegistry) and registry.has_module(module_id):
|
||||
return
|
||||
if not isinstance(registry, PlatformRegistry) or not registry.has_module(module_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Module is disabled: {module_id}")
|
||||
|
||||
tenant_id: str | None = None
|
||||
if not authorization and not x_api_key and not request.cookies:
|
||||
public_resolver = registry.public_tenant_resolver(module_id)
|
||||
if public_resolver is not None:
|
||||
tenant_id = public_resolver(request, session)
|
||||
if tenant_id is None:
|
||||
yield
|
||||
return
|
||||
else:
|
||||
try:
|
||||
principal = get_api_principal(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code in {
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
}:
|
||||
yield
|
||||
return
|
||||
raise
|
||||
if (
|
||||
not isinstance(principal, ApiPrincipal)
|
||||
or principal.principal.tenant_id is None
|
||||
):
|
||||
yield
|
||||
return
|
||||
tenant_id = principal.principal.tenant_id
|
||||
|
||||
resolver = registry.tenant_entitlement_resolver()
|
||||
try:
|
||||
admission = resolver.require(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
module_id=module_id,
|
||||
work_state="interactive",
|
||||
)
|
||||
except TenantModuleUnavailable as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Module is unavailable in the active tenant: {module_id}",
|
||||
) from exc
|
||||
except (ModuleEntitlementResolutionError, RuntimeError, SQLAlchemyError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Tenant module entitlement could not be resolved.",
|
||||
) from exc
|
||||
request.state.govoplan_module_admission = admission
|
||||
with tenant_execution_scope(
|
||||
resolver,
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
work_state="interactive",
|
||||
):
|
||||
yield
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
@@ -67,6 +147,17 @@ class ModuleLifecycleManager:
|
||||
def mounted_module_ids(self) -> tuple[str, ...]:
|
||||
return tuple(sorted(self._mounted_modules))
|
||||
|
||||
def live_apply_enabled(self) -> bool:
|
||||
configured = getattr(
|
||||
self.settings,
|
||||
"module_live_apply_enabled",
|
||||
None,
|
||||
)
|
||||
if configured is not None:
|
||||
return bool(configured)
|
||||
app_env = str(getattr(self.settings, "app_env", "dev")).casefold()
|
||||
return app_env in {"dev", "development", "local", "test", "testing"}
|
||||
|
||||
def apply_enabled_modules(
|
||||
self,
|
||||
requested_enabled: Sequence[str],
|
||||
@@ -85,13 +176,40 @@ class ModuleLifecycleManager:
|
||||
next_set = set(plan.enabled_modules)
|
||||
activated = tuple(module_id for module_id in plan.enabled_modules if module_id not in previous_set)
|
||||
deactivated = tuple(module_id for module_id in previous if module_id not in next_set)
|
||||
graph_changes = bool(activated or deactivated)
|
||||
recovery: ModuleLifecycleRecovery | None = None
|
||||
if graph_changes or migrate:
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
with get_database().session() as recovery_session:
|
||||
recovery = begin_runtime_graph_recovery(
|
||||
recovery_session,
|
||||
previous_modules=previous,
|
||||
requested_modules=plan.enabled_modules,
|
||||
migrate=migrate,
|
||||
)
|
||||
|
||||
old_manifests = {
|
||||
manifest.id: manifest for manifest in self.registry.manifests()
|
||||
}
|
||||
try:
|
||||
if recovery is not None:
|
||||
recovery.checkpoint(
|
||||
kind="runtime-graph-effect-started",
|
||||
summary="Runtime module graph entered its mutation boundary",
|
||||
evidence={
|
||||
"activated_sha256": canonical_sha256(activated),
|
||||
"deactivated_sha256": canonical_sha256(deactivated),
|
||||
"migrate": migrate,
|
||||
},
|
||||
effect_started=True,
|
||||
)
|
||||
|
||||
if migrate:
|
||||
self._migrate(plan.enabled_modules)
|
||||
|
||||
mounted = tuple(module_id for module_id in plan.enabled_modules if self._mount_module_router(module_id))
|
||||
|
||||
old_manifests = {manifest.id: manifest for manifest in self.registry.manifests()}
|
||||
for module_id in deactivated:
|
||||
hook = old_manifests[module_id].on_deactivate
|
||||
if hook is not None:
|
||||
@@ -105,9 +223,64 @@ class ModuleLifecycleManager:
|
||||
if hook is not None:
|
||||
hook(self.context)
|
||||
|
||||
reconciliation = self.reconcile_workflow_definitions()
|
||||
|
||||
if self._app is not None:
|
||||
self._app.openapi_schema = None
|
||||
|
||||
if recovery is not None:
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
with get_database().session() as recovery_session:
|
||||
recovery.succeed(
|
||||
recovery_session,
|
||||
evidence={
|
||||
"active_graph_sha256": canonical_sha256(
|
||||
self.active_module_ids()
|
||||
),
|
||||
"mounted_graph_sha256": canonical_sha256(
|
||||
self.mounted_module_ids()
|
||||
),
|
||||
"workflow_reconciliation_sha256": canonical_sha256(
|
||||
reconciliation
|
||||
),
|
||||
},
|
||||
commit_projection=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.registry.replace(old_manifests.values())
|
||||
self.configure_runtime()
|
||||
if self._app is not None:
|
||||
self._app.openapi_schema = None
|
||||
if recovery is not None:
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
recovery.unresolved(
|
||||
summary="Runtime graph mutation did not reach verified completion",
|
||||
evidence={
|
||||
"error_type": type(exc).__name__,
|
||||
"previous_graph_sha256": canonical_sha256(previous),
|
||||
"registry_restored": True,
|
||||
"migrate": migrate,
|
||||
},
|
||||
outcome_unknown=migrate,
|
||||
)
|
||||
if not migrate:
|
||||
with get_database().session() as recovery_session:
|
||||
recovery.recovered(
|
||||
recovery_session,
|
||||
evidence={
|
||||
"active_graph_sha256": canonical_sha256(
|
||||
self.active_module_ids()
|
||||
),
|
||||
"previous_graph_restored": (
|
||||
self.active_module_ids() == previous
|
||||
),
|
||||
},
|
||||
summary="Previous runtime module graph was restored",
|
||||
)
|
||||
raise
|
||||
|
||||
return ModuleLifecycleResult(
|
||||
enabled_modules=plan.enabled_modules,
|
||||
activated_modules=activated,
|
||||
@@ -116,6 +289,24 @@ class ModuleLifecycleManager:
|
||||
migrations_applied=migrate,
|
||||
)
|
||||
|
||||
def reconcile_workflow_definitions(self) -> Mapping[str, object]:
|
||||
if not any(
|
||||
manifest.workflow_definitions for manifest in self.registry.manifests()
|
||||
):
|
||||
return {"skipped": True, "reason": "no_contributions"}
|
||||
provider = workflow_definition_contribution_provider(self.registry)
|
||||
if provider is None:
|
||||
return {"skipped": True, "reason": "provider_unavailable"}
|
||||
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
with get_database().session() as session:
|
||||
result = provider.reconcile(session)
|
||||
session.commit()
|
||||
if self._app is not None:
|
||||
self._app.state.govoplan_workflow_reconciliation = dict(result)
|
||||
return dict(result)
|
||||
|
||||
def _mount_module_router(self, module_id: str) -> bool:
|
||||
if module_id in self._mounted_modules:
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox"
|
||||
CAPABILITY_MAIL_NOTIFICATION_DELIVERY = "mail.notificationDelivery"
|
||||
CAPABILITY_MAIL_BOUNCE_PROCESSING = "mail.bounce_processing"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NotificationMailDeliveryRequest:
|
||||
tenant_id: str
|
||||
notification_id: str
|
||||
recipient: str
|
||||
subject: str
|
||||
body_text: str
|
||||
body_html: str | None = None
|
||||
action_url: str | None = None
|
||||
mail_profile_id: str | None = None
|
||||
from_address: str | None = None
|
||||
smtp_server_id: str | None = None
|
||||
smtp_credential_id: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class NotificationMailDeliveryProvider(Protocol):
|
||||
"""Mail-owned durable submission boundary for notification email."""
|
||||
|
||||
def submit_notification_mail(
|
||||
self,
|
||||
session: object,
|
||||
request: NotificationMailDeliveryRequest,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
@runtime_checkable
|
||||
class MailDeliveryOutboxProvider(Protocol):
|
||||
"""Stable worker boundary for Mail-owned external delivery effects."""
|
||||
|
||||
def dispatch_due(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
limit: int = 25,
|
||||
worker_id: str | None = None,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
def purge_expired(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
limit: int = 250,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MailBounceObservationRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
profile_id: str
|
||||
folder: str
|
||||
uid: str
|
||||
original_message_id: str | None
|
||||
command_id: str | None
|
||||
recipient: str | None
|
||||
action: str
|
||||
status_code: str | None
|
||||
diagnostic: str | None
|
||||
permanent: bool
|
||||
observed_at: datetime
|
||||
matched: bool
|
||||
evidence: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MailBounceProcessingProvider(Protocol):
|
||||
"""Mail-owned DSN ingestion and durable correlation boundary."""
|
||||
|
||||
def process_raw_message(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
profile_id: str,
|
||||
folder: str,
|
||||
uid: str,
|
||||
raw_message: bytes,
|
||||
) -> tuple[MailBounceObservationRef, ...]:
|
||||
...
|
||||
|
||||
def scan_due(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
def observations_for_commands(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
command_ids: tuple[str, ...],
|
||||
) -> Mapping[str, tuple[MailBounceObservationRef, ...]]:
|
||||
...
|
||||
|
||||
|
||||
def notification_mail_delivery_provider(
|
||||
registry: object | None,
|
||||
) -> NotificationMailDeliveryProvider | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
|
||||
):
|
||||
return None
|
||||
provider = registry.require_capability(CAPABILITY_MAIL_NOTIFICATION_DELIVERY)
|
||||
if not isinstance(provider, NotificationMailDeliveryProvider):
|
||||
raise TypeError(
|
||||
"mail.notificationDelivery provider does not implement "
|
||||
"NotificationMailDeliveryProvider"
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
def mail_bounce_processing_provider(
|
||||
registry: object | None,
|
||||
) -> MailBounceProcessingProvider | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING)
|
||||
):
|
||||
return None
|
||||
provider = registry.require_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING)
|
||||
if not isinstance(provider, MailBounceProcessingProvider):
|
||||
raise TypeError(
|
||||
"mail.bounce_processing provider does not implement "
|
||||
"MailBounceProcessingProvider"
|
||||
)
|
||||
return provider
|
||||
@@ -0,0 +1,846 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Iterable, Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from threading import RLock
|
||||
from time import monotonic
|
||||
from typing import Any, Literal
|
||||
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
|
||||
|
||||
MODULE_ENTITLEMENTS_KEY = "module_entitlements"
|
||||
MODULE_ENTITLEMENT_SCHEMA_VERSION = 1
|
||||
TENANT_PROTECTED_MODULES = ("access", "admin")
|
||||
|
||||
|
||||
class ModuleEntitlementError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class ModuleEntitlementConflict(ModuleEntitlementError):
|
||||
pass
|
||||
|
||||
|
||||
class ModuleEntitlementResolutionError(ModuleEntitlementError):
|
||||
pass
|
||||
|
||||
|
||||
class TenantModuleUnavailable(ModuleEntitlementError):
|
||||
def __init__(self, admission: "TenantModuleAdmission") -> None:
|
||||
self.admission = admission
|
||||
super().__init__(admission.reason)
|
||||
|
||||
|
||||
class TenantModuleOperatorActionRequired(ModuleEntitlementError):
|
||||
def __init__(self, admission: "TenantModuleAdmission") -> None:
|
||||
self.admission = admission
|
||||
super().__init__(admission.reason)
|
||||
|
||||
|
||||
TenantWorkState = Literal["interactive", "new", "accepted"]
|
||||
TenantAdmissionDisposition = Literal[
|
||||
"allowed",
|
||||
"rejected",
|
||||
"operator_action_required",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantModuleItem:
|
||||
id: str
|
||||
name: str
|
||||
dependencies: tuple[str, ...]
|
||||
runtime_active: bool
|
||||
availability: str
|
||||
selected: bool
|
||||
effective: bool
|
||||
forced: bool
|
||||
derived_dependency: bool
|
||||
tenant_can_toggle: bool
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantModuleEntitlementState:
|
||||
revision: int
|
||||
configured: bool
|
||||
available_modules: tuple[str, ...]
|
||||
forced_modules: tuple[str, ...]
|
||||
selected_modules: tuple[str, ...]
|
||||
effective_modules: tuple[str, ...]
|
||||
derived_dependencies: tuple[str, ...]
|
||||
modules: tuple[TenantModuleItem, ...]
|
||||
diagnostics: tuple[dict[str, str], ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantModuleAdmission:
|
||||
tenant_id: str
|
||||
module_id: str
|
||||
revision: int
|
||||
work_state: TenantWorkState
|
||||
allowed: bool
|
||||
disposition: TenantAdmissionDisposition
|
||||
reason: str
|
||||
|
||||
def payload(self) -> dict[str, object]:
|
||||
return {
|
||||
"tenant_id": self.tenant_id,
|
||||
"module_id": self.module_id,
|
||||
"entitlement_revision": self.revision,
|
||||
"work_state": self.work_state,
|
||||
"allowed": self.allowed,
|
||||
"disposition": self.disposition,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CachedTenantEntitlement:
|
||||
expires_at: float
|
||||
tenant_active: bool
|
||||
state: TenantModuleEntitlementState
|
||||
|
||||
|
||||
class TenantModuleEntitlementResolver:
|
||||
"""Resolve tenant-effective modules with bounded process-local caching.
|
||||
|
||||
Cache entries are explicitly invalidated by local mutations and expire
|
||||
quickly so changes made on another application node become authoritative
|
||||
without requiring a database lookup for every capability call.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
registry: object,
|
||||
*,
|
||||
ttl_seconds: float = 5.0,
|
||||
max_entries: int = 2048,
|
||||
) -> None:
|
||||
self._registry = registry
|
||||
self._ttl_seconds = max(0.0, min(float(ttl_seconds), 300.0))
|
||||
self._max_entries = max(1, int(max_entries))
|
||||
self._cache: OrderedDict[str, _CachedTenantEntitlement] = OrderedDict()
|
||||
self._lock = RLock()
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
session: object,
|
||||
tenant_id: str,
|
||||
) -> TenantModuleEntitlementState:
|
||||
normalized_tenant_id = str(tenant_id or "").strip()
|
||||
if not normalized_tenant_id:
|
||||
raise ModuleEntitlementResolutionError("Tenant id is required")
|
||||
|
||||
cached = self._cached(normalized_tenant_id)
|
||||
if cached is not None:
|
||||
if not cached.tenant_active:
|
||||
raise ModuleEntitlementResolutionError(
|
||||
f"Tenant is inactive: {normalized_tenant_id}"
|
||||
)
|
||||
return cached.state
|
||||
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
getter = getattr(session, "get", None)
|
||||
if not callable(getter):
|
||||
raise ModuleEntitlementResolutionError(
|
||||
"Tenant module entitlement resolution requires a database session"
|
||||
)
|
||||
tenant = getter(Tenant, normalized_tenant_id)
|
||||
if tenant is None:
|
||||
raise ModuleEntitlementResolutionError(
|
||||
f"Tenant is unavailable: {normalized_tenant_id}"
|
||||
)
|
||||
state = self._state_from_settings(getattr(tenant, "settings", None))
|
||||
tenant_active = bool(getattr(tenant, "is_active", False))
|
||||
self._store(normalized_tenant_id, tenant_active=tenant_active, state=state)
|
||||
if not tenant_active:
|
||||
raise ModuleEntitlementResolutionError(
|
||||
f"Tenant is inactive: {normalized_tenant_id}"
|
||||
)
|
||||
return state
|
||||
|
||||
def admission(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
module_id: str,
|
||||
work_state: TenantWorkState = "interactive",
|
||||
) -> TenantModuleAdmission:
|
||||
if work_state not in {"interactive", "new", "accepted"}:
|
||||
raise ModuleEntitlementError(f"Unsupported tenant work state: {work_state}")
|
||||
normalized_module_id = str(module_id or "").strip()
|
||||
if not normalized_module_id:
|
||||
raise ModuleEntitlementError("Module id is required")
|
||||
state = self.resolve(session, tenant_id)
|
||||
allowed = normalized_module_id in state.effective_modules
|
||||
if allowed:
|
||||
return TenantModuleAdmission(
|
||||
tenant_id=str(tenant_id),
|
||||
module_id=normalized_module_id,
|
||||
revision=state.revision,
|
||||
work_state=work_state,
|
||||
allowed=True,
|
||||
disposition="allowed",
|
||||
reason="The module is effective for this tenant.",
|
||||
)
|
||||
accepted = work_state == "accepted"
|
||||
return TenantModuleAdmission(
|
||||
tenant_id=str(tenant_id),
|
||||
module_id=normalized_module_id,
|
||||
revision=state.revision,
|
||||
work_state=work_state,
|
||||
allowed=False,
|
||||
disposition=(
|
||||
"operator_action_required" if accepted else "rejected"
|
||||
),
|
||||
reason=(
|
||||
"Accepted durable work was preserved because the owning module "
|
||||
"is no longer effective for this tenant; an operator must resume "
|
||||
"the module or resolve the work explicitly."
|
||||
if accepted
|
||||
else "The module is not effective for this tenant."
|
||||
),
|
||||
)
|
||||
|
||||
def require(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
module_id: str,
|
||||
work_state: TenantWorkState = "interactive",
|
||||
) -> TenantModuleAdmission:
|
||||
admission = self.admission(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
module_id=module_id,
|
||||
work_state=work_state,
|
||||
)
|
||||
if admission.allowed:
|
||||
return admission
|
||||
if admission.disposition == "operator_action_required":
|
||||
raise TenantModuleOperatorActionRequired(admission)
|
||||
raise TenantModuleUnavailable(admission)
|
||||
|
||||
def effective_tenant_ids(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
module_id: str,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return active tenants that may admit new work for one module."""
|
||||
|
||||
return tuple(
|
||||
admission.tenant_id
|
||||
for admission in self.active_tenant_admissions(
|
||||
session,
|
||||
module_id=module_id,
|
||||
work_state="new",
|
||||
)
|
||||
if admission.allowed
|
||||
)
|
||||
|
||||
def active_tenant_admissions(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
module_id: str,
|
||||
work_state: TenantWorkState = "new",
|
||||
) -> tuple[TenantModuleAdmission, ...]:
|
||||
"""Resolve one admission per active tenant with a single DB query."""
|
||||
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
query = getattr(session, "query", None)
|
||||
if not callable(query):
|
||||
raise ModuleEntitlementResolutionError(
|
||||
"Tenant module entitlement resolution requires a database session"
|
||||
)
|
||||
tenants = (
|
||||
query(Tenant)
|
||||
.filter(Tenant.is_active.is_(True))
|
||||
.order_by(Tenant.id.asc())
|
||||
.all()
|
||||
)
|
||||
admissions: list[TenantModuleAdmission] = []
|
||||
for tenant in tenants:
|
||||
state = self._state_from_settings(getattr(tenant, "settings", None))
|
||||
self._store(tenant.id, tenant_active=True, state=state)
|
||||
allowed = module_id in state.effective_modules
|
||||
accepted = work_state == "accepted"
|
||||
admissions.append(
|
||||
TenantModuleAdmission(
|
||||
tenant_id=tenant.id,
|
||||
module_id=module_id,
|
||||
revision=state.revision,
|
||||
work_state=work_state,
|
||||
allowed=allowed,
|
||||
disposition=(
|
||||
"allowed"
|
||||
if allowed
|
||||
else "operator_action_required"
|
||||
if accepted
|
||||
else "rejected"
|
||||
),
|
||||
reason=(
|
||||
"The module is effective for this tenant."
|
||||
if allowed
|
||||
else "Accepted durable work was preserved because the owning module is no longer effective for this tenant; an operator must resume the module or resolve the work explicitly."
|
||||
if accepted
|
||||
else "The module is not effective for this tenant."
|
||||
),
|
||||
)
|
||||
)
|
||||
return tuple(admissions)
|
||||
|
||||
def invalidate(self, tenant_id: str | None = None) -> None:
|
||||
with self._lock:
|
||||
if tenant_id is None:
|
||||
self._cache.clear()
|
||||
else:
|
||||
self._cache.pop(str(tenant_id), None)
|
||||
|
||||
def _state_from_settings(
|
||||
self,
|
||||
settings: Mapping[str, object] | None,
|
||||
) -> TenantModuleEntitlementState:
|
||||
manifests_method = getattr(self._registry, "manifests", None)
|
||||
if not callable(manifests_method):
|
||||
raise ModuleEntitlementResolutionError(
|
||||
"Tenant module entitlement resolver has no platform registry"
|
||||
)
|
||||
manifests = {manifest.id: manifest for manifest in manifests_method()}
|
||||
return tenant_module_entitlement_state(
|
||||
settings,
|
||||
manifests,
|
||||
runtime_active_modules=manifests,
|
||||
)
|
||||
|
||||
def _cached(self, tenant_id: str) -> _CachedTenantEntitlement | None:
|
||||
now = monotonic()
|
||||
with self._lock:
|
||||
cached = self._cache.get(tenant_id)
|
||||
if cached is None:
|
||||
return None
|
||||
if cached.expires_at <= now:
|
||||
self._cache.pop(tenant_id, None)
|
||||
return None
|
||||
self._cache.move_to_end(tenant_id)
|
||||
return cached
|
||||
|
||||
def _store(
|
||||
self,
|
||||
tenant_id: str,
|
||||
*,
|
||||
tenant_active: bool,
|
||||
state: TenantModuleEntitlementState,
|
||||
) -> None:
|
||||
if self._ttl_seconds <= 0:
|
||||
return
|
||||
with self._lock:
|
||||
self._cache[str(tenant_id)] = _CachedTenantEntitlement(
|
||||
expires_at=monotonic() + self._ttl_seconds,
|
||||
tenant_active=tenant_active,
|
||||
state=state,
|
||||
)
|
||||
self._cache.move_to_end(str(tenant_id))
|
||||
while len(self._cache) > self._max_entries:
|
||||
self._cache.popitem(last=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantExecutionContext:
|
||||
resolver: TenantModuleEntitlementResolver
|
||||
session: object
|
||||
tenant_id: str
|
||||
work_state: TenantWorkState
|
||||
|
||||
def require_module(self, module_id: str) -> TenantModuleAdmission:
|
||||
return self.resolver.require(
|
||||
self.session,
|
||||
tenant_id=self.tenant_id,
|
||||
module_id=module_id,
|
||||
work_state=self.work_state,
|
||||
)
|
||||
|
||||
|
||||
_TENANT_EXECUTION_CONTEXT: ContextVar[TenantExecutionContext | None] = ContextVar(
|
||||
"govoplan_tenant_execution_context",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def current_tenant_execution_context() -> TenantExecutionContext | None:
|
||||
return _TENANT_EXECUTION_CONTEXT.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def tenant_execution_scope(
|
||||
resolver: TenantModuleEntitlementResolver,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
work_state: TenantWorkState = "interactive",
|
||||
) -> Iterator[TenantExecutionContext]:
|
||||
context = TenantExecutionContext(
|
||||
resolver=resolver,
|
||||
session=session,
|
||||
tenant_id=str(tenant_id),
|
||||
work_state=work_state,
|
||||
)
|
||||
token = _TENANT_EXECUTION_CONTEXT.set(context)
|
||||
try:
|
||||
yield context
|
||||
finally:
|
||||
_TENANT_EXECUTION_CONTEXT.reset(token)
|
||||
|
||||
|
||||
def tenant_module_entitlement_state(
|
||||
settings: Mapping[str, object] | None,
|
||||
manifests: Mapping[str, ModuleManifest],
|
||||
*,
|
||||
runtime_active_modules: Iterable[str] | None = None,
|
||||
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
|
||||
) -> TenantModuleEntitlementState:
|
||||
module_ids = tuple(sorted(manifests))
|
||||
known = set(module_ids)
|
||||
runtime_active = (
|
||||
known
|
||||
if runtime_active_modules is None
|
||||
else known.intersection(_normalized_ids(runtime_active_modules))
|
||||
)
|
||||
protected = known.intersection(_normalized_ids(protected_modules))
|
||||
raw_document = (settings or {}).get(MODULE_ENTITLEMENTS_KEY)
|
||||
configured = isinstance(raw_document, Mapping)
|
||||
diagnostics: list[dict[str, str]] = []
|
||||
|
||||
if not configured:
|
||||
revision = 0
|
||||
requested_available = set(known)
|
||||
requested_forced = set(protected)
|
||||
requested_selected = set(known)
|
||||
else:
|
||||
document = raw_document
|
||||
revision = _revision(document.get("revision"), diagnostics)
|
||||
system_policy = document.get("system_policy")
|
||||
tenant_selection = document.get("tenant_selection")
|
||||
if not isinstance(system_policy, Mapping) or not isinstance(
|
||||
tenant_selection, Mapping
|
||||
):
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"module_entitlements.invalid_document",
|
||||
"The tenant module entitlement document is malformed and was restricted to protected modules.",
|
||||
)
|
||||
)
|
||||
requested_available = set(protected)
|
||||
requested_forced = set(protected)
|
||||
requested_selected = set()
|
||||
else:
|
||||
requested_available = _configured_ids(
|
||||
system_policy.get("available_modules"),
|
||||
field="system_policy.available_modules",
|
||||
known=known,
|
||||
fallback=protected,
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
requested_forced = _configured_ids(
|
||||
system_policy.get("forced_modules"),
|
||||
field="system_policy.forced_modules",
|
||||
known=known,
|
||||
fallback=protected,
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
requested_selected = _configured_ids(
|
||||
tenant_selection.get("enabled_modules"),
|
||||
field="tenant_selection.enabled_modules",
|
||||
known=known,
|
||||
fallback=(),
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
|
||||
available, missing_available = _dependency_closure(
|
||||
requested_available | requested_forced | protected,
|
||||
manifests,
|
||||
)
|
||||
forced, missing_forced = _dependency_closure(
|
||||
requested_forced | protected,
|
||||
manifests,
|
||||
)
|
||||
selected = requested_selected.intersection(available)
|
||||
effective_candidates, missing_selected = _dependency_closure(
|
||||
selected | forced,
|
||||
manifests,
|
||||
)
|
||||
effective_candidates.intersection_update(available)
|
||||
effective = effective_candidates.intersection(runtime_active)
|
||||
derived = effective_candidates - selected - forced
|
||||
|
||||
for module_id in sorted(
|
||||
missing_available | missing_forced | missing_selected
|
||||
):
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"module_entitlements.missing_dependency",
|
||||
f"A selected module requires unavailable dependency {module_id}.",
|
||||
)
|
||||
)
|
||||
|
||||
items: list[TenantModuleItem] = []
|
||||
for module_id in module_ids:
|
||||
manifest = manifests[module_id]
|
||||
is_available = module_id in available
|
||||
is_forced = module_id in forced
|
||||
is_selected = module_id in selected
|
||||
is_derived = module_id in derived
|
||||
is_runtime_active = module_id in runtime_active
|
||||
is_effective = module_id in effective
|
||||
reason: str | None = None
|
||||
if not is_available:
|
||||
reason = "Unavailable by system policy."
|
||||
elif is_forced:
|
||||
reason = "Required by system policy or a protected platform dependency."
|
||||
elif is_derived:
|
||||
reason = "Required by another selected module."
|
||||
elif not is_runtime_active and (is_selected or is_forced):
|
||||
reason = "Selected for this tenant, but the module is not active in the deployment."
|
||||
items.append(
|
||||
TenantModuleItem(
|
||||
id=module_id,
|
||||
name=manifest.name,
|
||||
dependencies=tuple(manifest.dependencies),
|
||||
runtime_active=is_runtime_active,
|
||||
availability=(
|
||||
"forced" if is_forced else "available" if is_available else "unavailable"
|
||||
),
|
||||
selected=is_selected,
|
||||
effective=is_effective,
|
||||
forced=is_forced,
|
||||
derived_dependency=is_derived,
|
||||
tenant_can_toggle=is_available and not is_forced and not is_derived,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
|
||||
return TenantModuleEntitlementState(
|
||||
revision=revision,
|
||||
configured=configured,
|
||||
available_modules=tuple(sorted(available)),
|
||||
forced_modules=tuple(sorted(forced)),
|
||||
selected_modules=tuple(sorted(selected)),
|
||||
effective_modules=tuple(sorted(effective)),
|
||||
derived_dependencies=tuple(sorted(derived)),
|
||||
modules=tuple(items),
|
||||
diagnostics=tuple(diagnostics),
|
||||
)
|
||||
|
||||
|
||||
def update_system_tenant_module_policy(
|
||||
settings: Mapping[str, object] | None,
|
||||
manifests: Mapping[str, ModuleManifest],
|
||||
*,
|
||||
available_modules: Iterable[str],
|
||||
forced_modules: Iterable[str],
|
||||
enabled_modules: Iterable[str],
|
||||
expected_revision: int | None,
|
||||
runtime_active_modules: Iterable[str] | None = None,
|
||||
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
|
||||
) -> tuple[dict[str, object], TenantModuleEntitlementState]:
|
||||
current = tenant_module_entitlement_state(
|
||||
settings,
|
||||
manifests,
|
||||
runtime_active_modules=runtime_active_modules,
|
||||
protected_modules=protected_modules,
|
||||
)
|
||||
_check_revision(current.revision, expected_revision)
|
||||
known = set(manifests)
|
||||
available_requested = _validated_requested_ids(
|
||||
available_modules, known=known, field="available_modules"
|
||||
)
|
||||
forced_requested = _validated_requested_ids(
|
||||
forced_modules, known=known, field="forced_modules"
|
||||
)
|
||||
enabled_requested = _validated_requested_ids(
|
||||
enabled_modules, known=known, field="enabled_modules"
|
||||
)
|
||||
protected = known.intersection(_normalized_ids(protected_modules))
|
||||
available, missing = _dependency_closure(
|
||||
available_requested | forced_requested | protected,
|
||||
manifests,
|
||||
)
|
||||
forced, forced_missing = _dependency_closure(
|
||||
forced_requested | protected,
|
||||
manifests,
|
||||
)
|
||||
if missing or forced_missing:
|
||||
missing_text = ", ".join(sorted(missing | forced_missing))
|
||||
raise ModuleEntitlementError(
|
||||
f"Module policy references dependencies that are not installed: {missing_text}"
|
||||
)
|
||||
unavailable_enabled = enabled_requested - available
|
||||
if unavailable_enabled:
|
||||
raise ModuleEntitlementError(
|
||||
"Tenant selection contains modules unavailable by system policy: "
|
||||
+ ", ".join(sorted(unavailable_enabled))
|
||||
)
|
||||
_validate_enabled_dependencies(enabled_requested | forced, available, manifests)
|
||||
updated = _write_document(
|
||||
settings,
|
||||
revision=current.revision + 1,
|
||||
available_modules=available,
|
||||
forced_modules=forced,
|
||||
enabled_modules=enabled_requested,
|
||||
)
|
||||
return updated, tenant_module_entitlement_state(
|
||||
updated,
|
||||
manifests,
|
||||
runtime_active_modules=runtime_active_modules,
|
||||
protected_modules=protected_modules,
|
||||
)
|
||||
|
||||
|
||||
def update_tenant_module_selection(
|
||||
settings: Mapping[str, object] | None,
|
||||
manifests: Mapping[str, ModuleManifest],
|
||||
*,
|
||||
enabled_modules: Iterable[str],
|
||||
expected_revision: int | None,
|
||||
runtime_active_modules: Iterable[str] | None = None,
|
||||
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
|
||||
) -> tuple[dict[str, object], TenantModuleEntitlementState]:
|
||||
current = tenant_module_entitlement_state(
|
||||
settings,
|
||||
manifests,
|
||||
runtime_active_modules=runtime_active_modules,
|
||||
protected_modules=protected_modules,
|
||||
)
|
||||
_check_revision(current.revision, expected_revision)
|
||||
enabled = _validated_requested_ids(
|
||||
enabled_modules,
|
||||
known=set(manifests),
|
||||
field="enabled_modules",
|
||||
)
|
||||
unavailable = enabled - set(current.available_modules)
|
||||
if unavailable:
|
||||
raise ModuleEntitlementError(
|
||||
"Tenant selection contains modules unavailable by system policy: "
|
||||
+ ", ".join(sorted(unavailable))
|
||||
)
|
||||
_validate_enabled_dependencies(
|
||||
enabled | set(current.forced_modules),
|
||||
set(current.available_modules),
|
||||
manifests,
|
||||
)
|
||||
updated = _write_document(
|
||||
settings,
|
||||
revision=current.revision + 1,
|
||||
available_modules=current.available_modules,
|
||||
forced_modules=current.forced_modules,
|
||||
enabled_modules=enabled,
|
||||
)
|
||||
return updated, tenant_module_entitlement_state(
|
||||
updated,
|
||||
manifests,
|
||||
runtime_active_modules=runtime_active_modules,
|
||||
protected_modules=protected_modules,
|
||||
)
|
||||
|
||||
|
||||
def module_entitlement_payload(
|
||||
tenant_id: str,
|
||||
state: TenantModuleEntitlementState,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"revision": state.revision,
|
||||
"configured": state.configured,
|
||||
"available_modules": list(state.available_modules),
|
||||
"forced_modules": list(state.forced_modules),
|
||||
"selected_modules": list(state.selected_modules),
|
||||
"effective_modules": list(state.effective_modules),
|
||||
"derived_dependencies": list(state.derived_dependencies),
|
||||
"modules": [
|
||||
{
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"dependencies": list(item.dependencies),
|
||||
"runtime_active": item.runtime_active,
|
||||
"availability": item.availability,
|
||||
"selected": item.selected,
|
||||
"effective": item.effective,
|
||||
"forced": item.forced,
|
||||
"derived_dependency": item.derived_dependency,
|
||||
"tenant_can_toggle": item.tenant_can_toggle,
|
||||
"reason": item.reason,
|
||||
}
|
||||
for item in state.modules
|
||||
],
|
||||
"diagnostics": [dict(item) for item in state.diagnostics],
|
||||
}
|
||||
|
||||
|
||||
def _write_document(
|
||||
settings: Mapping[str, object] | None,
|
||||
*,
|
||||
revision: int,
|
||||
available_modules: Iterable[str],
|
||||
forced_modules: Iterable[str],
|
||||
enabled_modules: Iterable[str],
|
||||
) -> dict[str, object]:
|
||||
updated = dict(settings or {})
|
||||
updated[MODULE_ENTITLEMENTS_KEY] = {
|
||||
"schema_version": MODULE_ENTITLEMENT_SCHEMA_VERSION,
|
||||
"revision": revision,
|
||||
"system_policy": {
|
||||
"available_modules": sorted(set(available_modules)),
|
||||
"forced_modules": sorted(set(forced_modules)),
|
||||
},
|
||||
"tenant_selection": {
|
||||
"enabled_modules": sorted(set(enabled_modules)),
|
||||
},
|
||||
}
|
||||
return updated
|
||||
|
||||
|
||||
def _validate_enabled_dependencies(
|
||||
enabled: set[str],
|
||||
available: set[str],
|
||||
manifests: Mapping[str, ModuleManifest],
|
||||
) -> None:
|
||||
closure, missing = _dependency_closure(enabled, manifests)
|
||||
if missing:
|
||||
raise ModuleEntitlementError(
|
||||
"Selected modules require dependencies that are not installed: "
|
||||
+ ", ".join(sorted(missing))
|
||||
)
|
||||
unavailable = closure - available
|
||||
if unavailable:
|
||||
raise ModuleEntitlementError(
|
||||
"Selected modules require dependencies unavailable by system policy: "
|
||||
+ ", ".join(sorted(unavailable))
|
||||
)
|
||||
|
||||
|
||||
def _dependency_closure(
|
||||
requested: Iterable[str],
|
||||
manifests: Mapping[str, ModuleManifest],
|
||||
) -> tuple[set[str], set[str]]:
|
||||
closure: set[str] = set()
|
||||
missing: set[str] = set()
|
||||
pending = list(_normalized_ids(requested))
|
||||
while pending:
|
||||
module_id = pending.pop()
|
||||
if module_id in closure:
|
||||
continue
|
||||
manifest = manifests.get(module_id)
|
||||
if manifest is None:
|
||||
missing.add(module_id)
|
||||
continue
|
||||
closure.add(module_id)
|
||||
pending.extend(manifest.dependencies)
|
||||
return closure, missing
|
||||
|
||||
|
||||
def _configured_ids(
|
||||
value: object,
|
||||
*,
|
||||
field: str,
|
||||
known: set[str],
|
||||
fallback: Iterable[str],
|
||||
diagnostics: list[dict[str, str]],
|
||||
) -> set[str]:
|
||||
if not isinstance(value, list | tuple):
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"module_entitlements.invalid_field",
|
||||
f"{field} is malformed and was evaluated with a restrictive fallback.",
|
||||
)
|
||||
)
|
||||
return set(fallback)
|
||||
values = _normalized_ids(value)
|
||||
unknown = values - known
|
||||
if unknown:
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"module_entitlements.unknown_module",
|
||||
f"{field} references unknown modules: {', '.join(sorted(unknown))}.",
|
||||
)
|
||||
)
|
||||
return values.intersection(known)
|
||||
|
||||
|
||||
def _validated_requested_ids(
|
||||
values: Iterable[str],
|
||||
*,
|
||||
known: set[str],
|
||||
field: str,
|
||||
) -> set[str]:
|
||||
normalized = _normalized_ids(values)
|
||||
unknown = normalized - known
|
||||
if unknown:
|
||||
raise ModuleEntitlementError(
|
||||
f"{field} contains unknown modules: {', '.join(sorted(unknown))}"
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalized_ids(values: Iterable[object]) -> set[str]:
|
||||
return {
|
||||
clean
|
||||
for value in values
|
||||
if (clean := str(value).strip())
|
||||
}
|
||||
|
||||
|
||||
def _revision(value: object, diagnostics: list[dict[str, str]]) -> int:
|
||||
if isinstance(value, int) and value >= 0:
|
||||
return value
|
||||
diagnostics.append(
|
||||
_diagnostic(
|
||||
"module_entitlements.invalid_revision",
|
||||
"The module entitlement revision is invalid; concurrent updates will require a reload.",
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _check_revision(current: int, expected: int | None) -> None:
|
||||
if expected is not None and expected != current:
|
||||
raise ModuleEntitlementConflict(
|
||||
f"Module entitlement revision changed from {expected} to {current}; reload before saving."
|
||||
)
|
||||
|
||||
|
||||
def _diagnostic(code: str, message: str) -> dict[str, str]:
|
||||
return {"code": code, "message": message, "severity": "warning"}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MODULE_ENTITLEMENTS_KEY",
|
||||
"MODULE_ENTITLEMENT_SCHEMA_VERSION",
|
||||
"TENANT_PROTECTED_MODULES",
|
||||
"ModuleEntitlementConflict",
|
||||
"ModuleEntitlementError",
|
||||
"ModuleEntitlementResolutionError",
|
||||
"TenantExecutionContext",
|
||||
"TenantModuleAdmission",
|
||||
"TenantModuleEntitlementResolver",
|
||||
"TenantModuleEntitlementState",
|
||||
"TenantModuleItem",
|
||||
"TenantModuleOperatorActionRequired",
|
||||
"TenantModuleUnavailable",
|
||||
"TenantWorkState",
|
||||
"current_tenant_execution_context",
|
||||
"module_entitlement_payload",
|
||||
"tenant_execution_scope",
|
||||
"tenant_module_entitlement_state",
|
||||
"update_system_tenant_module_policy",
|
||||
"update_tenant_module_selection",
|
||||
]
|
||||
@@ -27,6 +27,12 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||
from govoplan_core.core.events import current_event_trace
|
||||
from govoplan_core.core.module_lifecycle_recovery import (
|
||||
ModuleLifecycleRecovery,
|
||||
ModuleLifecycleRecoveryError,
|
||||
begin_module_installer_recovery,
|
||||
canonical_sha256,
|
||||
)
|
||||
from govoplan_core.core.module_management import (
|
||||
PROTECTED_MODULES,
|
||||
ModuleInstallPlan,
|
||||
@@ -268,6 +274,11 @@ class ModuleInstallerRunResult:
|
||||
return_code: int = 0
|
||||
error: str | None = None
|
||||
rollback: dict[str, object] | None = None
|
||||
recovery: ModuleLifecycleRecovery | None = field(
|
||||
default=None,
|
||||
repr=False,
|
||||
compare=False,
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
@@ -293,6 +304,7 @@ class _ModuleInstallRunState:
|
||||
result_commands: tuple[str, ...]
|
||||
record_redactions: tuple[str, ...]
|
||||
record: dict[str, Any]
|
||||
recovery: ModuleLifecycleRecovery | None = None
|
||||
|
||||
|
||||
def default_installer_runtime_dir(database_url: str | None = None, *, cwd: Path | None = None) -> Path:
|
||||
@@ -335,6 +347,18 @@ def module_install_preflight(
|
||||
issues.append(ModuleInstallerIssue("warning", "empty_plan", "No planned package changes are present."))
|
||||
if not maintenance_mode:
|
||||
issues.append(ModuleInstallerIssue("blocker", "maintenance_required", "Package changes require maintenance mode."))
|
||||
if os.getenv("GOVOPLAN_STATE_PROFILE", "local").strip().lower() == "shared":
|
||||
issues.append(
|
||||
ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"immutable_cluster_release_required",
|
||||
(
|
||||
"Shared-state deployments cannot mutate packages on one runtime node. "
|
||||
"Build and roll out one immutable release image across every API, worker, "
|
||||
"and scheduler node."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
activation_candidates = desired_modules_after_package_plan(desired_sequence, plan)
|
||||
issues.extend(module_manifest_compatibility_issues(available, module_ids=activation_candidates))
|
||||
@@ -491,6 +515,7 @@ def run_module_install_plan(
|
||||
remove_uninstalled_modules_from_desired: bool = True,
|
||||
dry_run: bool = False,
|
||||
request_context: Mapping[str, object] | None = None,
|
||||
finalize_recovery: bool = True,
|
||||
) -> ModuleInstallerRunResult:
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
effective_runtime_dir = runtime_dir or default_installer_runtime_dir(database_url)
|
||||
@@ -508,6 +533,7 @@ def run_module_install_plan(
|
||||
raise ModuleInstallerError("Install preflight is blocked: " + "; ".join(issue.message for issue in preflight.issues if issue.severity == "blocker"))
|
||||
|
||||
state = _prepare_module_install_run(
|
||||
session=session,
|
||||
plan=plan,
|
||||
preflight=preflight,
|
||||
database_url=database_url,
|
||||
@@ -538,6 +564,7 @@ def run_module_install_plan(
|
||||
|
||||
if failed_error is not None:
|
||||
return _failed_module_install_run_result(
|
||||
session=session,
|
||||
state=state,
|
||||
plan=plan,
|
||||
executed=executed,
|
||||
@@ -557,11 +584,13 @@ def run_module_install_plan(
|
||||
remove_uninstalled_modules_from_desired=remove_uninstalled_modules_from_desired,
|
||||
executed=executed,
|
||||
state=state,
|
||||
finalize_recovery=finalize_recovery,
|
||||
)
|
||||
|
||||
|
||||
def _prepare_module_install_run(
|
||||
*,
|
||||
session: Session,
|
||||
plan: ModuleInstallPlan,
|
||||
preflight: ModuleInstallerPreflight,
|
||||
database_url: str,
|
||||
@@ -593,12 +622,38 @@ def _prepare_module_install_run(
|
||||
verify_modules=True,
|
||||
)
|
||||
record_redactions = _installer_secret_redactions(database_url)
|
||||
record = _initial_module_install_record(
|
||||
recovery: ModuleLifecycleRecovery | None = None
|
||||
if not dry_run:
|
||||
try:
|
||||
recovery = begin_module_installer_recovery(
|
||||
session,
|
||||
run_id=run_id,
|
||||
plan=plan,
|
||||
preflight=preflight,
|
||||
commands=commands,
|
||||
record_redactions=record_redactions,
|
||||
plan=tuple(item.as_dict() for item in plan.items),
|
||||
command_count=len(commands),
|
||||
migrate_database=migrate_database,
|
||||
destructive_retirement=_destructive_retirement_requested(plan),
|
||||
snapshot_sha256=None,
|
||||
backup_reference=(
|
||||
f"module-installer:{run_id}:database-backup"
|
||||
if _destructive_retirement_requested(plan)
|
||||
else None
|
||||
),
|
||||
request_context_sha256=canonical_sha256(dict(request_context or {})),
|
||||
)
|
||||
recovery.checkpoint(
|
||||
kind="snapshot-started",
|
||||
summary="Installer environment snapshot started before package effects",
|
||||
evidence={
|
||||
"run_id": run_id,
|
||||
"database_backup_expected": bool(
|
||||
migrate_database or _destructive_retirement_requested(plan)
|
||||
),
|
||||
},
|
||||
)
|
||||
except ModuleLifecycleRecoveryError as exc:
|
||||
raise ModuleInstallerError(str(exc)) from exc
|
||||
|
||||
try:
|
||||
snapshot = _snapshot_environment(
|
||||
run_dir,
|
||||
webui_root=webui_root,
|
||||
@@ -607,7 +662,32 @@ def _prepare_module_install_run(
|
||||
database_backup_command=database_backup_command,
|
||||
database_restore_command=database_restore_command,
|
||||
database_restore_check_command=database_restore_check_command,
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
if recovery is not None:
|
||||
recovery.unresolved(
|
||||
summary="Installer snapshot preparation failed before package effects",
|
||||
evidence={"snapshot_error_type": type(exc).__name__},
|
||||
outcome_unknown=False,
|
||||
)
|
||||
raise
|
||||
|
||||
if recovery is not None:
|
||||
recovery.checkpoint(
|
||||
kind="snapshot-verified",
|
||||
summary="Installer environment snapshot and backup evidence were verified",
|
||||
evidence={
|
||||
"snapshot_sha256": canonical_sha256(snapshot),
|
||||
**_database_backup_recovery_evidence(snapshot),
|
||||
},
|
||||
)
|
||||
record = _initial_module_install_record(
|
||||
run_id=run_id,
|
||||
plan=plan,
|
||||
preflight=preflight,
|
||||
commands=commands,
|
||||
record_redactions=record_redactions,
|
||||
snapshot=snapshot,
|
||||
build_webui=build_webui,
|
||||
migrate_database=migrate_database,
|
||||
activate_installed_modules=activate_installed_modules,
|
||||
@@ -615,6 +695,8 @@ def _prepare_module_install_run(
|
||||
dry_run=dry_run,
|
||||
request_context=request_context,
|
||||
)
|
||||
if recovery is not None:
|
||||
record["recovery"] = _module_lifecycle_recovery_record(recovery)
|
||||
record_path = run_dir / "record.json"
|
||||
_write_json(record_path, record)
|
||||
return _ModuleInstallRunState(
|
||||
@@ -625,6 +707,7 @@ def _prepare_module_install_run(
|
||||
result_commands=_command_displays(commands, redactions=record_redactions),
|
||||
record_redactions=record_redactions,
|
||||
record=record,
|
||||
recovery=recovery,
|
||||
)
|
||||
|
||||
|
||||
@@ -661,6 +744,40 @@ def _initial_module_install_record(
|
||||
return record
|
||||
|
||||
|
||||
def _module_lifecycle_recovery_record(
|
||||
recovery: ModuleLifecycleRecovery,
|
||||
*,
|
||||
status: str = "running",
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"operation_id": recovery.operation_id,
|
||||
"operation_type": recovery.operation_type,
|
||||
"mode": recovery.mode.value,
|
||||
"plan_sha256": recovery.plan_sha256,
|
||||
"replayed": recovery.replayed,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def _database_backup_recovery_evidence(
|
||||
snapshot: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
backup = snapshot.get("database_backup")
|
||||
if not isinstance(backup, Mapping):
|
||||
return {"database_backup_present": False}
|
||||
sha256 = str(backup.get("artifact_sha256") or "").strip()
|
||||
return {
|
||||
"database_backup_present": True,
|
||||
"database_backup_type": str(backup.get("type") or "unknown"),
|
||||
"database_backup_sha256": sha256 or "unavailable",
|
||||
"database_backup_size_bytes": int(backup.get("size_bytes") or 0),
|
||||
"database_backup_reference": (
|
||||
f"sha256:{sha256}" if sha256 else "unavailable"
|
||||
),
|
||||
"restore_check_sha256": canonical_sha256(backup.get("restore_check")),
|
||||
}
|
||||
|
||||
|
||||
def _execute_module_install_run(
|
||||
*,
|
||||
session: Session,
|
||||
@@ -673,14 +790,91 @@ def _execute_module_install_run(
|
||||
failed_error: str | None = None
|
||||
with _installer_lock(effective_runtime_dir):
|
||||
try:
|
||||
if state.recovery is not None:
|
||||
state.recovery.checkpoint(
|
||||
kind="effects-starting",
|
||||
summary="Installer acquired local and distributed execution fences",
|
||||
evidence={
|
||||
"command_count": len(state.commands),
|
||||
"destructive_retirement": _destructive_retirement_requested(plan),
|
||||
},
|
||||
)
|
||||
if _destructive_retirement_requested(plan):
|
||||
state.recovery.checkpoint(
|
||||
kind="retirement-effect-started",
|
||||
summary="Destructive module retirement entered its effect boundary",
|
||||
evidence={
|
||||
"retirement_plan_sha256": canonical_sha256(
|
||||
[
|
||||
item.as_dict()
|
||||
for item in plan.items
|
||||
if item.destroy_data
|
||||
]
|
||||
),
|
||||
},
|
||||
effect_started=True,
|
||||
)
|
||||
_execute_module_install_retirements(session=session, plan=plan, available=available, state=state)
|
||||
for command in state.commands:
|
||||
executed.append(_run_module_install_command(command, state=state))
|
||||
for index, command in enumerate(state.commands):
|
||||
if state.recovery is not None:
|
||||
command_record = _command_record(
|
||||
command,
|
||||
redactions=state.record_redactions,
|
||||
)
|
||||
state.recovery.checkpoint(
|
||||
kind="command-effect-started",
|
||||
summary="Installer command entered its effect boundary",
|
||||
evidence={
|
||||
"command_index": index,
|
||||
"command_source": str(command.get("source") or "unknown"),
|
||||
"command_sha256": canonical_sha256(command_record),
|
||||
},
|
||||
effect_started=True,
|
||||
)
|
||||
command_result = _run_module_install_command(command, state=state)
|
||||
executed.append(command_result)
|
||||
if state.recovery is not None:
|
||||
state.recovery.checkpoint(
|
||||
kind="command-result-verified",
|
||||
summary="Installer command returned a conclusive successful result",
|
||||
evidence={
|
||||
"command_index": index,
|
||||
"return_code": int(command_result["return_code"]),
|
||||
"result_sha256": canonical_sha256(command_result),
|
||||
},
|
||||
)
|
||||
state.record["commands"] = executed
|
||||
_write_json(state.record_path, state.record)
|
||||
except Exception as exc:
|
||||
failed_error = _redact_installer_text(str(exc), redactions=state.record_redactions)
|
||||
_rollback_session_after_module_install_error(session, exc)
|
||||
if state.recovery is not None:
|
||||
outcome_unknown = not isinstance(exc, ModuleInstallerError)
|
||||
try:
|
||||
state.recovery.unresolved(
|
||||
summary="Module installer effects did not reach verified completion",
|
||||
evidence={
|
||||
"error_type": type(exc).__name__,
|
||||
"completed_command_count": len(executed),
|
||||
},
|
||||
outcome_unknown=outcome_unknown,
|
||||
)
|
||||
state.record["recovery"] = _module_lifecycle_recovery_record(
|
||||
state.recovery,
|
||||
status=(
|
||||
"outcome_unknown"
|
||||
if outcome_unknown
|
||||
else "recovery_required"
|
||||
if state.recovery.effect_started
|
||||
else "failed"
|
||||
),
|
||||
)
|
||||
except Exception as recovery_exc:
|
||||
state.record["recovery_error"] = type(recovery_exc).__name__
|
||||
failed_error = (
|
||||
f"{failed_error}; recovery ledger transition failed: "
|
||||
f"{type(recovery_exc).__name__}"
|
||||
)
|
||||
return executed, failed_error
|
||||
|
||||
|
||||
@@ -728,6 +922,7 @@ def _rollback_session_after_module_install_error(session: Session, exc: Exceptio
|
||||
|
||||
def _failed_module_install_run_result(
|
||||
*,
|
||||
session: Session,
|
||||
state: _ModuleInstallRunState,
|
||||
plan: ModuleInstallPlan,
|
||||
executed: list[dict[str, object]],
|
||||
@@ -753,6 +948,7 @@ def _failed_module_install_run_result(
|
||||
commands=state.result_commands,
|
||||
return_code=1,
|
||||
error=failed_error,
|
||||
recovery=state.recovery,
|
||||
)
|
||||
rollback = rollback_module_install_run(
|
||||
run_id=state.run_id,
|
||||
@@ -763,6 +959,30 @@ def _failed_module_install_run_result(
|
||||
database_url=database_url,
|
||||
)
|
||||
_update_run_record(state.record_path, {"destructive_retirement_rollback": rollback.as_dict()})
|
||||
if rollback.return_code == 0 and state.recovery is not None:
|
||||
try:
|
||||
state.recovery.recovered(
|
||||
session,
|
||||
evidence={
|
||||
"rollback_return_code": rollback.return_code,
|
||||
"rollback_sha256": canonical_sha256(rollback.as_dict()),
|
||||
},
|
||||
summary="Verified rollback restored the pre-install module state",
|
||||
)
|
||||
_update_run_record(
|
||||
state.record_path,
|
||||
{
|
||||
"recovery": _module_lifecycle_recovery_record(
|
||||
state.recovery,
|
||||
status="recovered",
|
||||
)
|
||||
},
|
||||
)
|
||||
except Exception as recovery_exc:
|
||||
_update_run_record(
|
||||
state.record_path,
|
||||
{"recovery_error": type(recovery_exc).__name__},
|
||||
)
|
||||
return ModuleInstallerRunResult(
|
||||
run_id=state.run_id,
|
||||
status="rolled-back" if rollback.return_code == 0 else "failed",
|
||||
@@ -771,6 +991,7 @@ def _failed_module_install_run_result(
|
||||
return_code=1,
|
||||
error=failed_error,
|
||||
rollback=rollback.as_dict(),
|
||||
recovery=state.recovery,
|
||||
)
|
||||
|
||||
|
||||
@@ -783,6 +1004,7 @@ def _applied_module_install_run_result(
|
||||
remove_uninstalled_modules_from_desired: bool,
|
||||
executed: list[dict[str, object]],
|
||||
state: _ModuleInstallRunState,
|
||||
finalize_recovery: bool,
|
||||
) -> ModuleInstallerRunResult:
|
||||
save_module_install_plan(session, tuple(_mark_applied(item) for item in plan.items))
|
||||
if activate_installed_modules or remove_uninstalled_modules_from_desired:
|
||||
@@ -794,14 +1016,50 @@ def _applied_module_install_run_result(
|
||||
)
|
||||
save_desired_enabled_modules(session, next_desired)
|
||||
state.record["desired_enabled_after"] = list(next_desired)
|
||||
recovery_evidence = {
|
||||
"command_count": len(executed),
|
||||
"command_results_sha256": canonical_sha256(executed),
|
||||
"desired_graph_sha256": canonical_sha256(
|
||||
state.record.get("desired_enabled_after", list(desired_enabled))
|
||||
),
|
||||
"plan_projection_sha256": canonical_sha256(
|
||||
[item.as_dict() for item in plan.items]
|
||||
),
|
||||
}
|
||||
if state.recovery is not None and finalize_recovery:
|
||||
state.recovery.succeed(
|
||||
session,
|
||||
evidence=recovery_evidence,
|
||||
commit_projection=True,
|
||||
)
|
||||
recovery_status = "succeeded"
|
||||
else:
|
||||
session.commit()
|
||||
recovery_status = "awaiting_supervisor" if state.recovery is not None else None
|
||||
if state.recovery is not None:
|
||||
state.recovery.checkpoint(
|
||||
kind="local-projection-committed",
|
||||
summary="Package and desired-graph projections await runtime health verification",
|
||||
evidence=recovery_evidence,
|
||||
)
|
||||
state.record.update({
|
||||
"status": "applied",
|
||||
"finished_at": datetime.now(tz=UTC).isoformat(),
|
||||
"commands": executed,
|
||||
})
|
||||
if state.recovery is not None and recovery_status is not None:
|
||||
state.record["recovery"] = _module_lifecycle_recovery_record(
|
||||
state.recovery,
|
||||
status=recovery_status,
|
||||
)
|
||||
_write_json(state.record_path, state.record)
|
||||
return ModuleInstallerRunResult(run_id=state.run_id, status="applied", record_path=state.record_path, commands=state.result_commands)
|
||||
return ModuleInstallerRunResult(
|
||||
run_id=state.run_id,
|
||||
status="applied",
|
||||
record_path=state.record_path,
|
||||
commands=state.result_commands,
|
||||
recovery=state.recovery,
|
||||
)
|
||||
|
||||
|
||||
def supervise_module_install_plan(
|
||||
@@ -852,6 +1110,7 @@ def supervise_module_install_plan(
|
||||
remove_uninstalled_modules_from_desired=remove_uninstalled_modules_from_desired,
|
||||
dry_run=False,
|
||||
request_context=request_context,
|
||||
finalize_recovery=False,
|
||||
)
|
||||
supervisor: dict[str, object] = {
|
||||
"started_at": datetime.now(tz=UTC).isoformat(),
|
||||
@@ -926,6 +1185,27 @@ def supervise_module_install_plan(
|
||||
"status": "ok",
|
||||
"finished_at": datetime.now(tz=UTC).isoformat(),
|
||||
})
|
||||
if result.recovery is not None:
|
||||
result.recovery.succeed(
|
||||
session,
|
||||
evidence={
|
||||
"restart_results_sha256": canonical_sha256(restart_results),
|
||||
"health_results_sha256": canonical_sha256(supervisor.get("health")),
|
||||
"runtime_health_verified": True,
|
||||
},
|
||||
commit_projection=False,
|
||||
)
|
||||
supervisor["recovery_operation_id"] = result.recovery.operation_id
|
||||
supervisor["recovery_status"] = "succeeded"
|
||||
_update_run_record(
|
||||
result.record_path,
|
||||
{
|
||||
"recovery": _module_lifecycle_recovery_record(
|
||||
result.recovery,
|
||||
status="succeeded",
|
||||
)
|
||||
},
|
||||
)
|
||||
_update_run_record(result.record_path, {"supervisor": supervisor})
|
||||
return result
|
||||
|
||||
@@ -3252,6 +3532,31 @@ def _rollback_after_supervisor_failure(
|
||||
session.commit()
|
||||
supervisor["rollback"] = rollback.as_dict()
|
||||
|
||||
if rollback.return_code == 0 and result.recovery is not None:
|
||||
try:
|
||||
result.recovery.recovered(
|
||||
session,
|
||||
evidence={
|
||||
"rollback_sha256": canonical_sha256(rollback.as_dict()),
|
||||
"desired_graph_restored": True,
|
||||
},
|
||||
summary="Supervisor rollback restored package and desired module state",
|
||||
)
|
||||
supervisor["recovery_operation_id"] = result.recovery.operation_id
|
||||
supervisor["recovery_status"] = "recovered"
|
||||
_update_run_record(
|
||||
result.record_path,
|
||||
{
|
||||
"recovery": _module_lifecycle_recovery_record(
|
||||
result.recovery,
|
||||
status="recovered",
|
||||
)
|
||||
},
|
||||
)
|
||||
except Exception as recovery_exc:
|
||||
supervisor["recovery_status"] = "reconciliation-failed"
|
||||
supervisor["recovery_error"] = type(recovery_exc).__name__
|
||||
|
||||
rollback_restart = _run_restart_commands(restart_commands)
|
||||
if rollback_restart:
|
||||
supervisor["rollback_restart_commands"] = rollback_restart
|
||||
@@ -3272,6 +3577,7 @@ def _rollback_after_supervisor_failure(
|
||||
return_code=1,
|
||||
error=reason,
|
||||
rollback=rollback.as_dict(),
|
||||
recovery=result.recovery,
|
||||
)
|
||||
|
||||
|
||||
@@ -3328,12 +3634,6 @@ def _wait_for_health_urls(urls: Iterable[str], *, timeout_seconds: float, interv
|
||||
return payload
|
||||
|
||||
|
||||
def _run_restart_command_legacy(command: str | None) -> dict[str, object] | None:
|
||||
if not command:
|
||||
return None
|
||||
return _run_restart_command(command)
|
||||
|
||||
|
||||
def _planned_python_install_packages(record: Mapping[str, object]) -> tuple[str, ...]:
|
||||
raw_plan = record.get("plan")
|
||||
if not isinstance(raw_plan, list):
|
||||
@@ -3685,10 +3985,24 @@ def _snapshot_sqlite_database(run_dir: Path, database_url: str | None) -> dict[s
|
||||
source.backup(target)
|
||||
else:
|
||||
backup_path.touch()
|
||||
with closing(sqlite3.connect(str(backup_path))) as candidate:
|
||||
row = candidate.execute("PRAGMA integrity_check").fetchone()
|
||||
integrity = str(row[0] if row else "missing result")
|
||||
if integrity.lower() != "ok":
|
||||
raise ModuleInstallerError(
|
||||
f"SQLite backup failed its restore-readiness integrity check: {integrity}"
|
||||
)
|
||||
artifact_sha256 = _sha256_file(backup_path)
|
||||
return {
|
||||
"type": "sqlite",
|
||||
"source": str(db_path),
|
||||
"path": backup_path.name,
|
||||
"artifact_sha256": artifact_sha256,
|
||||
"size_bytes": backup_path.stat().st_size,
|
||||
"restore_check": {
|
||||
"type": "sqlite_integrity_check",
|
||||
"result": integrity,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -3727,6 +4041,12 @@ def _snapshot_external_database(
|
||||
payload["database_url_secret"] = database_url_secret
|
||||
if result.returncode != 0:
|
||||
raise ModuleInstallerError(f"Database backup command failed ({result.returncode}): {_redact_installer_text(backup_command, redactions=redactions)}")
|
||||
if not backup_path.is_file() or backup_path.stat().st_size <= 0:
|
||||
raise ModuleInstallerError(
|
||||
"Database backup command did not create a non-empty backup artifact."
|
||||
)
|
||||
payload["artifact_sha256"] = _sha256_file(backup_path)
|
||||
payload["size_bytes"] = backup_path.stat().st_size
|
||||
if restore_check_command:
|
||||
restore_check = _run_database_hook(
|
||||
restore_check_command,
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Mapping, Sequence
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.core.recovery import (
|
||||
RecoveryGuaranteeError,
|
||||
RecoveryMode,
|
||||
RecoveryOperation,
|
||||
RecoveryPlan,
|
||||
RecoveryStatus,
|
||||
)
|
||||
from govoplan_core.core.recovery_runtime import (
|
||||
DurableRecoveryOperation,
|
||||
RecoveryOperationBusy,
|
||||
RecoveryOperationStateConflict,
|
||||
begin_durable_recovery_operation,
|
||||
claim_durable_recovery_operation,
|
||||
)
|
||||
from govoplan_core.core.runtime_coordination import process_runtime_identity
|
||||
|
||||
|
||||
class ModuleLifecycleRecoveryError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModuleLifecycleRecoveryDeclaration:
|
||||
operation_type: str
|
||||
mode: RecoveryMode
|
||||
resources: tuple[str, ...]
|
||||
verification: tuple[str, ...]
|
||||
|
||||
|
||||
MODULE_LIFECYCLE_RECOVERY_OPERATIONS = (
|
||||
ModuleLifecycleRecoveryDeclaration(
|
||||
operation_type="module-lifecycle.pre-migration",
|
||||
mode=RecoveryMode.COMPENSATION,
|
||||
resources=("postgresql", "package-environment", "webui-bundle", "filesystem"),
|
||||
verification=(
|
||||
"verify the canonical install plan and immutable package references",
|
||||
"verify the package and WebUI snapshots before mutation",
|
||||
"verify the installed manifests and desired module graph",
|
||||
),
|
||||
),
|
||||
ModuleLifecycleRecoveryDeclaration(
|
||||
operation_type="module-lifecycle.post-migration",
|
||||
mode=RecoveryMode.FORWARD_RECOVERY,
|
||||
resources=(
|
||||
"postgresql",
|
||||
"package-environment",
|
||||
"webui-bundle",
|
||||
"runtime-nodes",
|
||||
),
|
||||
verification=(
|
||||
"verify the backup reference and migration execution evidence",
|
||||
"verify migration heads and installed module manifests",
|
||||
"verify the desired graph and runtime health before completion",
|
||||
),
|
||||
),
|
||||
ModuleLifecycleRecoveryDeclaration(
|
||||
operation_type="module-retirement.destroy-data",
|
||||
mode=RecoveryMode.SNAPSHOT_RESTORE,
|
||||
resources=("postgresql", "object-storage", "package-environment"),
|
||||
verification=(
|
||||
"verify the pinned backup artifact and restore-readiness evidence",
|
||||
"verify the retirement provider result and remaining migration state",
|
||||
"verify the installed manifests and desired module graph",
|
||||
),
|
||||
),
|
||||
ModuleLifecycleRecoveryDeclaration(
|
||||
operation_type="module-runtime.apply-graph",
|
||||
mode=RecoveryMode.COMPENSATION,
|
||||
resources=("postgresql", "runtime-nodes", "module-registry"),
|
||||
verification=(
|
||||
"verify the requested graph against available module contracts",
|
||||
"verify activation and deactivation hooks completed",
|
||||
"verify the active graph and workflow contribution reconciliation",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_DECLARATIONS = {
|
||||
item.operation_type: item for item in MODULE_LIFECYCLE_RECOVERY_OPERATIONS
|
||||
}
|
||||
|
||||
|
||||
def canonical_sha256(value: object) -> str:
|
||||
encoded = json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def lifecycle_session_factory(session: Session) -> sessionmaker[Session]:
|
||||
bind = session.get_bind()
|
||||
if bind is None:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Module lifecycle recovery requires a database bind"
|
||||
)
|
||||
return sessionmaker(bind=bind, expire_on_commit=False)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ModuleLifecycleRecovery:
|
||||
operation: DurableRecoveryOperation | None
|
||||
operation_id: str
|
||||
operation_type: str
|
||||
mode: RecoveryMode
|
||||
plan_sha256: str
|
||||
replayed: bool
|
||||
effect_started: bool = False
|
||||
|
||||
def checkpoint(
|
||||
self,
|
||||
*,
|
||||
kind: str,
|
||||
summary: str,
|
||||
evidence: Mapping[str, object],
|
||||
effect_started: bool = False,
|
||||
) -> None:
|
||||
if self.operation is None:
|
||||
return
|
||||
self.effect_started = self.effect_started or effect_started
|
||||
self.operation.checkpoint(
|
||||
kind=kind,
|
||||
summary=summary,
|
||||
evidence={
|
||||
**dict(evidence),
|
||||
"effect_started": self.effect_started,
|
||||
"plan_sha256": self.plan_sha256,
|
||||
},
|
||||
)
|
||||
|
||||
def succeed(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
evidence: Mapping[str, object],
|
||||
commit_projection: bool,
|
||||
) -> None:
|
||||
if self.operation is None:
|
||||
return
|
||||
terminal = {
|
||||
"verified": True,
|
||||
"checks": {
|
||||
**dict(evidence),
|
||||
"plan_sha256": self.plan_sha256,
|
||||
"effect_started": self.effect_started,
|
||||
},
|
||||
}
|
||||
if commit_projection:
|
||||
self.operation.commit_verified_success(session, evidence=terminal)
|
||||
else:
|
||||
self.operation.succeed(evidence=terminal)
|
||||
|
||||
def unresolved(
|
||||
self,
|
||||
*,
|
||||
summary: str,
|
||||
evidence: Mapping[str, object],
|
||||
outcome_unknown: bool,
|
||||
) -> None:
|
||||
if self.operation is None:
|
||||
return
|
||||
if not self.effect_started:
|
||||
self.operation.fail(
|
||||
summary=summary,
|
||||
evidence={
|
||||
"verified": True,
|
||||
"checks": {
|
||||
**dict(evidence),
|
||||
"effect_started": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
return
|
||||
self.operation.unresolved(
|
||||
status=(
|
||||
RecoveryStatus.OUTCOME_UNKNOWN
|
||||
if outcome_unknown
|
||||
else RecoveryStatus.RECOVERY_REQUIRED
|
||||
),
|
||||
summary=summary,
|
||||
evidence={
|
||||
**dict(evidence),
|
||||
"effect_started": True,
|
||||
"plan_sha256": self.plan_sha256,
|
||||
},
|
||||
failure_summary=(
|
||||
"Inspect the installer run record and affected state services "
|
||||
"before retrying or restoring"
|
||||
),
|
||||
)
|
||||
|
||||
def recovered(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
evidence: Mapping[str, object],
|
||||
summary: str,
|
||||
) -> None:
|
||||
state = session.get(RecoveryOperation, self.operation_id)
|
||||
if state is None:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Module lifecycle recovery operation is unavailable"
|
||||
)
|
||||
if state.status == RecoveryStatus.RECOVERED.value:
|
||||
return
|
||||
try:
|
||||
handle = claim_durable_recovery_operation(
|
||||
lifecycle_session_factory(session),
|
||||
identity=process_runtime_identity(),
|
||||
operation_id=self.operation_id,
|
||||
lease_ttl_seconds=900,
|
||||
)
|
||||
except RecoveryOperationStateConflict as exc:
|
||||
if exc.status == RecoveryStatus.RECOVERED.value:
|
||||
return
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
f"Module lifecycle recovery is already {exc.status}"
|
||||
) from exc
|
||||
except (RecoveryOperationBusy, RecoveryGuaranteeError, RuntimeError) as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Module lifecycle recovery authority is unavailable"
|
||||
) from exc
|
||||
session.expire_all()
|
||||
state = session.get(RecoveryOperation, self.operation_id)
|
||||
if state is None:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Module lifecycle recovery operation is unavailable"
|
||||
)
|
||||
recovery_evidence = {
|
||||
"verified": True,
|
||||
"checks": {
|
||||
**dict(evidence),
|
||||
"plan_sha256": self.plan_sha256,
|
||||
},
|
||||
}
|
||||
if state.status == RecoveryStatus.OUTCOME_UNKNOWN.value:
|
||||
handle.resolve_unknown(
|
||||
effect_occurred=False,
|
||||
evidence=recovery_evidence,
|
||||
summary=summary,
|
||||
)
|
||||
else:
|
||||
handle.compensate(
|
||||
failure_summary=summary,
|
||||
failure_evidence={
|
||||
"effect_started": self.effect_started,
|
||||
"plan_sha256": self.plan_sha256,
|
||||
},
|
||||
recovery_evidence=recovery_evidence,
|
||||
)
|
||||
|
||||
|
||||
def begin_module_installer_recovery(
|
||||
session: Session,
|
||||
*,
|
||||
run_id: str,
|
||||
plan: Sequence[Mapping[str, object]],
|
||||
command_count: int,
|
||||
migrate_database: bool,
|
||||
destructive_retirement: bool,
|
||||
snapshot_sha256: str | None,
|
||||
backup_reference: str | None,
|
||||
request_context_sha256: str,
|
||||
) -> ModuleLifecycleRecovery:
|
||||
operation_type = (
|
||||
"module-retirement.destroy-data"
|
||||
if destructive_retirement
|
||||
else "module-lifecycle.post-migration"
|
||||
if migrate_database
|
||||
else "module-lifecycle.pre-migration"
|
||||
)
|
||||
declaration = _DECLARATIONS[operation_type]
|
||||
plan_sha256 = canonical_sha256([dict(item) for item in plan])
|
||||
recovery_plan = RecoveryPlan(
|
||||
mode=declaration.mode,
|
||||
preconditions=(
|
||||
"maintenance mode and installer preflight are current",
|
||||
"package references and the requested module graph are pinned",
|
||||
"the deployment-wide module lifecycle fence is owned",
|
||||
),
|
||||
compensation_steps=(
|
||||
"restore the Python and WebUI package snapshots",
|
||||
"restore the prior desired module graph",
|
||||
"verify installed manifests and runtime health",
|
||||
)
|
||||
if declaration.mode == RecoveryMode.COMPENSATION
|
||||
else (),
|
||||
forward_recovery_steps=(
|
||||
"inspect migration task and command evidence",
|
||||
"complete or repair migrations under the same deployment fence",
|
||||
"verify migration heads, manifests, desired graph, and runtime health",
|
||||
)
|
||||
if declaration.mode == RecoveryMode.FORWARD_RECOVERY
|
||||
else (),
|
||||
verification_steps=declaration.verification,
|
||||
backup_reference=(
|
||||
backup_reference
|
||||
if declaration.mode == RecoveryMode.SNAPSHOT_RESTORE
|
||||
else None
|
||||
),
|
||||
)
|
||||
if declaration.mode == RecoveryMode.SNAPSHOT_RESTORE and not backup_reference:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Destructive module retirement requires verified backup evidence"
|
||||
)
|
||||
session.commit()
|
||||
try:
|
||||
started = begin_durable_recovery_operation(
|
||||
lifecycle_session_factory(session),
|
||||
identity=process_runtime_identity(),
|
||||
module_id="core",
|
||||
operation_type=operation_type,
|
||||
idempotency_key=f"module-installer:{run_id}",
|
||||
request={
|
||||
"run_id": run_id,
|
||||
"plan_sha256": plan_sha256,
|
||||
"command_count": command_count,
|
||||
"migrate_database": migrate_database,
|
||||
"destructive_retirement": destructive_retirement,
|
||||
"snapshot_expected": True,
|
||||
"request_context_sha256": request_context_sha256,
|
||||
},
|
||||
recovery_plan=recovery_plan,
|
||||
precondition_evidence={
|
||||
"plan_sha256": plan_sha256,
|
||||
"snapshot_sha256": snapshot_sha256 or "pending",
|
||||
"request_context_sha256": request_context_sha256,
|
||||
"command_count": command_count,
|
||||
"backup_reference_present": bool(backup_reference),
|
||||
},
|
||||
lease_resource_key="core:module-lifecycle:deployment",
|
||||
lease_ttl_seconds=900,
|
||||
resource_type="module_installer_run",
|
||||
resource_id=run_id,
|
||||
metadata={
|
||||
"resources": list(declaration.resources),
|
||||
"migrate_database": migrate_database,
|
||||
"destructive_retirement": destructive_retirement,
|
||||
},
|
||||
block_unresolved_resource=True,
|
||||
)
|
||||
except RecoveryOperationBusy as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Another runtime owns the deployment module lifecycle fence"
|
||||
) from exc
|
||||
except RecoveryOperationStateConflict as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
f"Module installer recovery is already {exc.status}"
|
||||
) from exc
|
||||
except (RecoveryGuaranteeError, RuntimeError, SQLAlchemyError, ValueError) as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"The recovery ledger is unavailable; module mutation did not start"
|
||||
) from exc
|
||||
return ModuleLifecycleRecovery(
|
||||
operation=started.operation,
|
||||
operation_id=started.operation_id,
|
||||
operation_type=operation_type,
|
||||
mode=declaration.mode,
|
||||
plan_sha256=plan_sha256,
|
||||
replayed=started.replayed,
|
||||
)
|
||||
|
||||
|
||||
def begin_runtime_graph_recovery(
|
||||
session: Session,
|
||||
*,
|
||||
previous_modules: Sequence[str],
|
||||
requested_modules: Sequence[str],
|
||||
migrate: bool,
|
||||
) -> ModuleLifecycleRecovery:
|
||||
declaration = _DECLARATIONS["module-runtime.apply-graph"]
|
||||
plan = {
|
||||
"previous_modules": sorted(set(previous_modules)),
|
||||
"requested_modules": sorted(set(requested_modules)),
|
||||
"migrate": migrate,
|
||||
}
|
||||
plan_sha256 = canonical_sha256(plan)
|
||||
session.commit()
|
||||
try:
|
||||
started = begin_durable_recovery_operation(
|
||||
lifecycle_session_factory(session),
|
||||
identity=process_runtime_identity(),
|
||||
module_id="core",
|
||||
operation_type=declaration.operation_type,
|
||||
idempotency_key=f"module-runtime:{uuid4()}",
|
||||
request={**plan, "plan_sha256": plan_sha256},
|
||||
recovery_plan=RecoveryPlan(
|
||||
mode=declaration.mode,
|
||||
preconditions=(
|
||||
"the requested graph passed module contract validation",
|
||||
"the deployment-wide module lifecycle fence is owned",
|
||||
),
|
||||
compensation_steps=(
|
||||
"restore the previous in-process active registry",
|
||||
"reconfigure capability contexts from the previous graph",
|
||||
),
|
||||
verification_steps=declaration.verification,
|
||||
),
|
||||
precondition_evidence={
|
||||
"plan_sha256": plan_sha256,
|
||||
"previous_graph_sha256": canonical_sha256(
|
||||
sorted(set(previous_modules))
|
||||
),
|
||||
"requested_graph_sha256": canonical_sha256(
|
||||
sorted(set(requested_modules))
|
||||
),
|
||||
},
|
||||
lease_resource_key="core:module-lifecycle:deployment",
|
||||
lease_ttl_seconds=300,
|
||||
resource_type="module_runtime_graph",
|
||||
resource_id=plan_sha256,
|
||||
metadata={"resources": list(declaration.resources)},
|
||||
block_unresolved_resource=True,
|
||||
)
|
||||
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"Another lifecycle mutation is active or unresolved"
|
||||
) from exc
|
||||
except (RecoveryGuaranteeError, RuntimeError, SQLAlchemyError, ValueError) as exc:
|
||||
raise ModuleLifecycleRecoveryError(
|
||||
"The recovery ledger is unavailable; the active graph was unchanged"
|
||||
) from exc
|
||||
return ModuleLifecycleRecovery(
|
||||
operation=started.operation,
|
||||
operation_id=started.operation_id,
|
||||
operation_type=declaration.operation_type,
|
||||
mode=declaration.mode,
|
||||
plan_sha256=plan_sha256,
|
||||
replayed=started.replayed,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MODULE_LIFECYCLE_RECOVERY_OPERATIONS",
|
||||
"ModuleLifecycleRecovery",
|
||||
"ModuleLifecycleRecoveryDeclaration",
|
||||
"ModuleLifecycleRecoveryError",
|
||||
"begin_module_installer_recovery",
|
||||
"begin_runtime_graph_recovery",
|
||||
"canonical_sha256",
|
||||
"lifecycle_session_factory",
|
||||
]
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
|
||||
from govoplan_core.core.access import DEFAULT_CAPABILITY_PROVIDERS
|
||||
from govoplan_core.core.discovery import iter_module_entry_points
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_core.db.session import get_database
|
||||
@@ -17,8 +18,8 @@ from govoplan_core.server.registry import parse_enabled_modules
|
||||
|
||||
MODULE_SETTINGS_KEY = "module_management"
|
||||
INSTALL_PLAN_KEY = "install_plan"
|
||||
REQUIRED_PLATFORM_MODULES = ("access",)
|
||||
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
|
||||
REQUIRED_PLATFORM_MODULES: tuple[str, ...] = ()
|
||||
PROTECTED_MODULES = ("admin",)
|
||||
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
|
||||
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
||||
INSTALL_PLAN_SOURCES = ("manual", "catalog")
|
||||
@@ -109,6 +110,7 @@ def startup_candidate_module_ids(
|
||||
if desired is not None:
|
||||
candidates.extend(str(item).strip() for item in desired if str(item).strip())
|
||||
candidates.extend(REQUIRED_PLATFORM_MODULES)
|
||||
candidates.extend(DEFAULT_CAPABILITY_PROVIDERS.values())
|
||||
if "admin" in fallback:
|
||||
candidates.append("admin")
|
||||
return tuple(dict.fromkeys(candidates))
|
||||
@@ -404,19 +406,118 @@ def plan_desired_enabled_modules(
|
||||
*,
|
||||
protected_modules: Iterable[str] = PROTECTED_MODULES,
|
||||
) -> ModuleStatePlan:
|
||||
requested = {str(item).strip() for item in requested_enabled if str(item).strip()}
|
||||
protected = {str(item).strip() for item in protected_modules if str(item).strip()}
|
||||
requested.update(protected)
|
||||
requested = _normalized_module_ids(requested_enabled)
|
||||
requested.update(_normalized_module_ids(protected_modules))
|
||||
missing = sorted(module_id for module_id in requested if module_id not in available)
|
||||
if missing:
|
||||
raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))
|
||||
|
||||
added_dependencies: set[str] = set()
|
||||
added_dependencies = _expand_enabled_module_closure(requested, available)
|
||||
ordered = _order_enabled_modules(requested, available, added_dependencies)
|
||||
return ModuleStatePlan(
|
||||
enabled_modules=tuple(dict.fromkeys(ordered)),
|
||||
added_dependencies=tuple(sorted(added_dependencies)),
|
||||
)
|
||||
|
||||
|
||||
def _normalized_module_ids(values: Iterable[str]) -> set[str]:
|
||||
return {str(item).strip() for item in values if str(item).strip()}
|
||||
|
||||
|
||||
def _expand_enabled_module_closure(
|
||||
requested: set[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
) -> set[str]:
|
||||
added: set[str] = set()
|
||||
while True:
|
||||
dependencies = {
|
||||
dependency
|
||||
for module_id in requested
|
||||
for dependency in available[module_id].dependencies
|
||||
}
|
||||
_require_installed_modules(
|
||||
dependencies,
|
||||
available,
|
||||
message="Required modules are not installed: ",
|
||||
)
|
||||
dependency_additions = dependencies - requested
|
||||
added.update(dependency_additions)
|
||||
requested.update(dependency_additions)
|
||||
|
||||
providers = _missing_capability_providers(requested, available)
|
||||
_require_installed_modules(
|
||||
providers,
|
||||
available,
|
||||
message="Required capability providers are not installed: ",
|
||||
)
|
||||
additions = providers - requested
|
||||
if not additions:
|
||||
return added
|
||||
added.update(additions)
|
||||
requested.update(additions)
|
||||
|
||||
|
||||
def _missing_capability_providers(
|
||||
requested: set[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
) -> set[str]:
|
||||
provided = {
|
||||
capability
|
||||
for module_id in requested
|
||||
for capability in available[module_id].capability_factories
|
||||
}
|
||||
return {
|
||||
provider_id
|
||||
for module_id in requested
|
||||
for capability in available[module_id].required_capabilities
|
||||
if capability not in provided
|
||||
for provider_id in (DEFAULT_CAPABILITY_PROVIDERS.get(capability),)
|
||||
if provider_id is not None
|
||||
}
|
||||
|
||||
|
||||
def _require_installed_modules(
|
||||
module_ids: set[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
*,
|
||||
message: str,
|
||||
) -> None:
|
||||
missing = sorted(module_id for module_id in module_ids if module_id not in available)
|
||||
if missing:
|
||||
raise ModuleManagementError(message + ", ".join(missing))
|
||||
|
||||
|
||||
def _order_enabled_modules(
|
||||
requested: set[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
added_dependencies: set[str],
|
||||
) -> list[str]:
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for module_id in sorted(requested):
|
||||
_visit_enabled_module(
|
||||
module_id,
|
||||
requested=requested,
|
||||
available=available,
|
||||
added_dependencies=added_dependencies,
|
||||
visiting=visiting,
|
||||
visited=visited,
|
||||
ordered=ordered,
|
||||
)
|
||||
return ordered
|
||||
|
||||
def visit(module_id: str) -> None:
|
||||
|
||||
def _visit_enabled_module(
|
||||
module_id: str,
|
||||
*,
|
||||
requested: set[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
added_dependencies: set[str],
|
||||
visiting: set[str],
|
||||
visited: set[str],
|
||||
ordered: list[str],
|
||||
) -> None:
|
||||
if module_id in visited:
|
||||
return
|
||||
if module_id in visiting:
|
||||
@@ -427,22 +528,25 @@ def plan_desired_enabled_modules(
|
||||
visiting.add(module_id)
|
||||
for dependency_id in manifest.dependencies:
|
||||
if dependency_id not in available:
|
||||
raise ModuleManagementError(f"Module {module_id!r} depends on uninstalled module {dependency_id!r}.")
|
||||
raise ModuleManagementError(
|
||||
f"Module {module_id!r} depends on uninstalled module {dependency_id!r}."
|
||||
)
|
||||
if dependency_id not in requested:
|
||||
added_dependencies.add(dependency_id)
|
||||
requested.add(dependency_id)
|
||||
visit(dependency_id)
|
||||
_visit_enabled_module(
|
||||
dependency_id,
|
||||
requested=requested,
|
||||
available=available,
|
||||
added_dependencies=added_dependencies,
|
||||
visiting=visiting,
|
||||
visited=visited,
|
||||
ordered=ordered,
|
||||
)
|
||||
visiting.remove(module_id)
|
||||
visited.add(module_id)
|
||||
ordered.append(module_id)
|
||||
|
||||
for module_id in sorted(requested):
|
||||
visit(module_id)
|
||||
return ModuleStatePlan(
|
||||
enabled_modules=tuple(dict.fromkeys(ordered)),
|
||||
added_dependencies=tuple(sorted(added_dependencies)),
|
||||
)
|
||||
|
||||
|
||||
def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
|
||||
dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import binascii
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -16,6 +17,11 @@ from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||
|
||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||
from govoplan_core.core.provider_governance import (
|
||||
external_provider_from_mapping,
|
||||
module_architecture_from_mapping,
|
||||
module_architecture_issues,
|
||||
)
|
||||
from govoplan_core.security.http_fetch import fetch_http_text, is_http_url
|
||||
|
||||
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||
@@ -238,7 +244,17 @@ def sign_module_package_catalog(
|
||||
|
||||
|
||||
def record_module_package_catalog_acceptance(validation: dict[str, object]) -> None:
|
||||
state_path = _configured_sequence_state_path()
|
||||
_record_catalog_acceptance(
|
||||
validation,
|
||||
state_path=_configured_sequence_state_path(),
|
||||
)
|
||||
|
||||
|
||||
def _record_catalog_acceptance(
|
||||
validation: dict[str, object],
|
||||
*,
|
||||
state_path: Path | None,
|
||||
) -> None:
|
||||
if state_path is None or validation.get("valid") is not True:
|
||||
return
|
||||
channel = validation.get("channel")
|
||||
@@ -613,6 +629,72 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
||||
"notes": _optional_str(value, "notes"),
|
||||
"tags": _string_list(value.get("tags")),
|
||||
}
|
||||
raw_architecture = value.get("architecture")
|
||||
if raw_architecture is not None:
|
||||
if not isinstance(raw_architecture, Mapping):
|
||||
raise ValueError(
|
||||
f"Module package catalog architecture for {module_id!r} must be an object."
|
||||
)
|
||||
architecture = module_architecture_from_mapping(raw_architecture)
|
||||
issues = module_architecture_issues(
|
||||
architecture,
|
||||
has_migrations=bool(item["migration_tasks"])
|
||||
or bool(item["migration_notes"]),
|
||||
)
|
||||
if issues:
|
||||
raise ValueError(
|
||||
f"Module package catalog architecture for {module_id!r} is invalid: "
|
||||
+ "; ".join(issues)
|
||||
)
|
||||
item["architecture"] = architecture.to_dict()
|
||||
raw_providers = value.get("external_providers")
|
||||
if raw_providers is not None:
|
||||
if not isinstance(raw_providers, list):
|
||||
raise ValueError(
|
||||
f"Module package catalog external_providers for {module_id!r} must be a list."
|
||||
)
|
||||
providers = []
|
||||
seen_provider_ids: set[str] = set()
|
||||
for raw_provider in raw_providers:
|
||||
if not isinstance(raw_provider, Mapping):
|
||||
raise ValueError(
|
||||
f"Module package catalog external provider entries for {module_id!r} must be objects."
|
||||
)
|
||||
provider = external_provider_from_mapping(raw_provider)
|
||||
if provider.module_id != module_id:
|
||||
raise ValueError(
|
||||
f"Module package catalog provider {provider.id!r} belongs to "
|
||||
f"{provider.module_id!r}, not {module_id!r}."
|
||||
)
|
||||
if provider.id in seen_provider_ids:
|
||||
raise ValueError(
|
||||
f"Module package catalog has duplicate provider {provider.id!r}."
|
||||
)
|
||||
seen_provider_ids.add(provider.id)
|
||||
providers.append(provider)
|
||||
if providers and "architecture" not in item:
|
||||
raise ValueError(
|
||||
f"Module package catalog {module_id!r} declares external providers without architecture metadata."
|
||||
)
|
||||
if providers:
|
||||
architecture_payload = item["architecture"]
|
||||
if not isinstance(architecture_payload, Mapping):
|
||||
raise ValueError(
|
||||
f"Module package catalog {module_id!r} has invalid architecture metadata."
|
||||
)
|
||||
architecture_modes = set(
|
||||
_string_list(architecture_payload.get("supported_authority_modes"))
|
||||
)
|
||||
provider_modes = {
|
||||
mode for provider in providers for mode in provider.authority_modes
|
||||
}
|
||||
missing_modes = provider_modes - architecture_modes
|
||||
if missing_modes:
|
||||
raise ValueError(
|
||||
f"Module package catalog {module_id!r} provider modes are missing from architecture metadata: "
|
||||
+ ", ".join(sorted(missing_modes))
|
||||
)
|
||||
item["external_providers"] = [provider.to_dict() for provider in providers]
|
||||
if not version_range_is_valid(
|
||||
version_min=item["current_version_min"] if isinstance(item["current_version_min"], str) else None,
|
||||
version_max_exclusive=item["current_version_max_exclusive"] if isinstance(item["current_version_max_exclusive"], str) else None,
|
||||
|
||||
@@ -4,8 +4,22 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||
|
||||
from govoplan_core.core.ownership import OwnershipProviderRegistration
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ExternalProviderDeclaration,
|
||||
ExternalProviderStateProviderRegistration,
|
||||
ModuleArchitectureDeclaration,
|
||||
)
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import APIRouter
|
||||
from govoplan_core.core.operations import OperationalCheckProviderRegistration
|
||||
from govoplan_core.core.search import (
|
||||
SearchProviderRegistration,
|
||||
SearchSourceProviderRegistration,
|
||||
)
|
||||
from govoplan_core.core.workflows import WorkflowDefinitionContribution
|
||||
|
||||
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||
@@ -57,6 +71,7 @@ class NavItem:
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
surface_id: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -68,6 +83,16 @@ class FrontendRoute:
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
surface_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PublicFrontendRoute:
|
||||
"""Explicitly allowlisted route that can render without authentication."""
|
||||
|
||||
path: str
|
||||
component: str
|
||||
order: int = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -80,8 +105,10 @@ class FrontendModule:
|
||||
asset_manifest_integrity: str | None = None
|
||||
asset_manifest_contract_version: str = "1"
|
||||
routes: tuple[FrontendRoute, ...] = ()
|
||||
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
||||
nav_items: tuple[NavItem, ...] = ()
|
||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||
view_surfaces: tuple[ViewSurface, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -196,6 +223,19 @@ class ModuleContext:
|
||||
DocumentationLayer = Literal["always", "configured", "available", "evidence"]
|
||||
DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"]
|
||||
DocumentationType = Literal["admin", "user"]
|
||||
DocumentationConfigurationState = Literal["enabled", "disabled", "inherited", "unavailable"]
|
||||
DocumentationSourceKind = Literal[
|
||||
"manifest",
|
||||
"route",
|
||||
"capability",
|
||||
"policy",
|
||||
"release_catalog",
|
||||
"configuration_package",
|
||||
"wiki",
|
||||
"repository",
|
||||
]
|
||||
DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
|
||||
CapabilityStability = Literal["experimental", "stable", "deprecated"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -234,6 +274,8 @@ class DocumentationTopic:
|
||||
i18n_key: str | None = None
|
||||
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
|
||||
source_module_id: str | None = None
|
||||
version_min: str | None = None
|
||||
version_max_exclusive: str | None = None
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -277,6 +319,53 @@ class DocumentationContext:
|
||||
data: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationConfigurationDecision:
|
||||
key: str
|
||||
state: DocumentationConfigurationState
|
||||
source: str | None = None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
DocumentationConfigurationResolver = Callable[
|
||||
[DocumentationContext, tuple[str, ...]],
|
||||
Mapping[str, DocumentationConfigurationDecision],
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationConfigurationProviderRegistration:
|
||||
keys: tuple[str, ...]
|
||||
resolve: DocumentationConfigurationResolver
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationSourceDefinition:
|
||||
id: str
|
||||
kind: DocumentationSourceKind
|
||||
label: str
|
||||
documentation_types: tuple[DocumentationType, ...] = ("admin",)
|
||||
condition: DocumentationCondition = field(default_factory=DocumentationCondition)
|
||||
state: DocumentationSourceState = "configured"
|
||||
state_reason: str | None = None
|
||||
provenance: Mapping[str, Any] = field(default_factory=dict)
|
||||
inspection: Mapping[str, Any] = field(default_factory=dict)
|
||||
link: DocumentationLink | None = None
|
||||
configuration_key: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CapabilityDocumentation:
|
||||
"""Provider-owned capability metadata safe for generic platform consumers."""
|
||||
|
||||
label: str
|
||||
summary: str
|
||||
contract_version: str | None = None
|
||||
stability: CapabilityStability = "stable"
|
||||
documentation_types: tuple[DocumentationType, ...] = ("admin",)
|
||||
audience: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class ResourceAclProvider(Protocol):
|
||||
resource_type: str
|
||||
|
||||
@@ -291,6 +380,10 @@ class ResourceAclProvider(Protocol):
|
||||
|
||||
|
||||
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
|
||||
TenantSummaryBatchProvider = Callable[
|
||||
[object, Sequence[str]],
|
||||
Mapping[str, Mapping[str, int]],
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -315,6 +408,7 @@ class DeleteVetoProviderRegistration:
|
||||
|
||||
RouteFactory = Callable[[ModuleContext], "APIRouter"]
|
||||
CapabilityFactory = Callable[[ModuleContext], object]
|
||||
PublicTenantResolver = Callable[[object, object], str | None]
|
||||
DocumentationProvider = Callable[[DocumentationContext], Iterable[DocumentationTopic]]
|
||||
LifecycleHook = Callable[[ModuleContext], None]
|
||||
|
||||
@@ -333,16 +427,42 @@ class ModuleManifest:
|
||||
permissions: tuple[PermissionDefinition, ...] = ()
|
||||
role_templates: tuple[RoleTemplate, ...] = ()
|
||||
route_factory: RouteFactory | None = None
|
||||
public_tenant_resolver: PublicTenantResolver | None = None
|
||||
migration_spec: MigrationSpec | None = None
|
||||
nav_items: tuple[NavItem, ...] = ()
|
||||
frontend: FrontendModule | None = None
|
||||
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
|
||||
ownership_providers: tuple[OwnershipProviderRegistration, ...] = ()
|
||||
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
|
||||
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
|
||||
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
|
||||
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
|
||||
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
|
||||
capability_documentation: Mapping[str, CapabilityDocumentation] = field(default_factory=dict)
|
||||
search_providers: tuple["SearchProviderRegistration", ...] = ()
|
||||
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
|
||||
operational_check_providers: tuple[
|
||||
"OperationalCheckProviderRegistration",
|
||||
...,
|
||||
] = ()
|
||||
architecture: ModuleArchitectureDeclaration | None = None
|
||||
external_providers: tuple[ExternalProviderDeclaration, ...] = ()
|
||||
external_provider_state_providers: tuple[
|
||||
ExternalProviderStateProviderRegistration,
|
||||
...,
|
||||
] = ()
|
||||
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
|
||||
on_activate: LifecycleHook | None = None
|
||||
on_deactivate: LifecycleHook | None = None
|
||||
documentation: tuple[DocumentationTopic, ...] = ()
|
||||
documentation_providers: tuple[DocumentationProvider, ...] = ()
|
||||
documentation_configuration_providers: tuple[
|
||||
DocumentationConfigurationProviderRegistration,
|
||||
...,
|
||||
] = ()
|
||||
documentation_sources: tuple[DocumentationSourceDefinition, ...] = ()
|
||||
# A renamed or extracted module may continue to own an established
|
||||
# permission namespace. This keeps persisted grants stable while the
|
||||
# runtime module ID changes.
|
||||
permission_namespace: str | None = None
|
||||
workflow_definitions: tuple["WorkflowDefinitionContribution", ...] = ()
|
||||
|
||||
@@ -33,6 +33,14 @@ class NotificationDispatchRequest:
|
||||
|
||||
@runtime_checkable
|
||||
class NotificationDispatchProvider(Protocol):
|
||||
def tenant_id_for_notification(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
notification_id: str,
|
||||
) -> str | None:
|
||||
...
|
||||
|
||||
def enqueue_notification(
|
||||
self,
|
||||
session: object,
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from heapq import nsmallest
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from typing import Any, Iterable, Protocol
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from govoplan_core.security.outbound_http import (
|
||||
OutboundHttpError,
|
||||
response_limit,
|
||||
validate_unpinned_sdk_http_url,
|
||||
)
|
||||
|
||||
|
||||
class StorageBackendError(RuntimeError):
|
||||
"""Base error for the deployment-owned object-storage boundary."""
|
||||
|
||||
|
||||
class StorageObjectMissing(StorageBackendError):
|
||||
"""Raised when a referenced object no longer exists."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StorageObjectInfo:
|
||||
key: str
|
||||
size_bytes: int
|
||||
modified_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StorageObjectPage:
|
||||
objects: tuple[StorageObjectInfo, ...]
|
||||
next_cursor: str | None = None
|
||||
|
||||
|
||||
class StorageBackend(Protocol):
|
||||
"""Shared byte-object storage used by modules without cross-module imports."""
|
||||
|
||||
name: str
|
||||
|
||||
def put_bytes(
|
||||
self,
|
||||
key: str,
|
||||
data: bytes,
|
||||
*,
|
||||
content_type: str | None = None,
|
||||
) -> None: ...
|
||||
|
||||
def get_bytes(self, key: str) -> bytes: ...
|
||||
|
||||
def iter_bytes(
|
||||
self,
|
||||
key: str,
|
||||
*,
|
||||
chunk_size: int = 1024 * 1024,
|
||||
) -> Iterable[bytes]: ...
|
||||
|
||||
def delete(self, key: str) -> None: ...
|
||||
|
||||
def exists(self, key: str) -> bool: ...
|
||||
|
||||
def stat(self, key: str) -> StorageObjectInfo: ...
|
||||
|
||||
def list_objects(
|
||||
self,
|
||||
*,
|
||||
prefix: str,
|
||||
after: str | None = None,
|
||||
limit: int = 500,
|
||||
) -> StorageObjectPage: ...
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LocalFilesystemStorageBackend:
|
||||
root: Path
|
||||
fallback_roots: tuple[Path, ...] = field(default_factory=tuple)
|
||||
name: str = "local"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.root = self.root.expanduser().resolve()
|
||||
self.fallback_roots = tuple(
|
||||
root.expanduser().resolve() for root in self.fallback_roots if root
|
||||
)
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _path_for_root(self, root: Path, key: str) -> Path:
|
||||
normalized = normalize_storage_key(key)
|
||||
path = (root / normalized).resolve()
|
||||
if not path.is_relative_to(root):
|
||||
raise StorageBackendError("Storage key escapes local storage root")
|
||||
return path
|
||||
|
||||
def _path(self, key: str) -> Path:
|
||||
return self._path_for_root(self.root, key)
|
||||
|
||||
def _readable_path(self, key: str) -> Path:
|
||||
primary = self._path(key)
|
||||
if primary.exists() and primary.is_file():
|
||||
return primary
|
||||
for root in self.fallback_roots:
|
||||
candidate = self._path_for_root(root, key)
|
||||
if candidate.exists() and candidate.is_file():
|
||||
return candidate
|
||||
raise StorageObjectMissing("Stored object does not exist")
|
||||
|
||||
def put_bytes(
|
||||
self,
|
||||
key: str,
|
||||
data: bytes,
|
||||
*,
|
||||
content_type: str | None = None,
|
||||
) -> None:
|
||||
del content_type
|
||||
path = self._path(key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=path.parent,
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
descriptor = -1
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
temporary.replace(path)
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
def get_bytes(self, key: str) -> bytes:
|
||||
return self._readable_path(key).read_bytes()
|
||||
|
||||
def iter_bytes(
|
||||
self,
|
||||
key: str,
|
||||
*,
|
||||
chunk_size: int = 1024 * 1024,
|
||||
) -> Iterable[bytes]:
|
||||
path = self._readable_path(key)
|
||||
with path.open("rb") as handle:
|
||||
while True:
|
||||
chunk = handle.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
path = self._path(key)
|
||||
if path.exists() and path.is_file():
|
||||
path.unlink()
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
try:
|
||||
self._readable_path(key)
|
||||
except StorageObjectMissing:
|
||||
return False
|
||||
return True
|
||||
|
||||
def stat(self, key: str) -> StorageObjectInfo:
|
||||
path = self._readable_path(key)
|
||||
metadata = path.stat()
|
||||
return StorageObjectInfo(
|
||||
key=normalize_storage_key(key),
|
||||
size_bytes=metadata.st_size,
|
||||
modified_at=datetime.fromtimestamp(
|
||||
metadata.st_mtime,
|
||||
tz=timezone.utc,
|
||||
),
|
||||
)
|
||||
|
||||
def list_objects(
|
||||
self,
|
||||
*,
|
||||
prefix: str,
|
||||
after: str | None = None,
|
||||
limit: int = 500,
|
||||
) -> StorageObjectPage:
|
||||
normalized_prefix = normalize_storage_prefix(prefix)
|
||||
normalized_after = normalize_storage_key(after) if after else None
|
||||
normalized_limit = max(1, min(int(limit), 5000))
|
||||
|
||||
def matching_objects() -> Iterable[StorageObjectInfo]:
|
||||
for path in _iter_local_files(self.root):
|
||||
key = path.relative_to(self.root).as_posix()
|
||||
if not key.startswith(normalized_prefix) or (
|
||||
normalized_after is not None and key <= normalized_after
|
||||
):
|
||||
continue
|
||||
metadata = path.stat()
|
||||
yield StorageObjectInfo(
|
||||
key=key,
|
||||
size_bytes=metadata.st_size,
|
||||
modified_at=datetime.fromtimestamp(
|
||||
metadata.st_mtime,
|
||||
tz=timezone.utc,
|
||||
),
|
||||
)
|
||||
|
||||
candidates = nsmallest(
|
||||
normalized_limit + 1,
|
||||
matching_objects(),
|
||||
key=lambda item: item.key,
|
||||
)
|
||||
has_more = len(candidates) > normalized_limit
|
||||
page = tuple(candidates[:normalized_limit])
|
||||
return StorageObjectPage(
|
||||
objects=page,
|
||||
next_cursor=page[-1].key if has_more and page else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class S3StorageBackend:
|
||||
bucket: str
|
||||
endpoint_url: str
|
||||
region_name: str
|
||||
access_key_id: str
|
||||
secret_access_key: str
|
||||
deployment_managed: bool = False
|
||||
endpoint_trusted: bool = False
|
||||
name: str = "s3"
|
||||
_client: Any = field(default=None, init=False, repr=False)
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
if self._client is not None:
|
||||
return self._client
|
||||
if self.deployment_managed:
|
||||
endpoint_url = _deployment_managed_garage_endpoint(self.endpoint_url)
|
||||
elif self.endpoint_trusted:
|
||||
endpoint_url = _trusted_deployment_endpoint(self.endpoint_url)
|
||||
else:
|
||||
try:
|
||||
endpoint_url = validate_unpinned_sdk_http_url(
|
||||
self.endpoint_url,
|
||||
label="Object storage S3 endpoint",
|
||||
)
|
||||
except OutboundHttpError as exc:
|
||||
raise StorageBackendError(str(exc)) from exc
|
||||
try:
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
except ModuleNotFoundError as exc:
|
||||
raise StorageBackendError(
|
||||
"boto3 is required for the S3 storage backend"
|
||||
) from exc
|
||||
options: dict[str, object] = {
|
||||
"endpoint_url": endpoint_url,
|
||||
"region_name": self.region_name,
|
||||
"aws_access_key_id": self.access_key_id,
|
||||
"aws_secret_access_key": self.secret_access_key,
|
||||
}
|
||||
if self.deployment_managed:
|
||||
options["config"] = Config(s3={"addressing_style": "path"})
|
||||
self._client = boto3.client("s3", **options)
|
||||
return self._client
|
||||
|
||||
def put_bytes(
|
||||
self,
|
||||
key: str,
|
||||
data: bytes,
|
||||
*,
|
||||
content_type: str | None = None,
|
||||
) -> None:
|
||||
normalized = normalize_storage_key(key)
|
||||
max_bytes = response_limit("file")
|
||||
if len(data) > max_bytes:
|
||||
raise StorageBackendError(
|
||||
f"Stored object exceeds the deployment limit of {max_bytes} bytes"
|
||||
)
|
||||
kwargs: dict[str, object] = {
|
||||
"Bucket": self.bucket,
|
||||
"Key": normalized,
|
||||
"Body": data,
|
||||
}
|
||||
if content_type:
|
||||
kwargs["ContentType"] = content_type
|
||||
try:
|
||||
self.client.put_object(**kwargs)
|
||||
except Exception as exc: # pragma: no cover - depends on S3 backend
|
||||
raise StorageBackendError(str(exc)) from exc
|
||||
|
||||
def get_bytes(self, key: str) -> bytes:
|
||||
normalized = normalize_storage_key(key)
|
||||
try:
|
||||
obj = self.client.get_object(
|
||||
Bucket=self.bucket,
|
||||
Key=normalized,
|
||||
)
|
||||
max_bytes = response_limit("file")
|
||||
body = obj["Body"]
|
||||
try:
|
||||
_reject_declared_object_size(obj, max_bytes=max_bytes)
|
||||
data = body.read(max_bytes + 1)
|
||||
if len(data) > max_bytes:
|
||||
raise StorageBackendError(
|
||||
"Stored object exceeds the deployment limit of "
|
||||
f"{max_bytes} bytes"
|
||||
)
|
||||
return data
|
||||
finally:
|
||||
if hasattr(body, "close"):
|
||||
body.close()
|
||||
except StorageBackendError:
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - depends on S3 backend
|
||||
if _s3_missing_error(exc):
|
||||
raise StorageObjectMissing("Stored object does not exist") from exc
|
||||
raise StorageBackendError(str(exc)) from exc
|
||||
|
||||
def iter_bytes(
|
||||
self,
|
||||
key: str,
|
||||
*,
|
||||
chunk_size: int = 1024 * 1024,
|
||||
) -> Iterable[bytes]:
|
||||
normalized = normalize_storage_key(key)
|
||||
try:
|
||||
obj = self.client.get_object(
|
||||
Bucket=self.bucket,
|
||||
Key=normalized,
|
||||
)
|
||||
max_bytes = response_limit("file")
|
||||
body = obj["Body"]
|
||||
try:
|
||||
_reject_declared_object_size(obj, max_bytes=max_bytes)
|
||||
total = 0
|
||||
while True:
|
||||
chunk = body.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise StorageBackendError(
|
||||
"Stored object exceeds the deployment limit of "
|
||||
f"{max_bytes} bytes"
|
||||
)
|
||||
yield chunk
|
||||
finally:
|
||||
if hasattr(body, "close"):
|
||||
body.close()
|
||||
except StorageBackendError:
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - depends on S3 backend
|
||||
if _s3_missing_error(exc):
|
||||
raise StorageObjectMissing("Stored object does not exist") from exc
|
||||
raise StorageBackendError(str(exc)) from exc
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
try:
|
||||
self.client.delete_object(
|
||||
Bucket=self.bucket,
|
||||
Key=normalize_storage_key(key),
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - depends on S3 backend
|
||||
raise StorageBackendError(str(exc)) from exc
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
try:
|
||||
self.client.head_object(
|
||||
Bucket=self.bucket,
|
||||
Key=normalize_storage_key(key),
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
if _s3_missing_error(exc):
|
||||
return False
|
||||
raise StorageBackendError(str(exc)) from exc
|
||||
|
||||
def stat(self, key: str) -> StorageObjectInfo:
|
||||
normalized = normalize_storage_key(key)
|
||||
try:
|
||||
response = self.client.head_object(
|
||||
Bucket=self.bucket,
|
||||
Key=normalized,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - depends on S3 backend
|
||||
if _s3_missing_error(exc):
|
||||
raise StorageObjectMissing("Stored object does not exist") from exc
|
||||
raise StorageBackendError(str(exc)) from exc
|
||||
try:
|
||||
size = int(response.get("ContentLength"))
|
||||
except (AttributeError, TypeError, ValueError) as exc:
|
||||
raise StorageBackendError(
|
||||
"S3 object metadata did not include a valid size"
|
||||
) from exc
|
||||
return StorageObjectInfo(
|
||||
key=normalized,
|
||||
size_bytes=size,
|
||||
modified_at=_storage_modified_at(response.get("LastModified")),
|
||||
)
|
||||
|
||||
def list_objects(
|
||||
self,
|
||||
*,
|
||||
prefix: str,
|
||||
after: str | None = None,
|
||||
limit: int = 500,
|
||||
) -> StorageObjectPage:
|
||||
normalized_prefix = normalize_storage_prefix(prefix)
|
||||
normalized_limit = max(1, min(int(limit), 1000))
|
||||
kwargs: dict[str, object] = {
|
||||
"Bucket": self.bucket,
|
||||
"Prefix": normalized_prefix,
|
||||
"MaxKeys": normalized_limit,
|
||||
}
|
||||
if after:
|
||||
kwargs["StartAfter"] = normalize_storage_key(after)
|
||||
try:
|
||||
response = self.client.list_objects_v2(**kwargs)
|
||||
except Exception as exc: # pragma: no cover - depends on S3 backend
|
||||
raise StorageBackendError(str(exc)) from exc
|
||||
objects = tuple(
|
||||
StorageObjectInfo(
|
||||
key=str(item["Key"]),
|
||||
size_bytes=int(item.get("Size") or 0),
|
||||
modified_at=_storage_modified_at(item.get("LastModified")),
|
||||
)
|
||||
for item in response.get("Contents", ())
|
||||
if isinstance(item, dict) and item.get("Key")
|
||||
)
|
||||
has_more = bool(response.get("IsTruncated"))
|
||||
return StorageObjectPage(
|
||||
objects=objects,
|
||||
next_cursor=objects[-1].key if has_more and objects else None,
|
||||
)
|
||||
|
||||
|
||||
def _storage_modified_at(value: object) -> datetime | None:
|
||||
if not isinstance(value, datetime):
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def configured_storage_backend(settings: object) -> StorageBackend:
|
||||
"""Build the deployment-wide object store from Core settings.
|
||||
|
||||
Modules own their metadata and key namespaces. The deployment owns the
|
||||
storage endpoint and credentials, so modules do not need to depend on the
|
||||
Files package merely to persist opaque generated bytes.
|
||||
"""
|
||||
|
||||
configured = (
|
||||
str(getattr(settings, "file_storage_backend", "local") or "local")
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if configured in {"local", "filesystem", "fs"}:
|
||||
raw_fallbacks = str(
|
||||
getattr(settings, "file_storage_local_fallback_roots", "") or ""
|
||||
)
|
||||
return LocalFilesystemStorageBackend(
|
||||
Path(
|
||||
str(
|
||||
getattr(
|
||||
settings,
|
||||
"file_storage_local_root",
|
||||
"runtime/files",
|
||||
)
|
||||
)
|
||||
),
|
||||
fallback_roots=tuple(
|
||||
Path(item.strip()) for item in raw_fallbacks.split(",") if item.strip()
|
||||
),
|
||||
)
|
||||
if configured in {"s3", "garage"}:
|
||||
return S3StorageBackend(
|
||||
bucket=str(
|
||||
getattr(settings, "file_storage_s3_bucket", None)
|
||||
or getattr(settings, "s3_bucket", "files")
|
||||
),
|
||||
endpoint_url=str(
|
||||
getattr(settings, "file_storage_s3_endpoint_url", None)
|
||||
or getattr(settings, "s3_endpoint_url", "")
|
||||
),
|
||||
region_name=str(
|
||||
getattr(settings, "file_storage_s3_region", None)
|
||||
or getattr(settings, "s3_region", "")
|
||||
),
|
||||
access_key_id=str(
|
||||
getattr(settings, "file_storage_s3_access_key_id", None)
|
||||
or getattr(settings, "s3_access_key_id", "")
|
||||
),
|
||||
secret_access_key=str(
|
||||
getattr(
|
||||
settings,
|
||||
"file_storage_s3_secret_access_key",
|
||||
None,
|
||||
)
|
||||
or getattr(settings, "s3_secret_access_key", "")
|
||||
),
|
||||
deployment_managed=bool(
|
||||
getattr(
|
||||
settings,
|
||||
"file_storage_s3_deployment_managed",
|
||||
False,
|
||||
)
|
||||
),
|
||||
endpoint_trusted=bool(
|
||||
getattr(
|
||||
settings,
|
||||
"file_storage_s3_endpoint_trusted",
|
||||
False,
|
||||
)
|
||||
),
|
||||
)
|
||||
raise StorageBackendError(f"Unsupported object storage backend: {configured}")
|
||||
|
||||
|
||||
def normalize_storage_key(value: str) -> str:
|
||||
candidate = str(value or "").strip().replace("\\", "/")
|
||||
parts = candidate.split("/")
|
||||
if (
|
||||
not candidate
|
||||
or candidate.startswith("/")
|
||||
or any(part in {"", ".", ".."} for part in parts)
|
||||
or any(ord(character) < 32 for character in candidate)
|
||||
):
|
||||
raise StorageBackendError("Storage key is not a safe relative key")
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def normalize_storage_prefix(value: str) -> str:
|
||||
candidate = str(value or "").strip().replace("\\", "/")
|
||||
if not candidate:
|
||||
return ""
|
||||
trailing_slash = candidate.endswith("/")
|
||||
normalized = normalize_storage_key(candidate.rstrip("/"))
|
||||
return normalized + ("/" if trailing_slash else "")
|
||||
|
||||
|
||||
def _reject_declared_object_size(obj: object, *, max_bytes: int) -> None:
|
||||
if not isinstance(obj, dict):
|
||||
return
|
||||
try:
|
||||
declared_size = int(obj.get("ContentLength"))
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if declared_size > max_bytes:
|
||||
raise StorageBackendError(
|
||||
f"Stored object exceeds the deployment limit of {max_bytes} bytes"
|
||||
)
|
||||
|
||||
|
||||
def _iter_local_files(root: Path):
|
||||
for entry in sorted(root.iterdir(), key=lambda item: item.name):
|
||||
if entry.is_symlink():
|
||||
continue
|
||||
if entry.is_dir():
|
||||
yield from _iter_local_files(entry)
|
||||
elif entry.is_file():
|
||||
yield entry
|
||||
|
||||
|
||||
def _s3_missing_error(exc: Exception) -> bool:
|
||||
response = getattr(exc, "response", None)
|
||||
if not isinstance(response, dict):
|
||||
return False
|
||||
error = response.get("Error")
|
||||
metadata = response.get("ResponseMetadata")
|
||||
code = str(error.get("Code") if isinstance(error, dict) else "")
|
||||
status_code = metadata.get("HTTPStatusCode") if isinstance(metadata, dict) else None
|
||||
return code in {"404", "NoSuchKey", "NotFound"} or status_code == 404
|
||||
|
||||
|
||||
def _deployment_managed_garage_endpoint(value: str) -> str:
|
||||
endpoint = str(value or "").strip()
|
||||
if endpoint != "http://garage:3900":
|
||||
raise StorageBackendError(
|
||||
"Deployment-managed S3 trust is restricted to http://garage:3900"
|
||||
)
|
||||
return endpoint
|
||||
|
||||
|
||||
def _trusted_deployment_endpoint(value: str) -> str:
|
||||
endpoint = str(value or "").strip()
|
||||
parsed = urlsplit(endpoint)
|
||||
try:
|
||||
parsed.port
|
||||
except ValueError as exc:
|
||||
raise StorageBackendError(
|
||||
"Deployment-trusted S3 endpoint has an invalid port"
|
||||
) from exc
|
||||
if (
|
||||
parsed.scheme.lower() != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
or parsed.path not in {"", "/"}
|
||||
):
|
||||
raise StorageBackendError(
|
||||
"Deployment-trusted S3 endpoint must be an HTTPS origin without "
|
||||
"credentials, query, fragment, or path"
|
||||
)
|
||||
return endpoint.rstrip("/")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LocalFilesystemStorageBackend",
|
||||
"S3StorageBackend",
|
||||
"StorageBackend",
|
||||
"StorageBackendError",
|
||||
"StorageObjectInfo",
|
||||
"StorageObjectMissing",
|
||||
"StorageObjectPage",
|
||||
"configured_storage_backend",
|
||||
"normalize_storage_key",
|
||||
"normalize_storage_prefix",
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
|
||||
OperationalCheckState = Literal["ok", "warning", "error", "inactive"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperationalCheck:
|
||||
"""A bounded module-owned runtime check exposed through the Ops module."""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
state: OperationalCheckState
|
||||
detail: str
|
||||
readiness_critical: bool = False
|
||||
metrics: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"label": self.label,
|
||||
"state": self.state,
|
||||
"detail": self.detail,
|
||||
"readiness_critical": self.readiness_critical,
|
||||
"metrics": dict(self.metrics),
|
||||
}
|
||||
|
||||
|
||||
OperationalCheckProvider = Callable[[], OperationalCheck]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperationalCheckProviderRegistration:
|
||||
"""Register one independently executable operational check."""
|
||||
|
||||
module_id: str
|
||||
check_id: str
|
||||
provider: OperationalCheckProvider
|
||||
cache_seconds: int = 60
|
||||
|
||||
@@ -1,14 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
ORGANIZATIONS_MODULE_ID = "organizations"
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY = "organizations.directory"
|
||||
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY = (
|
||||
"organizations.hierarchyDirectory"
|
||||
)
|
||||
|
||||
OrganizationStatus = Literal["active", "inactive", "suspended"]
|
||||
OrganizationResolutionStatus = Literal[
|
||||
"active",
|
||||
"inactive",
|
||||
"missing",
|
||||
"unreachable",
|
||||
"invalid",
|
||||
]
|
||||
OrganizationHierarchyDirection = Literal["ancestors", "descendants"]
|
||||
OrganizationLifecycleResource = Literal[
|
||||
"unit_type",
|
||||
"structure",
|
||||
"relation_type",
|
||||
"unit",
|
||||
"relation",
|
||||
"function_type",
|
||||
"function",
|
||||
]
|
||||
OrganizationLifecycleAction = Literal[
|
||||
"created",
|
||||
"updated",
|
||||
"moved",
|
||||
"deactivated",
|
||||
]
|
||||
ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION = 1
|
||||
ORGANIZATION_LIFECYCLE_RESOURCES: tuple[
|
||||
OrganizationLifecycleResource,
|
||||
...,
|
||||
] = (
|
||||
"unit_type",
|
||||
"structure",
|
||||
"relation_type",
|
||||
"unit",
|
||||
"relation",
|
||||
"function_type",
|
||||
"function",
|
||||
)
|
||||
ORGANIZATION_LIFECYCLE_ACTIONS: tuple[
|
||||
OrganizationLifecycleAction,
|
||||
...,
|
||||
] = ("created", "updated", "moved", "deactivated")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -35,6 +79,143 @@ class OrganizationFunctionRef:
|
||||
delegable: bool = False
|
||||
act_in_place_allowed: bool = False
|
||||
status: OrganizationStatus = "active"
|
||||
settings: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationUnitTypeRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
slug: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
status: OrganizationStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationFunctionTypeRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
slug: str
|
||||
name: str
|
||||
organization_unit_type_id: str | None = None
|
||||
description: str | None = None
|
||||
delegable: bool = False
|
||||
act_in_place_allowed: bool = False
|
||||
status: OrganizationStatus = "active"
|
||||
settings: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationStructureRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
slug: str
|
||||
name: str
|
||||
structure_kind: str
|
||||
description: str | None = None
|
||||
status: OrganizationStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationRelationTypeRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
slug: str
|
||||
name: str
|
||||
structure_id: str | None = None
|
||||
source_unit_type_id: str | None = None
|
||||
target_unit_type_id: str | None = None
|
||||
is_hierarchical: bool = True
|
||||
allow_cycles: bool = False
|
||||
description: str | None = None
|
||||
status: OrganizationStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationHierarchyCatalogRef:
|
||||
tenant_id: str
|
||||
structures: tuple[OrganizationStructureRef, ...] = ()
|
||||
relation_types: tuple[OrganizationRelationTypeRef, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationHierarchyEdgeRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
structure: OrganizationStructureRef
|
||||
relation_type: OrganizationRelationTypeRef
|
||||
source_unit_id: str
|
||||
target_unit_id: str
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
status: OrganizationStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationHierarchyMatchRef:
|
||||
unit: OrganizationUnitRef
|
||||
depth: int
|
||||
path: tuple[OrganizationHierarchyEdgeRef, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationHierarchyResolution:
|
||||
tenant_id: str
|
||||
root_unit_id: str
|
||||
direction: OrganizationHierarchyDirection
|
||||
structure_id: str
|
||||
relation_type_ids: tuple[str, ...]
|
||||
max_depth: int
|
||||
status: OrganizationResolutionStatus
|
||||
root: OrganizationUnitRef | None = None
|
||||
matches: tuple[OrganizationHierarchyMatchRef, ...] = ()
|
||||
cycle_detected: bool = False
|
||||
depth_limited: bool = False
|
||||
diagnostics: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationHierarchyPathResolution:
|
||||
tenant_id: str
|
||||
source_unit_id: str
|
||||
target_unit_id: str
|
||||
direction: OrganizationHierarchyDirection
|
||||
structure_id: str
|
||||
relation_type_ids: tuple[str, ...]
|
||||
max_depth: int
|
||||
status: OrganizationResolutionStatus
|
||||
source: OrganizationUnitRef | None = None
|
||||
target: OrganizationUnitRef | None = None
|
||||
path: tuple[OrganizationHierarchyEdgeRef, ...] = ()
|
||||
cycle_detected: bool = False
|
||||
depth_limited: bool = False
|
||||
diagnostics: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationFunctionTypeResolution:
|
||||
tenant_id: str
|
||||
function_type_id: str
|
||||
requested_unit_ids: tuple[str, ...]
|
||||
status: OrganizationResolutionStatus
|
||||
function_type: OrganizationFunctionTypeRef | None = None
|
||||
matches: tuple[OrganizationFunctionRef, ...] = ()
|
||||
missing_unit_ids: tuple[str, ...] = ()
|
||||
inactive_unit_ids: tuple[str, ...] = ()
|
||||
diagnostics: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationUnitTypeResolution:
|
||||
tenant_id: str
|
||||
unit_type_id: str
|
||||
status: OrganizationResolutionStatus
|
||||
unit_type: OrganizationUnitTypeRef | None = None
|
||||
structure_id: str | None = None
|
||||
root_unit_id: str | None = None
|
||||
matches: tuple[OrganizationUnitRef, ...] = ()
|
||||
diagnostics: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -55,3 +236,156 @@ class OrganizationDirectory(Protocol):
|
||||
include_subunits: bool = False,
|
||||
) -> Sequence[OrganizationFunctionRef]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class OrganizationHierarchyDirectory(Protocol):
|
||||
def hierarchy_catalog(
|
||||
self,
|
||||
tenant_id: str,
|
||||
) -> OrganizationHierarchyCatalogRef:
|
||||
...
|
||||
|
||||
def get_unit_type(
|
||||
self,
|
||||
tenant_id: str,
|
||||
unit_type_id: str,
|
||||
) -> OrganizationUnitTypeRef | None:
|
||||
...
|
||||
|
||||
def get_function_type(
|
||||
self,
|
||||
tenant_id: str,
|
||||
function_type_id: str,
|
||||
) -> OrganizationFunctionTypeRef | None:
|
||||
...
|
||||
|
||||
def resolve_functions_by_type(
|
||||
self,
|
||||
tenant_id: str,
|
||||
function_type_id: str,
|
||||
*,
|
||||
organization_unit_ids: Sequence[str] = (),
|
||||
) -> OrganizationFunctionTypeResolution:
|
||||
...
|
||||
|
||||
def resolve_units_by_type(
|
||||
self,
|
||||
tenant_id: str,
|
||||
unit_type_id: str,
|
||||
*,
|
||||
structure_id: str | None = None,
|
||||
root_unit_id: str | None = None,
|
||||
relation_type_ids: Sequence[str] = (),
|
||||
direction: OrganizationHierarchyDirection = "descendants",
|
||||
max_depth: int = 10,
|
||||
) -> OrganizationUnitTypeResolution:
|
||||
...
|
||||
|
||||
def resolve_hierarchy_relatives(
|
||||
self,
|
||||
tenant_id: str,
|
||||
organization_unit_ids: Sequence[str],
|
||||
*,
|
||||
structure_id: str,
|
||||
relation_type_ids: Sequence[str] = (),
|
||||
direction: OrganizationHierarchyDirection = "ancestors",
|
||||
max_depth: int = 10,
|
||||
) -> Sequence[OrganizationHierarchyResolution]:
|
||||
...
|
||||
|
||||
def resolve_hierarchy_paths(
|
||||
self,
|
||||
tenant_id: str,
|
||||
unit_pairs: Sequence[tuple[str, str]],
|
||||
*,
|
||||
structure_id: str,
|
||||
relation_type_ids: Sequence[str] = (),
|
||||
direction: OrganizationHierarchyDirection = "descendants",
|
||||
max_depth: int = 10,
|
||||
) -> Sequence[OrganizationHierarchyPathResolution]:
|
||||
...
|
||||
|
||||
|
||||
def organization_directory(
|
||||
registry: object | None,
|
||||
) -> OrganizationDirectory | None:
|
||||
capability = _capability(registry, CAPABILITY_ORGANIZATION_DIRECTORY)
|
||||
return capability if isinstance(capability, OrganizationDirectory) else None
|
||||
|
||||
|
||||
def organization_hierarchy_directory(
|
||||
registry: object | None,
|
||||
) -> OrganizationHierarchyDirectory | None:
|
||||
capability = _capability(
|
||||
registry,
|
||||
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
|
||||
)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, OrganizationHierarchyDirectory)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def organization_lifecycle_event_type(
|
||||
resource: OrganizationLifecycleResource,
|
||||
action: OrganizationLifecycleAction,
|
||||
) -> str:
|
||||
if resource not in ORGANIZATION_LIFECYCLE_RESOURCES:
|
||||
raise ValueError("Unsupported organization lifecycle resource.")
|
||||
if action not in ORGANIZATION_LIFECYCLE_ACTIONS:
|
||||
raise ValueError("Unsupported organization lifecycle action.")
|
||||
return f"organizations.{resource}.{action}.v1"
|
||||
|
||||
|
||||
ORGANIZATION_DIRECTORY_INVALIDATION_EVENT_TYPES = frozenset(
|
||||
organization_lifecycle_event_type(resource, action)
|
||||
for resource in ORGANIZATION_LIFECYCLE_RESOURCES
|
||||
for action in ORGANIZATION_LIFECYCLE_ACTIONS
|
||||
)
|
||||
|
||||
|
||||
def _capability(registry: object | None, name: str) -> object | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_ORGANIZATION_DIRECTORY",
|
||||
"CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY",
|
||||
"ORGANIZATION_DIRECTORY_INVALIDATION_EVENT_TYPES",
|
||||
"ORGANIZATION_LIFECYCLE_ACTIONS",
|
||||
"ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION",
|
||||
"ORGANIZATION_LIFECYCLE_RESOURCES",
|
||||
"ORGANIZATIONS_MODULE_ID",
|
||||
"OrganizationDirectory",
|
||||
"OrganizationFunctionRef",
|
||||
"OrganizationFunctionTypeRef",
|
||||
"OrganizationFunctionTypeResolution",
|
||||
"OrganizationHierarchyDirection",
|
||||
"OrganizationHierarchyCatalogRef",
|
||||
"OrganizationHierarchyDirectory",
|
||||
"OrganizationHierarchyEdgeRef",
|
||||
"OrganizationHierarchyMatchRef",
|
||||
"OrganizationHierarchyPathResolution",
|
||||
"OrganizationHierarchyResolution",
|
||||
"OrganizationLifecycleAction",
|
||||
"OrganizationLifecycleResource",
|
||||
"OrganizationRelationTypeRef",
|
||||
"OrganizationResolutionStatus",
|
||||
"OrganizationStatus",
|
||||
"OrganizationStructureRef",
|
||||
"OrganizationUnitRef",
|
||||
"OrganizationUnitTypeRef",
|
||||
"OrganizationUnitTypeResolution",
|
||||
"organization_directory",
|
||||
"organization_hierarchy_directory",
|
||||
"organization_lifecycle_event_type",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,965 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import StrEnum
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin, utcnow
|
||||
|
||||
|
||||
class OwnershipTransferKind(StrEnum):
|
||||
OWNER_INITIATED = "owner_initiated"
|
||||
TARGET_REQUESTED = "target_requested"
|
||||
ADMINISTRATIVE_RECOVERY = "administrative_recovery"
|
||||
|
||||
|
||||
class OwnershipTransferStatus(StrEnum):
|
||||
AWAITING_OWNER_APPROVAL = "awaiting_owner_approval"
|
||||
AWAITING_TARGET_ACCEPTANCE = "awaiting_target_acceptance"
|
||||
AWAITING_RECOVERY_APPROVALS = "awaiting_recovery_approvals"
|
||||
RECOVERY_SCHEDULED = "recovery_scheduled"
|
||||
COMPLETED = "completed"
|
||||
DECLINED = "declined"
|
||||
CANCELLED = "cancelled"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
TERMINAL_OWNERSHIP_TRANSFER_STATUSES = frozenset(
|
||||
{
|
||||
OwnershipTransferStatus.COMPLETED.value,
|
||||
OwnershipTransferStatus.DECLINED.value,
|
||||
OwnershipTransferStatus.CANCELLED.value,
|
||||
OwnershipTransferStatus.EXPIRED.value,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class OwnershipTransferError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class OwnershipAuthorizationError(PermissionError):
|
||||
pass
|
||||
|
||||
|
||||
class OwnershipIdempotencyConflict(OwnershipTransferError):
|
||||
pass
|
||||
|
||||
|
||||
class OwnershipTransferExpired(OwnershipTransferError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnershipSubjectRef:
|
||||
type: str
|
||||
id: str
|
||||
label: str | None = None
|
||||
scopes: frozenset[str] = frozenset()
|
||||
group_ids: frozenset[str] = frozenset()
|
||||
recently_authenticated: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.type.strip() or not self.id.strip():
|
||||
raise ValueError("Ownership subjects require a type and id")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnershipResourceRef:
|
||||
module_id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.module_id.strip() or not self.resource_type.strip() or not self.resource_id.strip():
|
||||
raise ValueError("Ownership resources require module, type, and id")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnershipActionDecision:
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
requirements: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnershipTransferPolicy:
|
||||
default_expiry_days: int = 7
|
||||
min_expiry_days: int = 1
|
||||
max_expiry_days: int = 30
|
||||
recovery_assurance_profile: str = "standard"
|
||||
recovery_required_approvals: int = 2
|
||||
recovery_delay_hours: int = 24
|
||||
recent_authentication_required: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not 1 <= self.min_expiry_days <= self.default_expiry_days <= self.max_expiry_days:
|
||||
raise ValueError("Ownership transfer expiry policy is inconsistent")
|
||||
if self.recovery_required_approvals < 1:
|
||||
raise ValueError("Ownership recovery requires at least one approval")
|
||||
if self.recovery_delay_hours < 0:
|
||||
raise ValueError("Ownership recovery delay cannot be negative")
|
||||
|
||||
@classmethod
|
||||
def development(cls) -> OwnershipTransferPolicy:
|
||||
return cls(
|
||||
recovery_assurance_profile="development-single-admin",
|
||||
recovery_required_approvals=1,
|
||||
recovery_delay_hours=0,
|
||||
recent_authentication_required=False,
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ResourceOwnershipProvider(Protocol):
|
||||
def current_owner(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
resource_id: str,
|
||||
) -> OwnershipSubjectRef | None:
|
||||
...
|
||||
|
||||
def authorize_ownership_action(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
resource_id: str,
|
||||
action: str,
|
||||
actor: OwnershipSubjectRef,
|
||||
current_owner: OwnershipSubjectRef,
|
||||
target_owner: OwnershipSubjectRef,
|
||||
) -> OwnershipActionDecision:
|
||||
...
|
||||
|
||||
def apply_owner(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
resource_id: str,
|
||||
expected_owner: OwnershipSubjectRef,
|
||||
target_owner: OwnershipSubjectRef,
|
||||
actor: OwnershipSubjectRef,
|
||||
reason: str | None,
|
||||
) -> None:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnershipProviderRegistration:
|
||||
resource_type: str
|
||||
provider: ResourceOwnershipProvider
|
||||
module_id: str | None = None
|
||||
|
||||
|
||||
class OwnershipTransfer(Base, TimestampMixin):
|
||||
__tablename__ = "core_ownership_transfers"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"resource_module",
|
||||
"idempotency_key",
|
||||
name="uq_core_ownership_transfer_idempotency",
|
||||
),
|
||||
Index(
|
||||
"ix_core_ownership_transfer_resource",
|
||||
"tenant_id",
|
||||
"resource_module",
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
"status",
|
||||
),
|
||||
Index(
|
||||
"ix_core_ownership_transfer_expiry",
|
||||
"status",
|
||||
"expires_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
default=lambda: str(uuid.uuid4()),
|
||||
)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
resource_module: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
resource_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
resource_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
current_owner_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
current_owner_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
target_owner_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
target_owner_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
initiated_by_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
initiated_by_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
owner_approved_by_type: Mapped[str | None] = mapped_column(String(40))
|
||||
owner_approved_by_id: Mapped[str | None] = mapped_column(String(255))
|
||||
target_accepted_by_type: Mapped[str | None] = mapped_column(String(40))
|
||||
target_accepted_by_id: Mapped[str | None] = mapped_column(String(255))
|
||||
reason: Mapped[str | None] = mapped_column(Text)
|
||||
assurance_profile: Mapped[str | None] = mapped_column(String(80))
|
||||
required_approvals: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
approvals: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||
decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
canonical_request_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
execute_after: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
declined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
metadata_: Mapped[dict[str, Any]] = mapped_column(
|
||||
"metadata",
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def start_owner_initiated_transfer(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
resource: OwnershipResourceRef,
|
||||
provider: ResourceOwnershipProvider,
|
||||
actor: OwnershipSubjectRef,
|
||||
target_owner: OwnershipSubjectRef,
|
||||
idempotency_key: str,
|
||||
reason: str | None = None,
|
||||
expiry_days: int | None = None,
|
||||
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
|
||||
now: datetime | None = None,
|
||||
) -> OwnershipTransfer:
|
||||
return _start_transfer(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
resource=resource,
|
||||
provider=provider,
|
||||
actor=actor,
|
||||
target_owner=target_owner,
|
||||
kind=OwnershipTransferKind.OWNER_INITIATED,
|
||||
initial_status=OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE,
|
||||
authorization_action="propose_transfer",
|
||||
idempotency_key=idempotency_key,
|
||||
reason=reason,
|
||||
expiry_days=expiry_days,
|
||||
policy=policy,
|
||||
now=now,
|
||||
assurance_profile=None,
|
||||
required_approvals=1,
|
||||
execute_after=None,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
def request_ownership(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
resource: OwnershipResourceRef,
|
||||
provider: ResourceOwnershipProvider,
|
||||
actor: OwnershipSubjectRef,
|
||||
target_owner: OwnershipSubjectRef | None = None,
|
||||
idempotency_key: str,
|
||||
reason: str | None = None,
|
||||
expiry_days: int | None = None,
|
||||
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
|
||||
now: datetime | None = None,
|
||||
) -> OwnershipTransfer:
|
||||
requested_owner = target_owner or actor
|
||||
return _start_transfer(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
resource=resource,
|
||||
provider=provider,
|
||||
actor=actor,
|
||||
target_owner=requested_owner,
|
||||
kind=OwnershipTransferKind.TARGET_REQUESTED,
|
||||
initial_status=OwnershipTransferStatus.AWAITING_OWNER_APPROVAL,
|
||||
authorization_action="request_ownership",
|
||||
idempotency_key=idempotency_key,
|
||||
reason=reason,
|
||||
expiry_days=expiry_days,
|
||||
policy=policy,
|
||||
now=now,
|
||||
assurance_profile=None,
|
||||
required_approvals=1,
|
||||
execute_after=None,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
def approve_ownership_request(
|
||||
session: Session,
|
||||
*,
|
||||
transfer: OwnershipTransfer,
|
||||
provider: ResourceOwnershipProvider,
|
||||
actor: OwnershipSubjectRef,
|
||||
now: datetime | None = None,
|
||||
) -> OwnershipTransfer:
|
||||
effective_now = _utc(now)
|
||||
_require_active(session, transfer, effective_now)
|
||||
if (
|
||||
transfer.kind != OwnershipTransferKind.TARGET_REQUESTED.value
|
||||
or transfer.status != OwnershipTransferStatus.AWAITING_OWNER_APPROVAL.value
|
||||
):
|
||||
raise OwnershipTransferError("Ownership request is not awaiting owner approval")
|
||||
_authorize(provider, session, transfer, actor, "approve_requested_transfer")
|
||||
transfer.owner_approved_by_type = actor.type
|
||||
transfer.owner_approved_by_id = actor.id
|
||||
transfer.status = OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE.value
|
||||
_touch(transfer)
|
||||
_record_decision(
|
||||
transfer,
|
||||
action="owner_approved",
|
||||
actor=actor,
|
||||
decided_at=effective_now,
|
||||
)
|
||||
_emit_transfer_event(session, transfer, "owner_approved", actor)
|
||||
session.flush()
|
||||
return transfer
|
||||
|
||||
|
||||
def accept_ownership_transfer(
|
||||
session: Session,
|
||||
*,
|
||||
transfer: OwnershipTransfer,
|
||||
provider: ResourceOwnershipProvider,
|
||||
actor: OwnershipSubjectRef,
|
||||
now: datetime | None = None,
|
||||
) -> OwnershipTransfer:
|
||||
effective_now = _utc(now)
|
||||
_require_active(session, transfer, effective_now)
|
||||
if transfer.status != OwnershipTransferStatus.AWAITING_TARGET_ACCEPTANCE.value:
|
||||
raise OwnershipTransferError("Ownership transfer is not awaiting target acceptance")
|
||||
action = (
|
||||
"accept_group_transfer"
|
||||
if transfer.target_owner_type == "group"
|
||||
else "accept_transfer"
|
||||
)
|
||||
_authorize(provider, session, transfer, actor, action)
|
||||
current_owner = _owner_ref(transfer, target=False)
|
||||
target_owner = _owner_ref(transfer, target=True)
|
||||
provider.apply_owner(
|
||||
session,
|
||||
tenant_id=transfer.tenant_id,
|
||||
resource_id=transfer.resource_id,
|
||||
expected_owner=current_owner,
|
||||
target_owner=target_owner,
|
||||
actor=actor,
|
||||
reason=transfer.reason,
|
||||
)
|
||||
transfer.target_accepted_by_type = actor.type
|
||||
transfer.target_accepted_by_id = actor.id
|
||||
transfer.status = OwnershipTransferStatus.COMPLETED.value
|
||||
transfer.completed_at = effective_now
|
||||
_touch(transfer)
|
||||
_record_decision(
|
||||
transfer,
|
||||
action="accepted",
|
||||
actor=actor,
|
||||
decided_at=effective_now,
|
||||
)
|
||||
_emit_transfer_event(session, transfer, "completed", actor)
|
||||
session.flush()
|
||||
return transfer
|
||||
|
||||
|
||||
def decline_ownership_transfer(
|
||||
session: Session,
|
||||
*,
|
||||
transfer: OwnershipTransfer,
|
||||
provider: ResourceOwnershipProvider,
|
||||
actor: OwnershipSubjectRef,
|
||||
now: datetime | None = None,
|
||||
) -> OwnershipTransfer:
|
||||
effective_now = _utc(now)
|
||||
_require_active(session, transfer, effective_now)
|
||||
_authorize(provider, session, transfer, actor, "decline_transfer")
|
||||
transfer.status = OwnershipTransferStatus.DECLINED.value
|
||||
transfer.declined_at = effective_now
|
||||
_touch(transfer)
|
||||
_record_decision(
|
||||
transfer,
|
||||
action="declined",
|
||||
actor=actor,
|
||||
decided_at=effective_now,
|
||||
)
|
||||
_emit_transfer_event(session, transfer, "declined", actor)
|
||||
session.flush()
|
||||
return transfer
|
||||
|
||||
|
||||
def cancel_ownership_transfer(
|
||||
session: Session,
|
||||
*,
|
||||
transfer: OwnershipTransfer,
|
||||
provider: ResourceOwnershipProvider,
|
||||
actor: OwnershipSubjectRef,
|
||||
now: datetime | None = None,
|
||||
) -> OwnershipTransfer:
|
||||
effective_now = _utc(now)
|
||||
_require_active(session, transfer, effective_now)
|
||||
_authorize(provider, session, transfer, actor, "cancel_transfer")
|
||||
transfer.status = OwnershipTransferStatus.CANCELLED.value
|
||||
transfer.cancelled_at = effective_now
|
||||
_touch(transfer)
|
||||
_record_decision(
|
||||
transfer,
|
||||
action="cancelled",
|
||||
actor=actor,
|
||||
decided_at=effective_now,
|
||||
)
|
||||
_emit_transfer_event(session, transfer, "cancelled", actor)
|
||||
session.flush()
|
||||
return transfer
|
||||
|
||||
|
||||
def start_administrative_recovery(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
resource: OwnershipResourceRef,
|
||||
provider: ResourceOwnershipProvider,
|
||||
actor: OwnershipSubjectRef,
|
||||
target_owner: OwnershipSubjectRef,
|
||||
idempotency_key: str,
|
||||
reason: str,
|
||||
policy: OwnershipTransferPolicy = OwnershipTransferPolicy(),
|
||||
now: datetime | None = None,
|
||||
) -> OwnershipTransfer:
|
||||
if not reason.strip():
|
||||
raise OwnershipTransferError("Administrative recovery requires a reason")
|
||||
if policy.recent_authentication_required and not actor.recently_authenticated:
|
||||
raise OwnershipAuthorizationError(
|
||||
"Administrative recovery requires recent authentication"
|
||||
)
|
||||
effective_now = _utc(now)
|
||||
transfer = _start_transfer(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
resource=resource,
|
||||
provider=provider,
|
||||
actor=actor,
|
||||
target_owner=target_owner,
|
||||
kind=OwnershipTransferKind.ADMINISTRATIVE_RECOVERY,
|
||||
initial_status=OwnershipTransferStatus.AWAITING_RECOVERY_APPROVALS,
|
||||
authorization_action="request_recovery",
|
||||
idempotency_key=idempotency_key,
|
||||
reason=reason,
|
||||
expiry_days=policy.default_expiry_days,
|
||||
policy=policy,
|
||||
now=effective_now,
|
||||
assurance_profile=policy.recovery_assurance_profile,
|
||||
required_approvals=policy.recovery_required_approvals,
|
||||
execute_after=effective_now
|
||||
+ timedelta(hours=policy.recovery_delay_hours),
|
||||
metadata={
|
||||
"recent_authentication_required": (
|
||||
policy.recent_authentication_required
|
||||
),
|
||||
"encryption_keys_included": False,
|
||||
},
|
||||
)
|
||||
return transfer
|
||||
|
||||
|
||||
def approve_administrative_recovery(
|
||||
session: Session,
|
||||
*,
|
||||
transfer: OwnershipTransfer,
|
||||
provider: ResourceOwnershipProvider,
|
||||
actor: OwnershipSubjectRef,
|
||||
now: datetime | None = None,
|
||||
) -> OwnershipTransfer:
|
||||
effective_now = _utc(now)
|
||||
_require_active(session, transfer, effective_now)
|
||||
_require_recovery_authentication(transfer, actor)
|
||||
if (
|
||||
transfer.kind != OwnershipTransferKind.ADMINISTRATIVE_RECOVERY.value
|
||||
or transfer.status
|
||||
not in {
|
||||
OwnershipTransferStatus.AWAITING_RECOVERY_APPROVALS.value,
|
||||
OwnershipTransferStatus.RECOVERY_SCHEDULED.value,
|
||||
}
|
||||
):
|
||||
raise OwnershipTransferError("Ownership recovery is not awaiting approval")
|
||||
_authorize(provider, session, transfer, actor, "approve_recovery")
|
||||
approvals = list(transfer.approvals or [])
|
||||
if any(
|
||||
item.get("actor_type") == actor.type and item.get("actor_id") == actor.id
|
||||
for item in approvals
|
||||
):
|
||||
return transfer
|
||||
approvals.append(
|
||||
{
|
||||
"actor_type": actor.type,
|
||||
"actor_id": actor.id,
|
||||
"approved_at": effective_now.isoformat(),
|
||||
}
|
||||
)
|
||||
transfer.approvals = approvals
|
||||
if len(approvals) >= transfer.required_approvals:
|
||||
transfer.status = OwnershipTransferStatus.RECOVERY_SCHEDULED.value
|
||||
_touch(transfer)
|
||||
_record_decision(
|
||||
transfer,
|
||||
action="recovery_approved",
|
||||
actor=actor,
|
||||
decided_at=effective_now,
|
||||
details={"approval_count": len(approvals)},
|
||||
)
|
||||
_emit_transfer_event(session, transfer, "recovery_approved", actor)
|
||||
session.flush()
|
||||
return transfer
|
||||
|
||||
|
||||
def execute_administrative_recovery(
|
||||
session: Session,
|
||||
*,
|
||||
transfer: OwnershipTransfer,
|
||||
provider: ResourceOwnershipProvider,
|
||||
actor: OwnershipSubjectRef,
|
||||
now: datetime | None = None,
|
||||
) -> OwnershipTransfer:
|
||||
effective_now = _utc(now)
|
||||
_require_active(session, transfer, effective_now)
|
||||
_require_recovery_authentication(transfer, actor)
|
||||
if transfer.status != OwnershipTransferStatus.RECOVERY_SCHEDULED.value:
|
||||
raise OwnershipTransferError("Ownership recovery has not reached its approval quorum")
|
||||
if transfer.execute_after and _utc(transfer.execute_after) > effective_now:
|
||||
raise OwnershipTransferError("Ownership recovery assurance delay has not elapsed")
|
||||
_authorize(provider, session, transfer, actor, "execute_recovery")
|
||||
provider.apply_owner(
|
||||
session,
|
||||
tenant_id=transfer.tenant_id,
|
||||
resource_id=transfer.resource_id,
|
||||
expected_owner=_owner_ref(transfer, target=False),
|
||||
target_owner=_owner_ref(transfer, target=True),
|
||||
actor=actor,
|
||||
reason=transfer.reason,
|
||||
)
|
||||
transfer.status = OwnershipTransferStatus.COMPLETED.value
|
||||
transfer.completed_at = effective_now
|
||||
_touch(transfer)
|
||||
_record_decision(
|
||||
transfer,
|
||||
action="recovery_executed",
|
||||
actor=actor,
|
||||
decided_at=effective_now,
|
||||
)
|
||||
_emit_transfer_event(session, transfer, "recovery_completed", actor)
|
||||
session.flush()
|
||||
return transfer
|
||||
|
||||
|
||||
def expire_due_ownership_transfers(
|
||||
session: Session,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
limit: int = 250,
|
||||
) -> int:
|
||||
effective_now = _utc(now)
|
||||
rows = (
|
||||
session.query(OwnershipTransfer)
|
||||
.filter(
|
||||
OwnershipTransfer.status.notin_(
|
||||
sorted(TERMINAL_OWNERSHIP_TRANSFER_STATUSES)
|
||||
),
|
||||
OwnershipTransfer.expires_at <= effective_now,
|
||||
)
|
||||
.order_by(OwnershipTransfer.expires_at.asc(), OwnershipTransfer.id.asc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
for transfer in rows:
|
||||
transfer.status = OwnershipTransferStatus.EXPIRED.value
|
||||
transfer.expired_at = effective_now
|
||||
_touch(transfer)
|
||||
_record_decision(
|
||||
transfer,
|
||||
action="expired",
|
||||
actor=None,
|
||||
decided_at=effective_now,
|
||||
)
|
||||
_emit_transfer_event(session, transfer, "expired", None)
|
||||
session.flush()
|
||||
return len(rows)
|
||||
|
||||
|
||||
def _start_transfer(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
resource: OwnershipResourceRef,
|
||||
provider: ResourceOwnershipProvider,
|
||||
actor: OwnershipSubjectRef,
|
||||
target_owner: OwnershipSubjectRef,
|
||||
kind: OwnershipTransferKind,
|
||||
initial_status: OwnershipTransferStatus,
|
||||
authorization_action: str,
|
||||
idempotency_key: str,
|
||||
reason: str | None,
|
||||
expiry_days: int | None,
|
||||
policy: OwnershipTransferPolicy,
|
||||
now: datetime | None,
|
||||
assurance_profile: str | None,
|
||||
required_approvals: int,
|
||||
execute_after: datetime | None,
|
||||
metadata: dict[str, Any],
|
||||
) -> OwnershipTransfer:
|
||||
if not resource.module_id or resource.module_id != resource.module_id.strip():
|
||||
raise OwnershipTransferError("Ownership resource module id is invalid")
|
||||
clean_key = idempotency_key.strip()
|
||||
if not clean_key or len(clean_key) > 200:
|
||||
raise OwnershipTransferError("A bounded ownership idempotency key is required")
|
||||
effective_now = _utc(now)
|
||||
days = policy.default_expiry_days if expiry_days is None else expiry_days
|
||||
if not policy.min_expiry_days <= days <= policy.max_expiry_days:
|
||||
raise OwnershipTransferError(
|
||||
"Ownership transfer expiry is outside the effective policy"
|
||||
)
|
||||
current_owner = provider.current_owner(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
resource_id=resource.resource_id,
|
||||
)
|
||||
if current_owner is None:
|
||||
raise OwnershipTransferError("Owned resource was not found")
|
||||
if current_owner.type == target_owner.type and current_owner.id == target_owner.id:
|
||||
raise OwnershipTransferError("Target is already the resource owner")
|
||||
_authorize_direct(
|
||||
provider,
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
resource_id=resource.resource_id,
|
||||
current_owner=current_owner,
|
||||
target_owner=target_owner,
|
||||
actor=actor,
|
||||
action=authorization_action,
|
||||
)
|
||||
request_hash = _request_hash(
|
||||
tenant_id=tenant_id,
|
||||
resource=resource,
|
||||
kind=kind,
|
||||
current_owner=current_owner,
|
||||
target_owner=target_owner,
|
||||
actor=actor,
|
||||
reason=reason,
|
||||
expiry_days=days,
|
||||
assurance_profile=assurance_profile,
|
||||
required_approvals=required_approvals,
|
||||
execute_after=execute_after,
|
||||
metadata=metadata,
|
||||
)
|
||||
existing = (
|
||||
session.query(OwnershipTransfer)
|
||||
.filter(
|
||||
OwnershipTransfer.tenant_id == tenant_id,
|
||||
OwnershipTransfer.resource_module == resource.module_id,
|
||||
OwnershipTransfer.idempotency_key == clean_key,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
if existing.canonical_request_hash != request_hash:
|
||||
raise OwnershipIdempotencyConflict(
|
||||
"Ownership idempotency key is already bound to another request"
|
||||
)
|
||||
return existing
|
||||
transfer = OwnershipTransfer(
|
||||
tenant_id=tenant_id,
|
||||
resource_module=resource.module_id,
|
||||
resource_type=resource.resource_type,
|
||||
resource_id=resource.resource_id,
|
||||
kind=kind.value,
|
||||
status=initial_status.value,
|
||||
current_owner_type=current_owner.type,
|
||||
current_owner_id=current_owner.id,
|
||||
target_owner_type=target_owner.type,
|
||||
target_owner_id=target_owner.id,
|
||||
initiated_by_type=actor.type,
|
||||
initiated_by_id=actor.id,
|
||||
reason=reason.strip() if reason else None,
|
||||
assurance_profile=assurance_profile,
|
||||
required_approvals=required_approvals,
|
||||
approvals=[],
|
||||
decisions=[],
|
||||
idempotency_key=clean_key,
|
||||
canonical_request_hash=request_hash,
|
||||
expires_at=effective_now + timedelta(days=days),
|
||||
execute_after=execute_after,
|
||||
metadata_=dict(metadata),
|
||||
)
|
||||
session.add(transfer)
|
||||
session.flush()
|
||||
_record_decision(
|
||||
transfer,
|
||||
action="started",
|
||||
actor=actor,
|
||||
decided_at=effective_now,
|
||||
details={
|
||||
"kind": kind.value,
|
||||
"initial_status": initial_status.value,
|
||||
"assurance_profile": assurance_profile,
|
||||
"required_approvals": required_approvals,
|
||||
},
|
||||
)
|
||||
_emit_transfer_event(session, transfer, "started", actor)
|
||||
return transfer
|
||||
|
||||
|
||||
def _authorize(
|
||||
provider: ResourceOwnershipProvider,
|
||||
session: Session,
|
||||
transfer: OwnershipTransfer,
|
||||
actor: OwnershipSubjectRef,
|
||||
action: str,
|
||||
) -> None:
|
||||
_authorize_direct(
|
||||
provider,
|
||||
session,
|
||||
tenant_id=transfer.tenant_id,
|
||||
resource_id=transfer.resource_id,
|
||||
current_owner=_owner_ref(transfer, target=False),
|
||||
target_owner=_owner_ref(transfer, target=True),
|
||||
actor=actor,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
def _authorize_direct(
|
||||
provider: ResourceOwnershipProvider,
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
resource_id: str,
|
||||
current_owner: OwnershipSubjectRef,
|
||||
target_owner: OwnershipSubjectRef,
|
||||
actor: OwnershipSubjectRef,
|
||||
action: str,
|
||||
) -> None:
|
||||
decision = provider.authorize_ownership_action(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
resource_id=resource_id,
|
||||
action=action,
|
||||
actor=actor,
|
||||
current_owner=current_owner,
|
||||
target_owner=target_owner,
|
||||
)
|
||||
if not decision.allowed:
|
||||
raise OwnershipAuthorizationError(
|
||||
decision.reason or f"Ownership action is not allowed: {action}"
|
||||
)
|
||||
|
||||
|
||||
def _require_active(
|
||||
session: Session,
|
||||
transfer: OwnershipTransfer,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
if transfer.status in TERMINAL_OWNERSHIP_TRANSFER_STATUSES:
|
||||
raise OwnershipTransferError("Ownership transfer is already final")
|
||||
if _utc(transfer.expires_at) <= now:
|
||||
transfer.status = OwnershipTransferStatus.EXPIRED.value
|
||||
transfer.expired_at = now
|
||||
_touch(transfer)
|
||||
_record_decision(
|
||||
transfer,
|
||||
action="expired",
|
||||
actor=None,
|
||||
decided_at=now,
|
||||
)
|
||||
_emit_transfer_event(session, transfer, "expired", None)
|
||||
raise OwnershipTransferExpired("Ownership transfer has expired")
|
||||
|
||||
|
||||
def _require_recovery_authentication(
|
||||
transfer: OwnershipTransfer,
|
||||
actor: OwnershipSubjectRef,
|
||||
) -> None:
|
||||
if (
|
||||
bool((transfer.metadata_ or {}).get("recent_authentication_required"))
|
||||
and not actor.recently_authenticated
|
||||
):
|
||||
raise OwnershipAuthorizationError(
|
||||
"Administrative recovery requires recent authentication"
|
||||
)
|
||||
|
||||
|
||||
def _owner_ref(
|
||||
transfer: OwnershipTransfer,
|
||||
*,
|
||||
target: bool,
|
||||
) -> OwnershipSubjectRef:
|
||||
return OwnershipSubjectRef(
|
||||
type=transfer.target_owner_type if target else transfer.current_owner_type,
|
||||
id=transfer.target_owner_id if target else transfer.current_owner_id,
|
||||
)
|
||||
|
||||
|
||||
def _touch(transfer: OwnershipTransfer) -> None:
|
||||
transfer.revision = int(transfer.revision or 0) + 1
|
||||
|
||||
|
||||
def _record_decision(
|
||||
transfer: OwnershipTransfer,
|
||||
*,
|
||||
action: str,
|
||||
actor: OwnershipSubjectRef | None,
|
||||
decided_at: datetime,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
decisions = list(transfer.decisions or [])
|
||||
decisions.append(
|
||||
{
|
||||
"sequence": len(decisions) + 1,
|
||||
"action": action,
|
||||
"actor_type": actor.type if actor else None,
|
||||
"actor_id": actor.id if actor else None,
|
||||
"decided_at": _utc(decided_at).isoformat(),
|
||||
"status": transfer.status,
|
||||
"details": dict(details or {}),
|
||||
}
|
||||
)
|
||||
transfer.decisions = decisions
|
||||
|
||||
|
||||
def _utc(value: datetime | None) -> datetime:
|
||||
if value is None:
|
||||
return utcnow()
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _request_hash(
|
||||
*,
|
||||
tenant_id: str,
|
||||
resource: OwnershipResourceRef,
|
||||
kind: OwnershipTransferKind,
|
||||
current_owner: OwnershipSubjectRef,
|
||||
target_owner: OwnershipSubjectRef,
|
||||
actor: OwnershipSubjectRef,
|
||||
reason: str | None,
|
||||
expiry_days: int,
|
||||
assurance_profile: str | None,
|
||||
required_approvals: int,
|
||||
execute_after: datetime | None,
|
||||
metadata: dict[str, Any],
|
||||
) -> str:
|
||||
payload = {
|
||||
"tenant_id": tenant_id,
|
||||
"resource": {
|
||||
"module_id": resource.module_id,
|
||||
"type": resource.resource_type,
|
||||
"id": resource.resource_id,
|
||||
},
|
||||
"kind": kind.value,
|
||||
"current_owner": {"type": current_owner.type, "id": current_owner.id},
|
||||
"target_owner": {"type": target_owner.type, "id": target_owner.id},
|
||||
"actor": {"type": actor.type, "id": actor.id},
|
||||
"reason": reason.strip() if reason else None,
|
||||
"expiry_days": expiry_days,
|
||||
"assurance_profile": assurance_profile,
|
||||
"required_approvals": required_approvals,
|
||||
"execute_after": (
|
||||
_utc(execute_after).isoformat()
|
||||
if execute_after is not None
|
||||
else None
|
||||
),
|
||||
"metadata": metadata,
|
||||
}
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _emit_transfer_event(
|
||||
session: Session,
|
||||
transfer: OwnershipTransfer,
|
||||
action: str,
|
||||
actor: OwnershipSubjectRef | None,
|
||||
) -> None:
|
||||
from govoplan_core.core.events import (
|
||||
EventActorRef,
|
||||
EventObjectRef,
|
||||
EventTenantRef,
|
||||
PlatformEvent,
|
||||
emit_platform_event,
|
||||
)
|
||||
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
type=f"core.ownership_transfer.{action}.v1",
|
||||
module_id="core",
|
||||
tenant=EventTenantRef(id=transfer.tenant_id),
|
||||
actor=EventActorRef(type=actor.type, id=actor.id) if actor else None,
|
||||
resource=EventObjectRef(
|
||||
type="ownership_transfer",
|
||||
id=transfer.id,
|
||||
),
|
||||
classification="confidential",
|
||||
payload={
|
||||
"schema_version": 1,
|
||||
"resource_module": transfer.resource_module,
|
||||
"resource_type": transfer.resource_type,
|
||||
"resource_id": transfer.resource_id,
|
||||
"kind": transfer.kind,
|
||||
"status": transfer.status,
|
||||
"current_owner_type": transfer.current_owner_type,
|
||||
"current_owner_id": transfer.current_owner_id,
|
||||
"target_owner_type": transfer.target_owner_type,
|
||||
"target_owner_id": transfer.target_owner_id,
|
||||
"assurance_profile": transfer.assurance_profile,
|
||||
"required_approvals": transfer.required_approvals,
|
||||
"approval_count": len(transfer.approvals or []),
|
||||
"encryption_keys_included": False,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OwnershipActionDecision",
|
||||
"OwnershipAuthorizationError",
|
||||
"OwnershipIdempotencyConflict",
|
||||
"OwnershipProviderRegistration",
|
||||
"OwnershipResourceRef",
|
||||
"OwnershipSubjectRef",
|
||||
"OwnershipTransfer",
|
||||
"OwnershipTransferError",
|
||||
"OwnershipTransferExpired",
|
||||
"OwnershipTransferKind",
|
||||
"OwnershipTransferPolicy",
|
||||
"OwnershipTransferStatus",
|
||||
"ResourceOwnershipProvider",
|
||||
"accept_ownership_transfer",
|
||||
"approve_administrative_recovery",
|
||||
"approve_ownership_request",
|
||||
"cancel_ownership_transfer",
|
||||
"decline_ownership_transfer",
|
||||
"execute_administrative_recovery",
|
||||
"expire_due_ownership_transfers",
|
||||
"request_ownership",
|
||||
"start_administrative_recovery",
|
||||
"start_owner_initiated_transfer",
|
||||
]
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_ACCESS_PEOPLE_SEARCH = "access.people_search"
|
||||
CAPABILITY_ADDRESSES_PEOPLE_SEARCH = "addresses.people_search"
|
||||
|
||||
# Identity search is deliberately absent here. ``identity.search`` is an
|
||||
# instance-wide canonical-identity directory and has no tenant/principal
|
||||
# visibility contract. Ordinary task pickers must use principal-aware search
|
||||
# providers instead.
|
||||
DEFAULT_PEOPLE_SEARCH_CAPABILITIES = (
|
||||
CAPABILITY_ACCESS_PEOPLE_SEARCH,
|
||||
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
|
||||
)
|
||||
|
||||
|
||||
class PeopleSearchError(ValueError):
|
||||
"""Stable, non-diagnostic error raised by people-search providers."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonSearchCandidate:
|
||||
"""A policy-filtered selection target returned by a directory provider."""
|
||||
|
||||
selection_key: str
|
||||
kind: str
|
||||
reference_id: str
|
||||
display_name: str
|
||||
email: str | None = None
|
||||
source_module: str | None = None
|
||||
source_label: str | None = None
|
||||
source_ref: str | None = None
|
||||
source_revision: str | None = None
|
||||
description: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PeopleSearchGroup:
|
||||
key: str
|
||||
label: str
|
||||
candidates: tuple[PersonSearchCandidate, ...] = ()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PeopleSearchProvider(Protocol):
|
||||
"""Server-side, principal-aware people/delivery-target search.
|
||||
|
||||
Implementations must derive visibility from ``principal`` and may only
|
||||
return records the principal can discover in its active tenant and policy
|
||||
context. Callers remain responsible for authorizing the task for which the
|
||||
picker is used (for example, creating a scheduling request).
|
||||
"""
|
||||
|
||||
def search_people(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str,
|
||||
limit: int = 25,
|
||||
) -> Sequence[PeopleSearchGroup]:
|
||||
...
|
||||
|
||||
|
||||
def people_search_providers(
|
||||
registry: object | None,
|
||||
*,
|
||||
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
|
||||
) -> tuple[PeopleSearchProvider, ...]:
|
||||
if registry is None or not hasattr(registry, "has_capability") or not hasattr(registry, "capability"):
|
||||
return ()
|
||||
|
||||
providers: list[PeopleSearchProvider] = []
|
||||
for capability_name in capability_names:
|
||||
if not registry.has_capability(capability_name):
|
||||
continue
|
||||
capability = registry.capability(capability_name)
|
||||
if isinstance(capability, PeopleSearchProvider):
|
||||
providers.append(capability)
|
||||
return tuple(providers)
|
||||
|
||||
|
||||
def search_visible_people(
|
||||
registry: object | None,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str,
|
||||
limit: int = 25,
|
||||
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
|
||||
) -> tuple[PeopleSearchGroup, ...]:
|
||||
"""Collect grouped results without importing optional feature modules.
|
||||
|
||||
``limit`` is enforced per provider so one directory cannot starve another
|
||||
result group. Providers are expected to return at most that many candidates
|
||||
in total. Duplicate group keys are merged and candidate selection keys are
|
||||
de-duplicated while preserving provider order.
|
||||
"""
|
||||
|
||||
normalized_query = str(query or "").strip()
|
||||
normalized_limit = max(1, min(int(limit), 100))
|
||||
ordered_group_keys: list[str] = []
|
||||
labels: dict[str, str] = {}
|
||||
candidates_by_group: dict[str, list[PersonSearchCandidate]] = {}
|
||||
candidate_keys_by_group: dict[str, set[str]] = {}
|
||||
|
||||
for provider in people_search_providers(registry, capability_names=capability_names):
|
||||
groups = provider.search_people(
|
||||
session,
|
||||
principal,
|
||||
query=normalized_query,
|
||||
limit=normalized_limit,
|
||||
)
|
||||
provider_candidate_count = 0
|
||||
for group in groups:
|
||||
if not group.key:
|
||||
continue
|
||||
if group.key not in candidates_by_group:
|
||||
ordered_group_keys.append(group.key)
|
||||
labels[group.key] = group.label
|
||||
candidates_by_group[group.key] = []
|
||||
candidate_keys_by_group[group.key] = set()
|
||||
for candidate in group.candidates:
|
||||
if provider_candidate_count >= normalized_limit:
|
||||
break
|
||||
if not candidate.selection_key or candidate.selection_key in candidate_keys_by_group[group.key]:
|
||||
continue
|
||||
candidate_keys_by_group[group.key].add(candidate.selection_key)
|
||||
candidates_by_group[group.key].append(candidate)
|
||||
provider_candidate_count += 1
|
||||
|
||||
return tuple(
|
||||
PeopleSearchGroup(
|
||||
key=group_key,
|
||||
label=labels[group_key],
|
||||
candidates=tuple(candidates_by_group[group_key]),
|
||||
)
|
||||
for group_key in ordered_group_keys
|
||||
if candidates_by_group[group_key]
|
||||
)
|
||||
|
||||
|
||||
def person_selection_key(kind: str, reference_id: str, *, email: str | None = None) -> str:
|
||||
normalized_kind = str(kind).strip().casefold()
|
||||
normalized_reference = str(reference_id).strip()
|
||||
normalized_email = str(email or "").strip().casefold()
|
||||
if not normalized_kind or not normalized_reference:
|
||||
raise ValueError("Person selection keys require a kind and reference id.")
|
||||
return ":".join(part for part in (normalized_kind, normalized_reference, normalized_email) if part)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_ACCESS_PEOPLE_SEARCH",
|
||||
"CAPABILITY_ADDRESSES_PEOPLE_SEARCH",
|
||||
"DEFAULT_PEOPLE_SEARCH_CAPABILITIES",
|
||||
"PeopleSearchError",
|
||||
"PeopleSearchGroup",
|
||||
"PeopleSearchProvider",
|
||||
"PersonSearchCandidate",
|
||||
"people_search_providers",
|
||||
"person_selection_key",
|
||||
"search_visible_people",
|
||||
]
|
||||
@@ -0,0 +1,303 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Literal, Mapping, Sequence
|
||||
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_core.core.views import (
|
||||
navigation_view_surface_id,
|
||||
route_view_surface_id,
|
||||
)
|
||||
|
||||
|
||||
PLATFORM_INTERFACE_CONTRACT_VERSION = "1"
|
||||
|
||||
PlatformInterfaceKind = Literal[
|
||||
"backend_capability",
|
||||
"frontend_route",
|
||||
"navigation",
|
||||
"permission",
|
||||
"provided_interface",
|
||||
"public_route",
|
||||
"search_provider",
|
||||
"search_source",
|
||||
"settings_route",
|
||||
"view_surface",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlatformInterfaceDeclaration:
|
||||
"""A sanitized, stable declaration from a module manifest.
|
||||
|
||||
The declaration contains identifiers and authorization metadata only. It
|
||||
deliberately excludes factories, executable callbacks, credentials, and
|
||||
mutable module state.
|
||||
"""
|
||||
|
||||
id: str
|
||||
module_id: str
|
||||
kind: PlatformInterfaceKind
|
||||
label: str | None = None
|
||||
path: str | None = None
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return f"{self.kind}:{self.id}"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"key": self.key,
|
||||
"id": self.id,
|
||||
"module_id": self.module_id,
|
||||
"kind": self.kind,
|
||||
"label": self.label,
|
||||
"path": self.path,
|
||||
"required_all": list(self.required_all),
|
||||
"required_any": list(self.required_any),
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
|
||||
def manifest_interface_declarations(
|
||||
manifest: ModuleManifest,
|
||||
) -> tuple[PlatformInterfaceDeclaration, ...]:
|
||||
"""Normalize the typed public declarations owned by one module manifest."""
|
||||
|
||||
declarations: list[PlatformInterfaceDeclaration] = []
|
||||
|
||||
for capability_name in sorted(manifest.capability_factories):
|
||||
documentation = manifest.capability_documentation.get(capability_name)
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=capability_name,
|
||||
module_id=manifest.id,
|
||||
kind="backend_capability",
|
||||
label=documentation.label if documentation is not None else None,
|
||||
metadata={
|
||||
"contract_version": (
|
||||
documentation.contract_version
|
||||
if documentation is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
for interface in manifest.provides_interfaces:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=interface.name,
|
||||
module_id=manifest.id,
|
||||
kind="provided_interface",
|
||||
metadata={"version": interface.version},
|
||||
)
|
||||
)
|
||||
|
||||
for permission in manifest.permissions:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=permission.scope,
|
||||
module_id=manifest.id,
|
||||
kind="permission",
|
||||
label=permission.label,
|
||||
metadata={
|
||||
"category": permission.category,
|
||||
"level": permission.level,
|
||||
"deprecated": permission.deprecated,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
for registration in manifest.search_providers:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=registration.id,
|
||||
module_id=manifest.id,
|
||||
kind="search_provider",
|
||||
metadata={
|
||||
"role": "provider",
|
||||
"resource_types": list(registration.resource_types),
|
||||
},
|
||||
)
|
||||
)
|
||||
for registration in manifest.search_sources:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=registration.id,
|
||||
module_id=manifest.id,
|
||||
kind="search_source",
|
||||
metadata={"role": "source"},
|
||||
)
|
||||
)
|
||||
|
||||
frontend = manifest.frontend
|
||||
if frontend is not None:
|
||||
for route in frontend.routes:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=route_view_surface_id(manifest.id, route.path),
|
||||
module_id=manifest.id,
|
||||
kind="frontend_route",
|
||||
path=route.path,
|
||||
required_all=route.required_all,
|
||||
required_any=route.required_any,
|
||||
metadata={
|
||||
"component": route.component,
|
||||
"order": route.order,
|
||||
"surface_id": route.surface_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
for route in frontend.public_routes:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=f"{manifest.id}.public.{_path_slug(route.path)}",
|
||||
module_id=manifest.id,
|
||||
kind="public_route",
|
||||
path=route.path,
|
||||
metadata={"component": route.component, "order": route.order},
|
||||
)
|
||||
)
|
||||
for route in frontend.settings_routes:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=route_view_surface_id(manifest.id, route.path),
|
||||
module_id=manifest.id,
|
||||
kind="settings_route",
|
||||
path=route.path,
|
||||
required_all=route.required_all,
|
||||
required_any=route.required_any,
|
||||
metadata={
|
||||
"component": route.component,
|
||||
"order": route.order,
|
||||
"surface_id": route.surface_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
for item in frontend.nav_items:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=navigation_view_surface_id(manifest.id, item.path),
|
||||
module_id=manifest.id,
|
||||
kind="navigation",
|
||||
label=item.label,
|
||||
path=item.path,
|
||||
required_all=item.required_all,
|
||||
required_any=item.required_any,
|
||||
metadata={
|
||||
"icon": item.icon,
|
||||
"section": item.section,
|
||||
"order": item.order,
|
||||
"surface_id": item.surface_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
for surface in frontend.view_surfaces:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=surface.id,
|
||||
module_id=manifest.id,
|
||||
kind="view_surface",
|
||||
label=surface.label,
|
||||
metadata={
|
||||
"surface_kind": surface.kind,
|
||||
"parent_id": surface.parent_id,
|
||||
"description": surface.description,
|
||||
"order": surface.order,
|
||||
"default_visible": surface.default_visible,
|
||||
"required": surface.required,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
frontend_navigation = {
|
||||
declaration.id: declaration
|
||||
for declaration in declarations
|
||||
if declaration.kind == "navigation"
|
||||
}
|
||||
for item in manifest.nav_items:
|
||||
declaration = PlatformInterfaceDeclaration(
|
||||
id=navigation_view_surface_id(manifest.id, item.path),
|
||||
module_id=manifest.id,
|
||||
kind="navigation",
|
||||
label=item.label,
|
||||
path=item.path,
|
||||
required_all=item.required_all,
|
||||
required_any=item.required_any,
|
||||
metadata={
|
||||
"icon": item.icon,
|
||||
"section": item.section,
|
||||
"order": item.order,
|
||||
"surface_id": item.surface_id,
|
||||
},
|
||||
)
|
||||
frontend_declaration = frontend_navigation.get(declaration.id)
|
||||
if frontend_declaration is not None and frontend_declaration == declaration:
|
||||
continue
|
||||
declarations.append(declaration)
|
||||
|
||||
return tuple(sorted(declarations, key=lambda item: (item.kind, item.id)))
|
||||
|
||||
|
||||
def validate_manifest_interface_declarations(manifest: ModuleManifest) -> None:
|
||||
seen: set[str] = set()
|
||||
for declaration in manifest_interface_declarations(manifest):
|
||||
if declaration.key in seen:
|
||||
raise ValueError(
|
||||
f"Module {manifest.id!r} declares duplicate platform interface "
|
||||
f"{declaration.key!r}"
|
||||
)
|
||||
seen.add(declaration.key)
|
||||
|
||||
|
||||
def manifest_interface_catalog(manifest: ModuleManifest) -> dict[str, Any]:
|
||||
declarations = manifest_interface_declarations(manifest)
|
||||
serialized = [item.to_dict() for item in declarations]
|
||||
canonical = json.dumps(
|
||||
serialized,
|
||||
ensure_ascii=True,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
return {
|
||||
"contract_version": PLATFORM_INTERFACE_CONTRACT_VERSION,
|
||||
"module_id": manifest.id,
|
||||
"module_version": manifest.version,
|
||||
"digest": f"sha256:{hashlib.sha256(canonical).hexdigest()}",
|
||||
"counts": dict(sorted(Counter(item.kind for item in declarations).items())),
|
||||
"declarations": serialized,
|
||||
}
|
||||
|
||||
|
||||
def platform_interface_catalog(
|
||||
manifests: Sequence[ModuleManifest],
|
||||
) -> dict[str, Any]:
|
||||
modules = [manifest_interface_catalog(manifest) for manifest in manifests]
|
||||
return {
|
||||
"contract_version": PLATFORM_INTERFACE_CONTRACT_VERSION,
|
||||
"modules": modules,
|
||||
}
|
||||
|
||||
|
||||
def _path_slug(path: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", ".", path.lower()).strip(".")
|
||||
return slug or "root"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PLATFORM_INTERFACE_CONTRACT_VERSION",
|
||||
"PlatformInterfaceDeclaration",
|
||||
"PlatformInterfaceKind",
|
||||
"manifest_interface_catalog",
|
||||
"manifest_interface_declarations",
|
||||
"platform_interface_catalog",
|
||||
"validate_manifest_interface_declarations",
|
||||
]
|
||||
@@ -4,13 +4,55 @@ from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
|
||||
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
|
||||
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
|
||||
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
|
||||
DefinitionKind = Literal["flow", "template"]
|
||||
DefinitionGovernanceAction = Literal[
|
||||
"view",
|
||||
"edit",
|
||||
"run",
|
||||
"reuse",
|
||||
"derive",
|
||||
"automate",
|
||||
]
|
||||
ViewGovernanceAction = Literal[
|
||||
"view",
|
||||
"select",
|
||||
"assign",
|
||||
"edit",
|
||||
"derive",
|
||||
"workflow_activate",
|
||||
]
|
||||
FunctionAssignmentChangeKind = Literal["request", "grant"]
|
||||
FunctionAssignmentGovernanceAction = Literal[
|
||||
"submit",
|
||||
"approve_holder",
|
||||
"approve_authority",
|
||||
"accept_recipient",
|
||||
"request_changes",
|
||||
"respond",
|
||||
"reject",
|
||||
"withdraw",
|
||||
"recover",
|
||||
"apply",
|
||||
]
|
||||
|
||||
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
|
||||
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
|
||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE = "policy.definitionGovernance"
|
||||
CAPABILITY_POLICY_VIEW_GOVERNANCE = "policy.viewGovernance"
|
||||
CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE = "policy.functionAssignmentGovernance"
|
||||
|
||||
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
|
||||
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = (
|
||||
"system",
|
||||
"tenant",
|
||||
"user",
|
||||
"group",
|
||||
"campaign",
|
||||
)
|
||||
|
||||
|
||||
def normalize_policy_scope_type(scope_type: str) -> PolicyScopeType:
|
||||
@@ -30,7 +72,11 @@ class PolicySourceRef:
|
||||
return policy_source_path(self.scope_type, self.scope_id)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"scope_type": self.scope_type, "scope_id": self.scope_id, "path": self.path}
|
||||
return {
|
||||
"scope_type": self.scope_type,
|
||||
"scope_id": self.scope_id,
|
||||
"path": self.path,
|
||||
}
|
||||
|
||||
|
||||
def policy_source_path(scope_type: str, scope_id: str | None = None) -> str:
|
||||
@@ -40,7 +86,9 @@ def policy_source_path(scope_type: str, scope_id: str | None = None) -> str:
|
||||
raise ValueError("System policy sources do not carry a scope_id")
|
||||
return "system"
|
||||
if not scope_id:
|
||||
raise ValueError(f"{clean_scope.capitalize()} policy sources require a scope_id")
|
||||
raise ValueError(
|
||||
f"{clean_scope.capitalize()} policy sources require a scope_id"
|
||||
)
|
||||
return f"{clean_scope}:{quote(str(scope_id), safe='')}"
|
||||
|
||||
|
||||
@@ -50,7 +98,9 @@ def parse_policy_source_path(path: str) -> PolicySourceRef:
|
||||
return PolicySourceRef(scope_type="system")
|
||||
scope_type, separator, encoded_scope_id = clean_path.partition(":")
|
||||
if not separator:
|
||||
raise ValueError("Policy source path must be system or <scope_type>:<url-encoded-scope-id>")
|
||||
raise ValueError(
|
||||
"Policy source path must be system or <scope_type>:<url-encoded-scope-id>"
|
||||
)
|
||||
clean_scope = normalize_policy_scope_type(scope_type)
|
||||
if clean_scope == "system":
|
||||
raise ValueError("System policy source path must be exactly system")
|
||||
@@ -87,9 +137,13 @@ class PolicySourceStep:
|
||||
policy_value = value.get("policy")
|
||||
return cls(
|
||||
scope_type=normalize_policy_scope_type(str(value.get("scope_type", ""))),
|
||||
scope_id=str(value["scope_id"]) if value.get("scope_id") is not None else None,
|
||||
scope_id=str(value["scope_id"])
|
||||
if value.get("scope_id") is not None
|
||||
else None,
|
||||
label=str(value.get("label") or ""),
|
||||
applied_fields=tuple(str(field) for field in (value.get("applied_fields") or ())),
|
||||
applied_fields=tuple(
|
||||
str(field) for field in (value.get("applied_fields") or ())
|
||||
),
|
||||
policy=policy_value if isinstance(policy_value, Mapping) else {},
|
||||
)
|
||||
|
||||
@@ -128,6 +182,202 @@ class PolicyDecision:
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FunctionAssignmentGovernanceRequest:
|
||||
tenant_id: str
|
||||
kind: FunctionAssignmentChangeKind
|
||||
action: FunctionAssignmentGovernanceAction
|
||||
function_id: str
|
||||
actor: PrincipalRef
|
||||
candidate_identity_id: str
|
||||
candidate_account_id: str | None = None
|
||||
current_state: str = "draft"
|
||||
function_settings: Mapping[str, Any] = field(default_factory=dict)
|
||||
context: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FunctionAssignmentGovernanceDecision:
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
profile: str = "unavailable"
|
||||
required_steps: tuple[str, ...] = ()
|
||||
authority_function_id: str | None = None
|
||||
evidence_required: bool = False
|
||||
recipient_acceptance_required: bool = False
|
||||
separation_of_duties: bool = True
|
||||
quorum: int = 1
|
||||
maximum_validity_days: int | None = None
|
||||
request_expiry_hours: int = 336
|
||||
source_path: tuple[PolicySourceStep, ...] = ()
|
||||
requirements: tuple[str, ...] = ()
|
||||
details: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"allowed": self.allowed,
|
||||
"reason": self.reason,
|
||||
"profile": self.profile,
|
||||
"required_steps": list(self.required_steps),
|
||||
"authority_function_id": self.authority_function_id,
|
||||
"evidence_required": self.evidence_required,
|
||||
"recipient_acceptance_required": (self.recipient_acceptance_required),
|
||||
"separation_of_duties": self.separation_of_duties,
|
||||
"quorum": self.quorum,
|
||||
"maximum_validity_days": self.maximum_validity_days,
|
||||
"request_expiry_hours": self.request_expiry_hours,
|
||||
"source_path": [step.to_dict() for step in self.source_path],
|
||||
"requirements": list(self.requirements),
|
||||
"details": dict(self.details),
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FunctionAssignmentGovernancePolicy(Protocol):
|
||||
def resolve_function_assignment_action(
|
||||
self,
|
||||
session: object | None = None,
|
||||
*,
|
||||
request: FunctionAssignmentGovernanceRequest,
|
||||
) -> FunctionAssignmentGovernanceDecision: ...
|
||||
|
||||
|
||||
def function_assignment_governance_policy(
|
||||
registry: object | None,
|
||||
) -> FunctionAssignmentGovernancePolicy | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_POLICY_FUNCTION_ASSIGNMENT_GOVERNANCE)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, FunctionAssignmentGovernancePolicy)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionScopeRef:
|
||||
scope_type: DefinitionScopeType
|
||||
scope_id: str | None = None
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return policy_source_path(self.scope_type, self.scope_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DefinitionGovernanceRequest:
|
||||
module_id: str
|
||||
definition_ref: str
|
||||
tenant_id: str
|
||||
definition_scope: DefinitionScopeRef
|
||||
target_scope: DefinitionScopeRef
|
||||
definition_kind: DefinitionKind
|
||||
action: DefinitionGovernanceAction
|
||||
actor: PrincipalRef
|
||||
status: str = "draft"
|
||||
inherit_to_lower_scopes: bool = False
|
||||
allow_run: bool = True
|
||||
allow_reuse: bool = False
|
||||
allow_automation: bool = False
|
||||
context: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DefinitionGovernancePolicy(Protocol):
|
||||
def resolve_definition_action(
|
||||
self,
|
||||
session: object | None = None,
|
||||
*,
|
||||
request: DefinitionGovernanceRequest,
|
||||
) -> PolicyDecision: ...
|
||||
|
||||
|
||||
def definition_governance_policy(
|
||||
registry: object | None,
|
||||
) -> DefinitionGovernancePolicy | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(CAPABILITY_POLICY_DEFINITION_GOVERNANCE)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_POLICY_DEFINITION_GOVERNANCE)
|
||||
return capability if isinstance(capability, DefinitionGovernancePolicy) else None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ViewGovernanceRequest:
|
||||
tenant_id: str
|
||||
action: ViewGovernanceAction
|
||||
actor: PrincipalRef
|
||||
target_scope: DefinitionScopeRef
|
||||
view_id: str | None = None
|
||||
candidate_view_ids: tuple[str, ...] = ()
|
||||
candidate_surface_ids: tuple[str, ...] = ()
|
||||
requested_surface_ids: tuple[str, ...] = ()
|
||||
context: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ViewGovernanceDecision:
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
allowed_view_ids: frozenset[str] | None = None
|
||||
visible_surface_ids: frozenset[str] | None = None
|
||||
source_path: tuple[PolicySourceStep, ...] = ()
|
||||
requirements: tuple[str, ...] = ()
|
||||
diagnostics: tuple[Mapping[str, Any], ...] = ()
|
||||
details: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"allowed": self.allowed,
|
||||
"reason": self.reason,
|
||||
"allowed_view_ids": (
|
||||
sorted(self.allowed_view_ids)
|
||||
if self.allowed_view_ids is not None
|
||||
else None
|
||||
),
|
||||
"visible_surface_ids": (
|
||||
sorted(self.visible_surface_ids)
|
||||
if self.visible_surface_ids is not None
|
||||
else None
|
||||
),
|
||||
"source_path": [step.to_dict() for step in self.source_path],
|
||||
"requirements": list(self.requirements),
|
||||
"diagnostics": [dict(item) for item in self.diagnostics],
|
||||
"details": dict(self.details),
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ViewGovernancePolicy(Protocol):
|
||||
def resolve_view_action(
|
||||
self,
|
||||
session: object | None = None,
|
||||
*,
|
||||
request: ViewGovernanceRequest,
|
||||
) -> ViewGovernanceDecision: ...
|
||||
|
||||
|
||||
def view_governance_policy(
|
||||
registry: object | None,
|
||||
) -> ViewGovernancePolicy | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(CAPABILITY_POLICY_VIEW_GOVERNANCE)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_POLICY_VIEW_GOVERNANCE)
|
||||
return capability if isinstance(capability, ViewGovernancePolicy) else None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SchedulingParticipantPrivacyRequest:
|
||||
"""Context for resolving what one Scheduling participant may see.
|
||||
@@ -173,44 +423,32 @@ class SchedulingParticipantPrivacyPolicy(Protocol):
|
||||
session: object,
|
||||
*,
|
||||
request: SchedulingParticipantPrivacyRequest,
|
||||
) -> SchedulingParticipantPrivacyDecision:
|
||||
...
|
||||
) -> SchedulingParticipantPrivacyDecision: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PrivacyRetentionService(Protocol):
|
||||
def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any: ...
|
||||
|
||||
def privacy_policy_from_session(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
def privacy_policy_from_session(self, *args: Any, **kwargs: Any) -> Any: ...
|
||||
|
||||
def set_privacy_policy(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
def set_privacy_policy(self, *args: Any, **kwargs: Any) -> Any: ...
|
||||
|
||||
def effective_privacy_policy(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
def effective_privacy_policy(self, *args: Any, **kwargs: Any) -> Any: ...
|
||||
|
||||
def parent_privacy_policy(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
def parent_privacy_policy(self, *args: Any, **kwargs: Any) -> Any: ...
|
||||
|
||||
def effective_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
def effective_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any: ...
|
||||
|
||||
def parent_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
def parent_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any: ...
|
||||
|
||||
def get_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
def get_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any: ...
|
||||
|
||||
def set_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
def set_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any: ...
|
||||
|
||||
def sanitize_audit_details_for_policy(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
def sanitize_audit_details_for_policy(self, *args: Any, **kwargs: Any) -> Any: ...
|
||||
|
||||
def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any: ...
|
||||
|
||||
|
||||
def scheduling_participant_privacy_policy(
|
||||
@@ -223,4 +461,8 @@ def scheduling_participant_privacy_policy(
|
||||
if not registry.has_capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY)
|
||||
return capability if isinstance(capability, SchedulingParticipantPrivacyPolicy) else None
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, SchedulingParticipantPrivacyPolicy)
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -73,6 +73,18 @@ class PollOptionUpdateCommand:
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollOptionOrderCommand:
|
||||
"""Complete active option order supplied by an owning module.
|
||||
|
||||
Providers must reject partial, duplicate, or unknown option collections.
|
||||
An exact repeat is an idempotent no-op. Reordering changes positions only;
|
||||
option identities and their existing answers remain valid.
|
||||
"""
|
||||
|
||||
option_ids: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollInvitationCommand:
|
||||
respondent_id: str | None = None
|
||||
@@ -98,6 +110,22 @@ class PollSubmitResponseCommand:
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollResponseRetirementCommand:
|
||||
"""Retire live responses while preserving their auditable history.
|
||||
|
||||
Owning modules provide every server-trusted identity that can refer to the
|
||||
participant. Providers must soft-delete matching live responses, retain
|
||||
their answers, and treat an exact ``idempotency_key`` replay as a no-op.
|
||||
"""
|
||||
|
||||
respondent_ids: tuple[str, ...] = ()
|
||||
invitation_id: str | None = None
|
||||
reason: str = "participant_removed"
|
||||
idempotency_key: str = ""
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollOptionRef:
|
||||
id: str
|
||||
@@ -138,6 +166,16 @@ class PollResponseRef:
|
||||
answers: tuple[PollAnswerRef, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollResponseRetirementRef:
|
||||
"""Outcome of an auditable response-retirement request."""
|
||||
|
||||
response_ids: tuple[str, ...] = ()
|
||||
retired_at: datetime | None = None
|
||||
newly_retired_count: int = 0
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PollSchedulingProvider(Protocol):
|
||||
def create_poll(
|
||||
@@ -183,6 +221,18 @@ class PollSchedulingProvider(Protocol):
|
||||
|
||||
...
|
||||
|
||||
def reorder_options(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollOptionOrderCommand,
|
||||
) -> PollRef:
|
||||
"""Atomically replace the complete active option order."""
|
||||
|
||||
...
|
||||
|
||||
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||
...
|
||||
|
||||
@@ -251,6 +301,21 @@ class PollResponseSubmissionProvider(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PollResponseRetirementProvider(Protocol):
|
||||
"""Optional extension for owning modules that remove participants."""
|
||||
|
||||
def retire_responses(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollResponseRetirementCommand,
|
||||
) -> PollResponseRetirementRef:
|
||||
...
|
||||
|
||||
|
||||
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
@@ -265,3 +330,20 @@ def poll_response_submission_provider(
|
||||
) -> PollResponseSubmissionProvider | None:
|
||||
provider = poll_scheduling_provider(registry)
|
||||
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
|
||||
|
||||
|
||||
def poll_response_retirement_provider(
|
||||
registry: object | None,
|
||||
) -> PollResponseRetirementProvider | None:
|
||||
"""Resolve response retirement without making it mandatory for Poll v1 providers."""
|
||||
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, PollResponseRetirementProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.poll import (
|
||||
PollAnswerRequest,
|
||||
PollInvitationRef,
|
||||
PollOptionRequest,
|
||||
PollResponseRef,
|
||||
)
|
||||
|
||||
|
||||
CAPABILITY_POLL_PARTICIPATION_GATEWAY = "poll.participation_gateway"
|
||||
# Policy-attestation identifier, not a credential or credential default.
|
||||
ANONYMOUS_PASSWORD_REQUIREMENT = "anonymous_password" # nosec B105 # noqa: S105
|
||||
PARTICIPATION_POLICY_VERSION = 1
|
||||
|
||||
|
||||
def participation_token_fingerprint(token: str) -> str:
|
||||
"""Return a non-reversible identifier suitable for audit/throttle keys."""
|
||||
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollResponseGatewayRef:
|
||||
"""Stable identity of the module resource governing a participation link."""
|
||||
|
||||
module_id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollParticipationPolicy:
|
||||
"""Generic response rules snapshotted onto one signed invitation.
|
||||
|
||||
``single_choice`` treats every non-``unavailable`` availability answer as
|
||||
a selection. Capacity is reserved only by ``available`` answers (and by
|
||||
selected answers for non-availability polls), so ``maybe`` never consumes
|
||||
a place.
|
||||
"""
|
||||
|
||||
version: int = PARTICIPATION_POLICY_VERSION
|
||||
single_choice: bool = False
|
||||
allow_maybe: bool = True
|
||||
max_participants_per_option: int | None = None
|
||||
allow_comments: bool = False
|
||||
participant_email_required: bool = False
|
||||
anonymous_password_required: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollGovernedInvitationCommand:
|
||||
gateway: PollResponseGatewayRef
|
||||
policy: PollParticipationPolicy
|
||||
respondent_id: str | None = None
|
||||
respondent_label: str | None = None
|
||||
email: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollGovernedResponseCommand:
|
||||
"""Submission already authorized by the named in-process gateway.
|
||||
|
||||
Passwords never cross this boundary. A gateway that owns a password
|
||||
verifier reports the completed check through ``verified_requirements``.
|
||||
Poll independently re-enforces the remaining snapshotted rules while
|
||||
holding its Poll-row lock.
|
||||
"""
|
||||
|
||||
respondent_id: str | None = None
|
||||
respondent_label: str | None = None
|
||||
participant_email: str | None = None
|
||||
participant_is_authenticated: bool = False
|
||||
answers: tuple[PollAnswerRequest, ...] = ()
|
||||
comment: str | None = None
|
||||
verified_requirements: frozenset[str] = frozenset()
|
||||
idempotency_key: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollGovernedResponseRef:
|
||||
response: PollResponseRef
|
||||
participant_email: str | None = None
|
||||
comment: str | None = None
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollOptionMutationRef:
|
||||
id: str
|
||||
position: int
|
||||
replayed: bool = False
|
||||
invalidated_response_count: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollInvitationRevocationRef:
|
||||
id: str
|
||||
revoked_at: datetime
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollInvitationExpiryRef:
|
||||
id: str
|
||||
expires_at: datetime | None
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollParticipationContextRef:
|
||||
invitation_id: str
|
||||
tenant_id: str
|
||||
poll_id: str
|
||||
gateway: PollResponseGatewayRef
|
||||
policy: PollParticipationPolicy
|
||||
respondent_id: str | None = None
|
||||
respondent_label: str | None = None
|
||||
email: str | None = None
|
||||
response: PollGovernedResponseRef | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollPublicInvitationRef:
|
||||
"""Non-sensitive routing identity for one valid governed invitation."""
|
||||
|
||||
invitation_id: str
|
||||
tenant_id: str
|
||||
poll_id: str
|
||||
gateway: PollResponseGatewayRef
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PollParticipationGatewayProvider(Protocol):
|
||||
def create_governed_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollGovernedInvitationCommand,
|
||||
) -> PollInvitationRef:
|
||||
...
|
||||
|
||||
def resolve_participation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
token: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
respondent_id: str | None = None,
|
||||
participant_email: str | None = None,
|
||||
participant_is_authenticated: bool = False,
|
||||
verified_requirements: frozenset[str] = frozenset(),
|
||||
) -> PollParticipationContextRef:
|
||||
"""Resolve and prefill one valid invitation for the exact gateway."""
|
||||
|
||||
...
|
||||
|
||||
def resolve_public_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
token: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
) -> PollPublicInvitationRef:
|
||||
"""Resolve tenant routing without disclosing participant details."""
|
||||
|
||||
...
|
||||
|
||||
def submit_governed_response(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
token: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
command: PollGovernedResponseCommand,
|
||||
) -> PollGovernedResponseRef:
|
||||
...
|
||||
|
||||
def resolve_authenticated_participation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
invitation_id: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
respondent_id: str,
|
||||
) -> PollParticipationContextRef:
|
||||
"""Resolve one governed invitation without retaining its public token."""
|
||||
|
||||
...
|
||||
|
||||
def submit_authenticated_response(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
invitation_id: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
respondent_id: str,
|
||||
command: PollGovernedResponseCommand,
|
||||
) -> PollGovernedResponseRef:
|
||||
"""Submit atomically for the exact authenticated invitation identity."""
|
||||
|
||||
...
|
||||
|
||||
def add_option(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollOptionRequest,
|
||||
) -> PollOptionMutationRef:
|
||||
...
|
||||
|
||||
def remove_option(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
option_id: str,
|
||||
) -> PollOptionMutationRef:
|
||||
...
|
||||
|
||||
def revoke_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
invitation_id: str,
|
||||
) -> PollInvitationRevocationRef:
|
||||
...
|
||||
|
||||
def update_invitation_expiry(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
invitation_id: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
expires_at: datetime | None,
|
||||
) -> PollInvitationExpiryRef:
|
||||
"""Expire or extend a non-revoked governed invitation in place."""
|
||||
|
||||
...
|
||||
|
||||
|
||||
def poll_participation_gateway_provider(
|
||||
registry: object | None,
|
||||
) -> PollParticipationGatewayProvider | None:
|
||||
"""Resolve the governed Poll gateway without importing its implementation."""
|
||||
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY)
|
||||
return capability if isinstance(capability, PollParticipationGatewayProvider) else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ANONYMOUS_PASSWORD_REQUIREMENT",
|
||||
"CAPABILITY_POLL_PARTICIPATION_GATEWAY",
|
||||
"PARTICIPATION_POLICY_VERSION",
|
||||
"PollGovernedInvitationCommand",
|
||||
"PollGovernedResponseCommand",
|
||||
"PollGovernedResponseRef",
|
||||
"PollInvitationExpiryRef",
|
||||
"PollInvitationRevocationRef",
|
||||
"PollOptionMutationRef",
|
||||
"PollParticipationContextRef",
|
||||
"PollParticipationGatewayProvider",
|
||||
"PollParticipationPolicy",
|
||||
"PollPublicInvitationRef",
|
||||
"PollResponseGatewayRef",
|
||||
"participation_token_fingerprint",
|
||||
"poll_participation_gateway_provider",
|
||||
]
|
||||
@@ -0,0 +1,575 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.events import EventClassification
|
||||
|
||||
|
||||
POSTBOX_MODULE_ID = "postbox"
|
||||
CAPABILITY_POSTBOX_DIRECTORY = "postbox.directory"
|
||||
CAPABILITY_POSTBOX_ACCESS = "postbox.access"
|
||||
CAPABILITY_POSTBOX_MESSAGES = "postbox.messages"
|
||||
CAPABILITY_POSTBOX_DELIVERY = "postbox.delivery"
|
||||
CAPABILITY_POSTBOX_EVIDENCE = "postbox.evidence"
|
||||
CAPABILITY_POSTBOX_ROUTING = "postbox.routing"
|
||||
|
||||
PostboxAction = Literal[
|
||||
"discover",
|
||||
"read",
|
||||
"send",
|
||||
"reply",
|
||||
"acknowledge",
|
||||
"administer",
|
||||
]
|
||||
PostboxMessageListState = Literal["all", "unread", "read", "acknowledged"]
|
||||
PostboxMessageAvailability = Literal["available", "withdrawn", "expired"]
|
||||
PostboxBindingStatus = Literal[
|
||||
"active",
|
||||
"missing",
|
||||
"not_effective",
|
||||
"unit_missing",
|
||||
"unit_inactive",
|
||||
"unit_tenant_mismatch",
|
||||
"function_missing",
|
||||
"function_inactive",
|
||||
"function_tenant_mismatch",
|
||||
"function_reassigned",
|
||||
"directory_unavailable",
|
||||
]
|
||||
|
||||
POSTBOX_CLASSIFICATIONS: tuple[EventClassification, ...] = (
|
||||
"public",
|
||||
"internal",
|
||||
"confidential",
|
||||
"restricted",
|
||||
)
|
||||
_POSTBOX_CLASSIFICATION_RANK = {
|
||||
value: rank for rank, value in enumerate(POSTBOX_CLASSIFICATIONS)
|
||||
}
|
||||
|
||||
|
||||
def normalize_postbox_classification(
|
||||
value: str,
|
||||
) -> EventClassification | None:
|
||||
candidate = value.strip().casefold()
|
||||
if candidate not in _POSTBOX_CLASSIFICATION_RANK:
|
||||
return None
|
||||
return candidate # type: ignore[return-value]
|
||||
|
||||
|
||||
def postbox_classification_allows(
|
||||
ceiling: str,
|
||||
content: str,
|
||||
) -> bool:
|
||||
normalized_ceiling = normalize_postbox_classification(ceiling)
|
||||
normalized_content = normalize_postbox_classification(content)
|
||||
if normalized_ceiling is None or normalized_content is None:
|
||||
return False
|
||||
return (
|
||||
_POSTBOX_CLASSIFICATION_RANK[normalized_content]
|
||||
<= _POSTBOX_CLASSIFICATION_RANK[normalized_ceiling]
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxActorRef:
|
||||
account_id: str
|
||||
identity_id: str | None = None
|
||||
selected_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
authorized_actions: frozenset[PostboxAction] = frozenset()
|
||||
authorized_classifications: frozenset[EventClassification] = frozenset(
|
||||
{"public", "internal"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxTargetRef:
|
||||
postbox_id: str | None = None
|
||||
address_key: str | None = None
|
||||
template_id: str | None = None
|
||||
organization_unit_id: str | None = None
|
||||
function_id: str | None = None
|
||||
context_key: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxDeliveryTemplateRef:
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
description: str | None
|
||||
published_revision_id: str
|
||||
function_type_id: str | None
|
||||
scope_kind: str
|
||||
scope_id: str | None
|
||||
classification: str
|
||||
allow_vacant_delivery: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxOrganizationFunctionTargetRef:
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
function_type_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxOrganizationUnitTargetRef:
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
unit_type_id: str | None = None
|
||||
parent_id: str | None = None
|
||||
functions: tuple[PostboxOrganizationFunctionTargetRef, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxDeliveryCatalogRef:
|
||||
postboxes: tuple["PostboxDirectoryEntryRef", ...] = ()
|
||||
templates: tuple[PostboxDeliveryTemplateRef, ...] = ()
|
||||
organization_units: tuple[PostboxOrganizationUnitTargetRef, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxAccessDecisionRef:
|
||||
allowed: bool
|
||||
action: PostboxAction
|
||||
postbox_id: str
|
||||
reason_code: str
|
||||
explanation: str
|
||||
organization_unit_id: str | None = None
|
||||
function_id: str | None = None
|
||||
assignment_ids: tuple[str, ...] = ()
|
||||
assignment_sources: tuple[str, ...] = ()
|
||||
selected_assignment_id: str | None = None
|
||||
holder_count: int = 0
|
||||
vacant: bool = True
|
||||
classification: str = "internal"
|
||||
classification_allowed: bool = True
|
||||
binding_status: PostboxBindingStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxDirectoryEntryRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
address: str
|
||||
address_key: str
|
||||
name: str
|
||||
status: str
|
||||
classification: str
|
||||
organization_unit_id: str | None = None
|
||||
organization_unit_name: str | None = None
|
||||
function_id: str | None = None
|
||||
function_name: str | None = None
|
||||
context_key: str | None = None
|
||||
template_revision_id: str | None = None
|
||||
holder_count: int = 0
|
||||
vacant: bool = True
|
||||
access: PostboxAccessDecisionRef | None = None
|
||||
resource_revision: int = 1
|
||||
etag: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxParticipantRef:
|
||||
kind: str
|
||||
reference_type: str
|
||||
reference_id: str | None = None
|
||||
label: str | None = None
|
||||
address: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxAttachmentRef:
|
||||
reference_type: str
|
||||
reference_id: str
|
||||
name: str | None = None
|
||||
media_type: str | None = None
|
||||
size_bytes: int | None = None
|
||||
digest: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxWrappedKeyRef:
|
||||
"""Opaque envelope-key record; key material remains crypto-provider owned."""
|
||||
|
||||
recipient_type: str
|
||||
recipient_id: str
|
||||
key_epoch: int
|
||||
wrapped_key_ref: str
|
||||
algorithm: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxExternalRecipientTokenRef:
|
||||
"""External grant state without the bearer secret itself."""
|
||||
|
||||
token_id: str
|
||||
state: str
|
||||
expires_at: datetime | None = None
|
||||
one_time: bool = False
|
||||
key_fetched_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
assurance_profile: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxMessageRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
postbox_id: str
|
||||
subject: str
|
||||
body_text: str | None
|
||||
status: str
|
||||
availability: PostboxMessageAvailability
|
||||
classification: str
|
||||
sender_label: str | None
|
||||
delivered_at: datetime
|
||||
read_at: datetime | None = None
|
||||
acknowledged_at: datetime | None = None
|
||||
expires_at: datetime | None = None
|
||||
withdrawn_at: datetime | None = None
|
||||
producer_module: str | None = None
|
||||
producer_resource_type: str | None = None
|
||||
producer_resource_id: str | None = None
|
||||
in_reply_to_message_id: str | None = None
|
||||
replaces_message_id: str | None = None
|
||||
encryption_profile: str = "plaintext_v1"
|
||||
key_epoch: int = 1
|
||||
ciphertext_ref: str | None = None
|
||||
signed_manifest_ref: str | None = None
|
||||
wrapped_keys: tuple[PostboxWrappedKeyRef, ...] = ()
|
||||
external_recipient_tokens: tuple[PostboxExternalRecipientTokenRef, ...] = ()
|
||||
participants: tuple[PostboxParticipantRef, ...] = ()
|
||||
attachments: tuple[PostboxAttachmentRef, ...] = ()
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxMessageAuthoringRequest:
|
||||
idempotency_key: str
|
||||
subject: str
|
||||
body_text: str | None = None
|
||||
classification: str = "internal"
|
||||
participants: tuple[PostboxParticipantRef, ...] = ()
|
||||
attachments: tuple[PostboxAttachmentRef, ...] = ()
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxDeliveryReceiptSummaryRef:
|
||||
delivery_id: str
|
||||
message_id: str
|
||||
postbox_id: str
|
||||
delivery_status: str
|
||||
accepted_at: datetime
|
||||
current_holder_count: int = 0
|
||||
currently_readable: bool = False
|
||||
message_count: int = 1
|
||||
routed_message_count: int = 0
|
||||
readable_message_count: int = 0
|
||||
read_receipt_count: int = 0
|
||||
acknowledged_receipt_count: int = 0
|
||||
withdrawn_message_count: int = 0
|
||||
expired_message_count: int = 0
|
||||
first_read_at: datetime | None = None
|
||||
last_read_at: datetime | None = None
|
||||
first_acknowledged_at: datetime | None = None
|
||||
last_acknowledged_at: datetime | None = None
|
||||
route_status_counts: Mapping[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxDeliveryRequest:
|
||||
tenant_id: str
|
||||
target: PostboxTargetRef
|
||||
producer_module: str
|
||||
producer_resource_type: str
|
||||
producer_resource_id: str | None
|
||||
idempotency_key: str
|
||||
subject: str
|
||||
body_text: str | None = None
|
||||
sender_label: str | None = None
|
||||
classification: str = "internal"
|
||||
participants: tuple[PostboxParticipantRef, ...] = ()
|
||||
attachments: tuple[PostboxAttachmentRef, ...] = ()
|
||||
expires_at: datetime | None = None
|
||||
ciphertext_ref: str | None = None
|
||||
signed_manifest_ref: str | None = None
|
||||
wrapped_keys: tuple[PostboxWrappedKeyRef, ...] = ()
|
||||
external_recipient_tokens: tuple[PostboxExternalRecipientTokenRef, ...] = ()
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxDeliveryResult:
|
||||
delivery_id: str
|
||||
postbox_id: str
|
||||
message_id: str
|
||||
address: str
|
||||
status: str
|
||||
vacant: bool
|
||||
holder_count: int
|
||||
duplicate: bool = False
|
||||
evidence: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
class PostboxDeliveryRejected(RuntimeError):
|
||||
"""A delivery was rejected before the provider accepted any effect."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
temporary: bool = False,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.temporary = temporary
|
||||
|
||||
|
||||
class PostboxDeliveryOutcomeUnknown(RuntimeError):
|
||||
"""The provider may have accepted an effect and must not be bypassed."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostboxDirectoryProvider(Protocol):
|
||||
def list_visible_postboxes(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
actor: PostboxActorRef,
|
||||
) -> Sequence[PostboxDirectoryEntryRef]:
|
||||
...
|
||||
|
||||
def resolve_postbox(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
target: PostboxTargetRef,
|
||||
materialize: bool = False,
|
||||
) -> PostboxDirectoryEntryRef | None:
|
||||
...
|
||||
|
||||
def delivery_catalog(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> PostboxDeliveryCatalogRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostboxAccessProvider(Protocol):
|
||||
def explain_access(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
postbox_id: str,
|
||||
actor: PostboxActorRef,
|
||||
action: PostboxAction,
|
||||
) -> PostboxAccessDecisionRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostboxMessagesProvider(Protocol):
|
||||
def list_messages(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
postbox_ids: Sequence[str],
|
||||
actor: PostboxActorRef,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
query: str | None = None,
|
||||
state: PostboxMessageListState = "all",
|
||||
) -> Sequence[PostboxMessageRef]:
|
||||
...
|
||||
|
||||
def get_message(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
message_id: str,
|
||||
actor: PostboxActorRef,
|
||||
) -> PostboxMessageRef | None:
|
||||
...
|
||||
|
||||
def mark_message(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
message_id: str,
|
||||
actor: PostboxActorRef,
|
||||
state: Literal["read", "acknowledged"],
|
||||
) -> PostboxMessageRef:
|
||||
...
|
||||
|
||||
def create_message(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
postbox_id: str,
|
||||
actor: PostboxActorRef,
|
||||
request: PostboxMessageAuthoringRequest,
|
||||
) -> PostboxMessageRef:
|
||||
...
|
||||
|
||||
def reply_to_message(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
message_id: str,
|
||||
actor: PostboxActorRef,
|
||||
request: PostboxMessageAuthoringRequest,
|
||||
) -> PostboxMessageRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostboxDeliveryProvider(Protocol):
|
||||
def deliver(
|
||||
self,
|
||||
session: object,
|
||||
request: PostboxDeliveryRequest,
|
||||
) -> PostboxDeliveryResult:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostboxEvidenceProvider(Protocol):
|
||||
def link_evidence(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
message_id: str,
|
||||
attachment: PostboxAttachmentRef,
|
||||
) -> PostboxMessageRef:
|
||||
...
|
||||
|
||||
def delivery_receipt_summaries(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
producer_module: str,
|
||||
delivery_ids: Sequence[str],
|
||||
) -> Mapping[str, PostboxDeliveryReceiptSummaryRef]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostboxRoutingProvider(Protocol):
|
||||
def dispatch_due_routes(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> Mapping[str, object]:
|
||||
...
|
||||
|
||||
|
||||
def _postbox_provider(
|
||||
registry: object | None,
|
||||
*,
|
||||
capability_name: str,
|
||||
provider_type: type,
|
||||
) -> object | None:
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(capability_name):
|
||||
return None
|
||||
capability = registry.capability(capability_name)
|
||||
return capability if isinstance(capability, provider_type) else None
|
||||
|
||||
|
||||
def postbox_directory_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxDirectoryProvider | None:
|
||||
provider = _postbox_provider(
|
||||
registry,
|
||||
capability_name=CAPABILITY_POSTBOX_DIRECTORY,
|
||||
provider_type=PostboxDirectoryProvider,
|
||||
)
|
||||
return provider if isinstance(provider, PostboxDirectoryProvider) else None
|
||||
|
||||
|
||||
def postbox_access_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxAccessProvider | None:
|
||||
provider = _postbox_provider(
|
||||
registry,
|
||||
capability_name=CAPABILITY_POSTBOX_ACCESS,
|
||||
provider_type=PostboxAccessProvider,
|
||||
)
|
||||
return provider if isinstance(provider, PostboxAccessProvider) else None
|
||||
|
||||
|
||||
def postbox_messages_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxMessagesProvider | None:
|
||||
provider = _postbox_provider(
|
||||
registry,
|
||||
capability_name=CAPABILITY_POSTBOX_MESSAGES,
|
||||
provider_type=PostboxMessagesProvider,
|
||||
)
|
||||
return provider if isinstance(provider, PostboxMessagesProvider) else None
|
||||
|
||||
|
||||
def postbox_delivery_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxDeliveryProvider | None:
|
||||
provider = _postbox_provider(
|
||||
registry,
|
||||
capability_name=CAPABILITY_POSTBOX_DELIVERY,
|
||||
provider_type=PostboxDeliveryProvider,
|
||||
)
|
||||
return provider if isinstance(provider, PostboxDeliveryProvider) else None
|
||||
|
||||
|
||||
def postbox_evidence_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxEvidenceProvider | None:
|
||||
provider = _postbox_provider(
|
||||
registry,
|
||||
capability_name=CAPABILITY_POSTBOX_EVIDENCE,
|
||||
provider_type=PostboxEvidenceProvider,
|
||||
)
|
||||
return provider if isinstance(provider, PostboxEvidenceProvider) else None
|
||||
|
||||
|
||||
def postbox_routing_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxRoutingProvider | None:
|
||||
provider = _postbox_provider(
|
||||
registry,
|
||||
capability_name=CAPABILITY_POSTBOX_ROUTING,
|
||||
provider_type=PostboxRoutingProvider,
|
||||
)
|
||||
return provider if isinstance(provider, PostboxRoutingProvider) else None
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.change_sequence import (
|
||||
ChangeSequenceEntry,
|
||||
retained_sequence_floor,
|
||||
record_change,
|
||||
)
|
||||
|
||||
AUTH_PRINCIPAL_REVISION_COLLECTION = "core.auth-principal-revisions"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthPrincipalRevision:
|
||||
system: int
|
||||
tenant: int
|
||||
|
||||
|
||||
def auth_principal_revision(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
) -> AuthPrincipalRevision:
|
||||
"""Return the durable global and tenant authorization revision.
|
||||
|
||||
The sequence rows make invalidation visible across processes. Retention
|
||||
floors keep revisions monotonic if old change rows are pruned.
|
||||
"""
|
||||
|
||||
rows = (
|
||||
session.query(
|
||||
ChangeSequenceEntry.tenant_id,
|
||||
func.max(ChangeSequenceEntry.id),
|
||||
)
|
||||
.filter(
|
||||
ChangeSequenceEntry.module_id == "core",
|
||||
ChangeSequenceEntry.collection == AUTH_PRINCIPAL_REVISION_COLLECTION,
|
||||
or_(
|
||||
ChangeSequenceEntry.tenant_id.is_(None),
|
||||
ChangeSequenceEntry.tenant_id == tenant_id,
|
||||
),
|
||||
)
|
||||
.group_by(ChangeSequenceEntry.tenant_id)
|
||||
.all()
|
||||
)
|
||||
revisions = {row_tenant_id: int(sequence_id or 0) for row_tenant_id, sequence_id in rows}
|
||||
system = max(
|
||||
revisions.get(None, 0),
|
||||
retained_sequence_floor(
|
||||
session,
|
||||
tenant_id=None,
|
||||
module_id="core",
|
||||
collections=(AUTH_PRINCIPAL_REVISION_COLLECTION,),
|
||||
),
|
||||
)
|
||||
tenant = 0
|
||||
if tenant_id is not None:
|
||||
tenant = max(
|
||||
revisions.get(tenant_id, 0),
|
||||
retained_sequence_floor(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
module_id="core",
|
||||
collections=(AUTH_PRINCIPAL_REVISION_COLLECTION,),
|
||||
),
|
||||
)
|
||||
return AuthPrincipalRevision(system=system, tenant=tenant)
|
||||
|
||||
|
||||
def invalidate_auth_principals(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
source_module: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
actor_type: str | None = None,
|
||||
actor_id: str | None = None,
|
||||
reason: str | None = None,
|
||||
) -> ChangeSequenceEntry:
|
||||
"""Advance the authorization revision in the caller's transaction."""
|
||||
|
||||
return record_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
module_id="core",
|
||||
collection=AUTH_PRINCIPAL_REVISION_COLLECTION,
|
||||
resource_type="auth_principal_revision",
|
||||
resource_id=tenant_id or "system",
|
||||
operation="invalidated",
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
payload={
|
||||
"source_module": source_module,
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
**({"reason": reason} if reason else {}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AUTH_PRINCIPAL_REVISION_COLLECTION",
|
||||
"AuthPrincipalRevision",
|
||||
"auth_principal_revision",
|
||||
"invalidate_auth_principals",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user