Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfc38a7bc9 | ||
|
|
e412c7d1bd | ||
|
|
9ed32618ea | ||
|
|
2cac95fa3e | ||
|
|
c558621550 | ||
|
|
c5119ab868 | ||
|
|
56f0661a10 | ||
|
|
c51ea3f66c | ||
|
|
5d49483369 | ||
|
|
4687e9e0d3 | ||
|
|
9bca590e53 | ||
|
|
64d6638c60 | ||
|
|
f43514be10 | ||
|
|
6a985d2a0e | ||
|
|
e8a22e54a5 | ||
|
|
e3c18f9aa8 | ||
|
|
d4bfc6e45a | ||
|
|
e76fe16870 | ||
|
|
2ab89eb809 | ||
|
|
118e96db43 | ||
|
|
b40615f8ac | ||
|
|
922b3f43b7 | ||
|
|
f823c30707 | ||
|
|
f065aa500a | ||
|
|
1dde038547 | ||
|
|
efbec82761 | ||
|
|
0ca9bd793c | ||
|
|
cd7cc674c7 |
@@ -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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# GovOPlaN Tenancy Codex Guide
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This repository owns tenant lifecycle, tenant administration, tenant context resolution, and tenant settings over Core's shared scope storage.
|
||||||
|
|
||||||
|
## Documentation Contract
|
||||||
|
|
||||||
|
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
|
||||||
|
- Keep feature content here; `govoplan-docs` projects it without importing Tenancy internals.
|
||||||
|
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- Core owns shared scope storage; Access owns authentication and permission evaluation.
|
||||||
|
- Preserve tenant isolation, ownership, and lifecycle recovery guarantees.
|
||||||
@@ -1,11 +1,38 @@
|
|||||||
# GovOPlaN Tenancy
|
# GovOPlaN Tenancy
|
||||||
|
|
||||||
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (platform).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
`govoplan-tenancy` owns tenant lifecycle, tenant administration API route
|
`govoplan-tenancy` owns tenant lifecycle, tenant administration API route
|
||||||
contributions, and the `tenancy.tenantResolver` capability during the GovOPlaN
|
contributions, the `tenancy.tenantResolver` capability, and the tenant registry
|
||||||
module split.
|
and tenant settings WebUI panels during the GovOPlaN module split.
|
||||||
|
|
||||||
`govoplan-access` no longer hard-depends on this module. Access can run in the
|
`govoplan-access` no longer hard-depends on this module. Access can run in the
|
||||||
single-scope compatibility mode used by the core/access baseline; installing
|
single-scope compatibility mode used by the core/access baseline; installing
|
||||||
tenancy adds explicit tenant management and resolver behavior. The shared scope
|
tenancy adds explicit tenant management and resolver behavior. The shared scope
|
||||||
storage table is core-owned as `core_scopes`; tenancy provides lifecycle and
|
storage table is core-owned as `core_scopes`; tenancy provides lifecycle and
|
||||||
administration behavior over those rows rather than owning the table.
|
administration behavior over those rows rather than owning the table.
|
||||||
|
|
||||||
|
The `@govoplan/tenancy-webui` package contributes `system-tenants` and
|
||||||
|
`tenant-settings` through the shared `admin.sections` capability. The Access
|
||||||
|
module owns the `/admin` shell but does not import these panels. Historical
|
||||||
|
`access.admin.*` surface identifiers remain stable so existing saved Views keep
|
||||||
|
working after the ownership move.
|
||||||
|
|
||||||
|
The tenant registry and active-tenant settings follow the shared interface
|
||||||
|
pattern contract documented in
|
||||||
|
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||||
|
Manifest-provided documentation topics back contextual help for tenant fields,
|
||||||
|
governance limits, permission blockers, and lifecycle consequences.
|
||||||
|
|
||||||
|
Destructive tenant erasure is an explicit, durable workflow rather than a
|
||||||
|
single delete request. Provider previews, policy-defined multi-party approval,
|
||||||
|
recent authentication, typed confirmation, suspension, idempotent checkpoints,
|
||||||
|
and reconciliation must all succeed before the Core scope is removed. See
|
||||||
|
`docs/TENANCY_MODULE_BOUNDARY.md` for the API and recovery contract.
|
||||||
|
|
||||||
|
Core's `module_entitlements` tenant-setting key is reserved. Generic tenant
|
||||||
|
updates preserve it even when replacing the remaining settings document;
|
||||||
|
system and tenant module administrators change it through the Admin module's
|
||||||
|
dedicated, revision-checked module policy APIs.
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Tenancy Interface Pattern Migration
|
||||||
|
|
||||||
|
This document records the bounded migration of Tenancy-owned WebUI surfaces to
|
||||||
|
the GovOPlaN interface pattern language. Core owns the shared components and the
|
||||||
|
Admin host; Tenancy owns the behavior and documentation described here.
|
||||||
|
|
||||||
|
## Surface Inventory
|
||||||
|
|
||||||
|
| Surface | Archetype | Consequence class | Contract |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `tenancy.admin.system-tenants` | Administration directory and list-detail | Create, configure, suspend | Shared admin layout, DataGrid, stable row actions, adaptive create/edit dialog, lifecycle confirmation, contextual help |
|
||||||
|
| Tenant details dialog | Read-only evidence/detail | None | Stable labels, effective governance provenance, retained object counts |
|
||||||
|
| `tenancy.admin.tenant-settings` | Effective configuration | Configure | Shared admin layout, language selection, dirty-state guard, permission blocker, contextual help |
|
||||||
|
|
||||||
|
## Consequence And Availability Rules
|
||||||
|
|
||||||
|
- Creating a tenant establishes a new data and administration boundary and
|
||||||
|
provisions a protected initial owner.
|
||||||
|
- Tenant slugs are immutable after creation.
|
||||||
|
- System policy caps tenant governance overrides. A tenant can narrow an
|
||||||
|
allowance but cannot loosen a system denial.
|
||||||
|
- Suspension retains tenant-owned records and audit evidence. The active
|
||||||
|
tenant cannot be suspended until the operator changes context.
|
||||||
|
- Unavailable actions remain visible when they belong to the surface and state
|
||||||
|
the missing permission, inapplicable state, responsible actor, and
|
||||||
|
destination where applicable.
|
||||||
|
- Dirty dialogs and settings use the shared unsaved-change guard. Consequential
|
||||||
|
suspension continues to use the shared destructive confirmation dialog.
|
||||||
|
|
||||||
|
## State And Accessibility Evidence
|
||||||
|
|
||||||
|
The panels use Core loading, error, success, empty, disabled-action, blocker,
|
||||||
|
dialog, and status components. Row actions reserve a stable three-action area,
|
||||||
|
retain translated accessible labels, and do not disappear for row-specific
|
||||||
|
permission or lifecycle states. Dialog order follows identity, ownership,
|
||||||
|
locale/status, description, and governed capabilities. Shared dialogs own focus
|
||||||
|
containment and restoration, and the existing Admin shell provides responsive
|
||||||
|
composition.
|
||||||
|
|
||||||
|
Stable help references are contributed through the module manifest for the
|
||||||
|
tenant registry, tenant settings, lifecycle actions, and individual fields.
|
||||||
|
The WebUI structural test and backend documentation-contract test prevent those
|
||||||
|
references and state explanations from silently regressing.
|
||||||
@@ -19,11 +19,83 @@ compatibility, but both routes delegate to the same
|
|||||||
Tenant retirement is non-destructive. It marks the tenant inactive and stores
|
Tenant retirement is non-destructive. It marks the tenant inactive and stores
|
||||||
lifecycle metadata in tenant settings.
|
lifecycle metadata in tenant settings.
|
||||||
|
|
||||||
Destructive deletion is intentionally narrow: it is allowed only when the
|
The compatibility `DELETE /api/v1/admin/tenants/{tenant_id}` route is
|
||||||
tenant is not the caller's active tenant and all registered tenant-owned counts
|
non-destructive retirement only. Requests with `mode=destroy` fail with a link
|
||||||
are zero. Populated tenants must be retired first or cleaned explicitly by
|
to the governed erasure-operation API; even an apparently empty scope must not
|
||||||
their owning modules before physical deletion.
|
bypass recent authentication, typed confirmation, approval, and durable
|
||||||
|
evidence.
|
||||||
|
|
||||||
|
Populated-tenant erasure uses `/erasure-operations` instead. A durable
|
||||||
|
operation stores a non-secret, digest-bound provider preview, policy snapshot,
|
||||||
|
distinct approvals, and per-step checkpoints. The safe default production
|
||||||
|
policy requires two distinct approvals, a preview no older than fifteen
|
||||||
|
minutes, recent interactive authentication, the dedicated
|
||||||
|
`system:tenants:erase` permission, and exact tenant-slug confirmation. The
|
||||||
|
policy is configurable through `tenant_erasure_policy` in system settings;
|
||||||
|
production profiles cannot reduce approval below two.
|
||||||
|
Authorized system administrators read and update it through
|
||||||
|
`GET/PATCH /api/v1/admin/tenant-erasure-policy`; policy changes require both
|
||||||
|
`system:tenants:erase` and `system:settings:write` plus recent interactive
|
||||||
|
authentication.
|
||||||
|
|
||||||
|
Execution verifies the preview again, suspends tenant access, and commits a
|
||||||
|
checkpoint before each module effect. A timeout or unknown/pending outcome
|
||||||
|
stops for reconciliation using the same provider idempotency key. Cancellation
|
||||||
|
is available only before destructive work starts. The Core scope is deleted
|
||||||
|
only after providers finish, a fresh inventory is clear, and delete vetoes and
|
||||||
|
tenant counts are zero. The durable operation then removes its free-text reason
|
||||||
|
and never stores typed confirmation, secrets, or erased tenant content.
|
||||||
|
|
||||||
|
The module's `privacy.dsar.tenancy` provider returns bounded requester and
|
||||||
|
approver roles, approval timestamps, operation state, and an unfinished
|
||||||
|
request reason only to the corroborated account selector. These actor and
|
||||||
|
checkpoint references are immutable authorization, separation-of-duties, and
|
||||||
|
recovery evidence, so the provider returns an explicit non-executable retain
|
||||||
|
action. Typed confirmation and credentials are never persisted; free-text
|
||||||
|
reason is removed when the tenant-erasure operation completes.
|
||||||
|
|
||||||
Tenant lifecycle planning uses registered tenant summary providers and delete
|
Tenant lifecycle planning uses registered tenant summary providers and delete
|
||||||
veto providers. Modules that own tenant-scoped data must contribute summaries
|
veto providers. Modules that own tenant-scoped data must contribute summaries
|
||||||
so destructive deletion cannot silently miss their rows.
|
so destructive deletion cannot silently miss their rows.
|
||||||
|
|
||||||
|
Delete veto providers are registered through module manifests and receive
|
||||||
|
`(session, tenant_id, resource_id)`. Providers should return a structured
|
||||||
|
`DeleteVetoIssue`, a list of issues, or `None`; legacy providers that raise an
|
||||||
|
exception are treated as blocking module vetoes. Tenancy exposes those issues in
|
||||||
|
the deletion plan with module attribution and resource details, so operators can
|
||||||
|
see which module blocks or qualifies the lifecycle action.
|
||||||
|
|
||||||
|
Core's `tenancy.erasure_provider.<module_id>` contract supplies module-owned
|
||||||
|
resource dispositions, irreversible warnings, ordered steps, idempotent
|
||||||
|
execution, and outcome reconciliation. An installed module with nonzero tenant
|
||||||
|
summary counts but no erasure provider explicitly blocks the operation. A
|
||||||
|
module that declares neither a tenant summary nor an erasure provider is
|
||||||
|
reported as outside tenant-persistence scope rather than silently executed.
|
||||||
|
Access provides the first concrete contribution and retains shared global
|
||||||
|
accounts and identities while erasing target-tenant authorization records.
|
||||||
|
|
||||||
|
## Lifecycle Events
|
||||||
|
|
||||||
|
`govoplan-tenancy.backend.lifecycle` is the module-local contract for tenant
|
||||||
|
lifecycle event names and payload shape. Modules that need to react to tenant
|
||||||
|
lifecycle changes should depend on the event type strings or the emitted audit
|
||||||
|
events, not on tenancy API route internals.
|
||||||
|
|
||||||
|
The stable lifecycle event names are:
|
||||||
|
|
||||||
|
- `tenant.created`: tenant registry entry was created and owner membership
|
||||||
|
provisioning was requested.
|
||||||
|
- `tenant.suspended`: tenant was marked inactive through the admin lifecycle
|
||||||
|
route.
|
||||||
|
- `tenant.resumed`: tenant was reactivated through the admin lifecycle route.
|
||||||
|
- `tenant.deletion_requested`: retirement or destructive erasure was requested
|
||||||
|
after lifecycle planning passed.
|
||||||
|
- `tenant.erasure_completed`: destructive tenant deletion completed.
|
||||||
|
|
||||||
|
Legacy audit actions such as `tenant.updated`, `tenant.retired`, and
|
||||||
|
`tenant.destroyed` can still be emitted for compatibility. New module behavior
|
||||||
|
should key off the explicit lifecycle events above.
|
||||||
|
|
||||||
|
Lifecycle event details use concrete tenant identifiers plus optional actor,
|
||||||
|
reason, count, and mode information. Destructive erasure is only emitted after
|
||||||
|
the tenant row is successfully scheduled for deletion in the same transaction.
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/tenancy-webui",
|
||||||
|
"version": "0.1.21",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-tenancy"
|
name = "govoplan-tenancy"
|
||||||
version = "0.1.6"
|
version = "0.1.21"
|
||||||
description = "GovOPlaN tenancy platform module."
|
description = "GovOPlaN tenancy platform module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.6",
|
"govoplan-core>=0.1.43",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,10 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
from govoplan_core.api.v1.schemas import DeltaDeletedItem, NavigationPreferencesPayload
|
||||||
|
from govoplan_core.i18n import REFERENCE_LANGUAGE_CODE
|
||||||
|
|
||||||
|
|
||||||
class TenantAdminItem(BaseModel):
|
class TenantAdminItem(BaseModel):
|
||||||
@@ -13,7 +14,11 @@ class TenantAdminItem(BaseModel):
|
|||||||
slug: str = Field(min_length=1, max_length=100)
|
slug: str = Field(min_length=1, max_length=100)
|
||||||
name: str = Field(min_length=1, max_length=255)
|
name: str = Field(min_length=1, max_length=255)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
default_locale: str = Field(default="en", min_length=1, max_length=20)
|
default_locale: str = Field(
|
||||||
|
default=REFERENCE_LANGUAGE_CODE,
|
||||||
|
min_length=1,
|
||||||
|
max_length=20,
|
||||||
|
)
|
||||||
settings: dict[str, Any] = Field(default_factory=dict)
|
settings: dict[str, Any] = Field(default_factory=dict)
|
||||||
allow_custom_groups: bool | None = None
|
allow_custom_groups: bool | None = None
|
||||||
allow_custom_roles: bool | None = None
|
allow_custom_roles: bool | None = None
|
||||||
@@ -27,9 +32,13 @@ class TenantAdminItem(BaseModel):
|
|||||||
|
|
||||||
class TenantListResponse(BaseModel):
|
class TenantListResponse(BaseModel):
|
||||||
tenants: list[TenantAdminItem]
|
tenants: list[TenantAdminItem]
|
||||||
|
total: int = 0
|
||||||
|
page: int = 1
|
||||||
|
page_size: int = 100
|
||||||
|
pages: int = 1
|
||||||
|
|
||||||
|
|
||||||
class TenantListDeltaResponse(BaseModel):
|
class TenantListDeltaResponse(TenantListResponse):
|
||||||
tenants: list[TenantAdminItem] = Field(default_factory=list)
|
tenants: list[TenantAdminItem] = Field(default_factory=list)
|
||||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||||
watermark: str | None = None
|
watermark: str | None = None
|
||||||
@@ -54,7 +63,7 @@ class TenantCreateRequest(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
owner_account_id: str | None = None
|
owner_account_id: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
default_locale: str = "en"
|
default_locale: str = REFERENCE_LANGUAGE_CODE
|
||||||
settings: dict[str, Any] = Field(default_factory=dict)
|
settings: dict[str, Any] = Field(default_factory=dict)
|
||||||
allow_custom_groups: bool | None = None
|
allow_custom_groups: bool | None = None
|
||||||
allow_custom_roles: bool | None = None
|
allow_custom_roles: bool | None = None
|
||||||
@@ -79,6 +88,7 @@ class TenantLifecycleIssue(BaseModel):
|
|||||||
code: str
|
code: str
|
||||||
message: str
|
message: str
|
||||||
module_id: str | None = None
|
module_id: str | None = None
|
||||||
|
details: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class TenantDeletionPlanResponse(BaseModel):
|
class TenantDeletionPlanResponse(BaseModel):
|
||||||
@@ -102,6 +112,66 @@ class TenantLifecycleResponse(BaseModel):
|
|||||||
plan: TenantDeletionPlanResponse
|
plan: TenantDeletionPlanResponse
|
||||||
|
|
||||||
|
|
||||||
|
class TenantErasurePreviewRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
idempotency_key: str = Field(min_length=8, max_length=160)
|
||||||
|
reason: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantErasureApprovalRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
confirmation: str = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantErasureExecutionRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
confirmation: str = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantErasurePolicyResponse(BaseModel):
|
||||||
|
production_profile: bool
|
||||||
|
required_approvals: int = Field(ge=1, le=10)
|
||||||
|
preview_ttl_seconds: int = Field(ge=60, le=86400)
|
||||||
|
recent_authentication_seconds: int = Field(ge=60, le=86400)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantErasurePolicyUpdateRequest(TenantErasurePolicyResponse):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class TenantErasureOperationResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
state: Literal[
|
||||||
|
"awaiting_approval",
|
||||||
|
"blocked",
|
||||||
|
"ready",
|
||||||
|
"running",
|
||||||
|
"reconciliation_required",
|
||||||
|
"cancelled",
|
||||||
|
"completed",
|
||||||
|
]
|
||||||
|
preview: dict[str, Any]
|
||||||
|
preview_digest: str
|
||||||
|
previewed_at: datetime
|
||||||
|
preview_expires_at: datetime
|
||||||
|
policy: TenantErasurePolicyResponse
|
||||||
|
approvals: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
steps: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
requested_by_account_id: str
|
||||||
|
reason: str | None = None
|
||||||
|
destructive_started: bool
|
||||||
|
revision: int
|
||||||
|
started_at: datetime | None = None
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
last_error: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class TenantContextSwitchRequest(BaseModel):
|
class TenantContextSwitchRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -119,10 +189,24 @@ class TenantSettingsItem(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
slug: str
|
slug: str
|
||||||
name: str
|
name: str
|
||||||
default_locale: str = Field(default="en", min_length=1, max_length=20)
|
default_locale: str = Field(
|
||||||
|
default=REFERENCE_LANGUAGE_CODE,
|
||||||
|
min_length=1,
|
||||||
|
max_length=20,
|
||||||
|
)
|
||||||
available_languages: list[dict[str, Any]] = Field(default_factory=list)
|
available_languages: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
system_enabled_language_codes: list[str] = Field(default_factory=list)
|
system_enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||||
|
navigation: NavigationPreferencesPayload | None = None
|
||||||
|
appearance_palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
|
||||||
|
appearance_palette_locked: bool = False
|
||||||
|
system_appearance_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
|
||||||
|
system_appearance_palette_locked: bool = False
|
||||||
|
effective_appearance_palette: Literal["default", "civic_blue", "forest", "plum"] = "default"
|
||||||
|
effective_appearance_source: Literal["tenant", "system", "tenant_lock", "system_lock"] = "system"
|
||||||
|
appearance_custom_overrides_allowed: bool | None = None
|
||||||
|
system_appearance_custom_overrides_allowed: bool = False
|
||||||
|
effective_appearance_custom_overrides_allowed: bool = False
|
||||||
settings: dict[str, Any] = Field(default_factory=dict)
|
settings: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@@ -141,3 +225,7 @@ class TenantSettingsUpdateRequest(BaseModel):
|
|||||||
|
|
||||||
default_locale: str = Field(min_length=1, max_length=20)
|
default_locale: str = Field(min_length=1, max_length=20)
|
||||||
enabled_language_codes: list[str] | None = None
|
enabled_language_codes: list[str] | None = None
|
||||||
|
navigation: NavigationPreferencesPayload | None = None
|
||||||
|
appearance_palette: Literal["default", "civic_blue", "forest", "plum"] | None = None
|
||||||
|
appearance_palette_locked: bool | None = None
|
||||||
|
appearance_custom_overrides_allowed: bool | None = None
|
||||||
|
|||||||
@@ -1,6 +1,53 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Index, Integer, JSON, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from govoplan_core.db.base import Base, TimestampMixin
|
||||||
from govoplan_core.tenancy.scope import Tenant, new_uuid
|
from govoplan_core.tenancy.scope import Tenant, new_uuid
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["Tenant", "new_uuid"]
|
class TenantErasureOperation(Base, TimestampMixin):
|
||||||
|
__tablename__ = "tenancy_erasure_operations"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_tenancy_erasure_tenant_idempotency",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_tenancy_erasure_tenant_state",
|
||||||
|
"tenant_id",
|
||||||
|
"state",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=new_uuid,
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
state: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||||
|
request_digest: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
preview_digest: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
preview: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
previewed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
preview_expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
approvals: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
steps: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
requested_by_account_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text)
|
||||||
|
destructive_started: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["Tenant", "TenantErasureOperation", "new_uuid"]
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
dsar_capability_name,
|
||||||
|
)
|
||||||
|
from govoplan_tenancy.backend.db.models import TenantErasureOperation
|
||||||
|
|
||||||
|
|
||||||
|
TENANCY_DSAR_CAPABILITY = dsar_capability_name("tenancy")
|
||||||
|
_MAX_TENANT_OPERATIONS = 5_000
|
||||||
|
_MAX_SUBJECT_RECORDS = 500
|
||||||
|
|
||||||
|
|
||||||
|
class TenancyDsarProvider:
|
||||||
|
provider_id = "tenancy"
|
||||||
|
module_id = "tenancy"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
db = _session(session)
|
||||||
|
account_id = _account_id(subject)
|
||||||
|
if account_id is None:
|
||||||
|
return ()
|
||||||
|
operations = (
|
||||||
|
db.query(TenantErasureOperation)
|
||||||
|
.filter(TenantErasureOperation.tenant_id == tenant_id)
|
||||||
|
.order_by(TenantErasureOperation.created_at, TenantErasureOperation.id)
|
||||||
|
.limit(_MAX_TENANT_OPERATIONS + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if len(operations) > _MAX_TENANT_OPERATIONS:
|
||||||
|
raise ValueError(
|
||||||
|
"Tenancy DSAR operation scan limit exceeded; narrow the tenant scope."
|
||||||
|
)
|
||||||
|
records = tuple(
|
||||||
|
record
|
||||||
|
for operation in operations
|
||||||
|
if (record := _subject_record(operation, account_id)) is not None
|
||||||
|
)
|
||||||
|
if len(records) > _MAX_SUBJECT_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Tenancy DSAR subject result limit exceeded; use a narrower request window."
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]:
|
||||||
|
del session, tenant_id
|
||||||
|
if _account_id(subject) is None:
|
||||||
|
raise ValueError("Tenancy DSAR requires one corroborated account.")
|
||||||
|
actions: list[DsarErasureActionRef] = []
|
||||||
|
for record in records:
|
||||||
|
_validate_record(record)
|
||||||
|
actions.append(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id=(
|
||||||
|
f"tenancy:retain:tenant_erasure_operation:{record.resource_id}"
|
||||||
|
),
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
kind="retain",
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title="Retain tenant-erasure governance evidence",
|
||||||
|
rationale=(
|
||||||
|
"The actor reference and approval timestamp are bounded "
|
||||||
|
"security evidence required to prove authorization, separation "
|
||||||
|
"of duties, checkpoints, and recovery. The operation never stores "
|
||||||
|
"typed confirmation or credentials, and its free-text reason is "
|
||||||
|
"removed at completion."
|
||||||
|
),
|
||||||
|
executable=False,
|
||||||
|
irreversible=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(actions)
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]:
|
||||||
|
del session, tenant_id, request_id
|
||||||
|
if _account_id(subject) is None:
|
||||||
|
raise ValueError("Tenancy DSAR requires one corroborated account.")
|
||||||
|
results: list[DsarExecutionResultRef] = []
|
||||||
|
for action in actions:
|
||||||
|
_validate_action(action)
|
||||||
|
results.append(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary=(
|
||||||
|
"Tenant-erasure authorization and recovery evidence is retained "
|
||||||
|
"under the recorded governance purpose."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_record(
|
||||||
|
operation: TenantErasureOperation,
|
||||||
|
account_id: str,
|
||||||
|
) -> DsarRecordRef | None:
|
||||||
|
requested = operation.requested_by_account_id == account_id
|
||||||
|
approvals = tuple(
|
||||||
|
item
|
||||||
|
for item in operation.approvals or []
|
||||||
|
if item.get("account_id") == account_id
|
||||||
|
)
|
||||||
|
if not requested and not approvals:
|
||||||
|
return None
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"actor_roles": [
|
||||||
|
*(("requester",) if requested else ()),
|
||||||
|
*(("approver",) if approvals else ()),
|
||||||
|
],
|
||||||
|
"approval_timestamps": [
|
||||||
|
str(item.get("approved_at"))
|
||||||
|
for item in approvals
|
||||||
|
if item.get("approved_at")
|
||||||
|
],
|
||||||
|
"state": operation.state,
|
||||||
|
"destructive_started": operation.destructive_started,
|
||||||
|
"completed_at": _iso(operation.completed_at),
|
||||||
|
}
|
||||||
|
if requested and operation.reason:
|
||||||
|
data["request_reason"] = operation.reason
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="tenancy",
|
||||||
|
module_id="tenancy",
|
||||||
|
resource_type="tenant_erasure_operation",
|
||||||
|
resource_id=operation.id,
|
||||||
|
category="security_and_governance_evidence",
|
||||||
|
title="Tenant-erasure authorization and recovery evidence",
|
||||||
|
data=data,
|
||||||
|
observed_at=_aware(operation.updated_at),
|
||||||
|
immutable_evidence=True,
|
||||||
|
retention_reason=(
|
||||||
|
"Authorization, separation-of-duties, destructive-effect, and recovery evidence."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _account_id(subject: DsarSubjectRef) -> str | None:
|
||||||
|
candidates = {
|
||||||
|
value.strip()
|
||||||
|
for value in (
|
||||||
|
subject.account_id,
|
||||||
|
subject.external_references.get("access.account"),
|
||||||
|
subject.external_references.get("tenancy.actor_account"),
|
||||||
|
)
|
||||||
|
if isinstance(value, str) and value.strip()
|
||||||
|
}
|
||||||
|
return next(iter(candidates)) if len(candidates) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Tenancy DSAR requires a SQLAlchemy Session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime | None) -> datetime | None:
|
||||||
|
if value is None or value.tzinfo is not None:
|
||||||
|
return value
|
||||||
|
return value.replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
aware = _aware(value)
|
||||||
|
return aware.isoformat() if aware else None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_record(record: DsarRecordRef) -> None:
|
||||||
|
if record.provider_id != "tenancy" or record.module_id != "tenancy":
|
||||||
|
raise ValueError("Tenancy DSAR cannot plan a foreign provider record.")
|
||||||
|
if record.resource_type != "tenant_erasure_operation" or not record.resource_id:
|
||||||
|
raise ValueError("Tenancy DSAR record identity is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||||
|
if action.provider_id != "tenancy" or action.module_id != "tenancy":
|
||||||
|
raise ValueError("Tenancy DSAR cannot execute a foreign provider action.")
|
||||||
|
if (
|
||||||
|
action.kind != "retain"
|
||||||
|
or action.executable
|
||||||
|
or not action.action_id.startswith("tenancy:retain:")
|
||||||
|
):
|
||||||
|
raise ValueError("Tenancy DSAR action is invalid.")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["TENANCY_DSAR_CAPABILITY", "TenancyDsarProvider"]
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.admin.settings import get_system_settings
|
||||||
|
from govoplan_core.core.tenant_erasure import (
|
||||||
|
TenantErasureInventory,
|
||||||
|
TenantErasureStepResult,
|
||||||
|
collect_tenant_erasure_inventory,
|
||||||
|
tenant_erasure_providers,
|
||||||
|
)
|
||||||
|
from govoplan_core.security.time import utc_now
|
||||||
|
from govoplan_tenancy.backend.db.models import TenantErasureOperation
|
||||||
|
|
||||||
|
|
||||||
|
TENANT_ERASURE_POLICY_KEY = "tenant_erasure_policy"
|
||||||
|
TERMINAL_ERASURE_STATES = frozenset({"cancelled", "completed"})
|
||||||
|
|
||||||
|
|
||||||
|
class TenantErasureConflict(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TenantErasurePolicy:
|
||||||
|
production_profile: bool = True
|
||||||
|
required_approvals: int = 2
|
||||||
|
preview_ttl_seconds: int = 900
|
||||||
|
recent_authentication_seconds: int = 900
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not 1 <= self.required_approvals <= 10:
|
||||||
|
raise ValueError("Tenant erasure required approvals must be between 1 and 10.")
|
||||||
|
if not 60 <= self.preview_ttl_seconds <= 86_400:
|
||||||
|
raise ValueError("Tenant erasure preview lifetime must be between 60 seconds and one day.")
|
||||||
|
if not 60 <= self.recent_authentication_seconds <= 86_400:
|
||||||
|
raise ValueError("Tenant erasure authentication window must be between 60 seconds and one day.")
|
||||||
|
if self.production_profile and self.required_approvals < 2:
|
||||||
|
raise ValueError("Production tenant erasure requires at least two approvals.")
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"production_profile": self.production_profile,
|
||||||
|
"required_approvals": self.required_approvals,
|
||||||
|
"preview_ttl_seconds": self.preview_ttl_seconds,
|
||||||
|
"recent_authentication_seconds": self.recent_authentication_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_erasure_policy(session: Session) -> TenantErasurePolicy:
|
||||||
|
settings = get_system_settings(session)
|
||||||
|
raw = (settings.settings or {}).get(TENANT_ERASURE_POLICY_KEY, {})
|
||||||
|
if raw is None:
|
||||||
|
raw = {}
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError("Tenant erasure policy must be an object.")
|
||||||
|
allowed = {
|
||||||
|
"production_profile",
|
||||||
|
"required_approvals",
|
||||||
|
"preview_ttl_seconds",
|
||||||
|
"recent_authentication_seconds",
|
||||||
|
}
|
||||||
|
unknown = set(raw) - allowed
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
"Tenant erasure policy contains unsupported fields: "
|
||||||
|
+ ", ".join(sorted(str(item) for item in unknown))
|
||||||
|
)
|
||||||
|
production = raw.get("production_profile", True)
|
||||||
|
if type(production) is not bool:
|
||||||
|
raise ValueError("Tenant erasure production profile must be boolean.")
|
||||||
|
default_approvals = 2 if production else 1
|
||||||
|
return TenantErasurePolicy(
|
||||||
|
production_profile=production,
|
||||||
|
required_approvals=_policy_integer(
|
||||||
|
raw, "required_approvals", default_approvals
|
||||||
|
),
|
||||||
|
preview_ttl_seconds=_policy_integer(
|
||||||
|
raw, "preview_ttl_seconds", 900
|
||||||
|
),
|
||||||
|
recent_authentication_seconds=_policy_integer(
|
||||||
|
raw, "recent_authentication_seconds", 900
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_integer(raw: dict[str, object], key: str, default: int) -> int:
|
||||||
|
value = raw.get(key, default)
|
||||||
|
if type(value) is not int:
|
||||||
|
raise ValueError(f"Tenant erasure {key} must be an integer.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_digest(value: object) -> str:
|
||||||
|
encoded = json.dumps(
|
||||||
|
value,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=True,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_erasure_inventory_digest(inventory: TenantErasureInventory | dict[str, Any]) -> str:
|
||||||
|
payload = inventory.to_dict() if isinstance(inventory, TenantErasureInventory) else inventory
|
||||||
|
stable = {
|
||||||
|
"schema_version": payload.get("schema_version"),
|
||||||
|
"tenant_id": payload.get("tenant_id"),
|
||||||
|
"complete": payload.get("complete"),
|
||||||
|
"allowed": payload.get("allowed"),
|
||||||
|
"modules": payload.get("modules"),
|
||||||
|
}
|
||||||
|
return _canonical_digest(stable)
|
||||||
|
|
||||||
|
|
||||||
|
def create_tenant_erasure_operation(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
registry: object,
|
||||||
|
tenant_id: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
requested_by_account_id: str,
|
||||||
|
reason: str | None,
|
||||||
|
current_time: datetime | None = None,
|
||||||
|
) -> tuple[TenantErasureOperation, bool]:
|
||||||
|
normalized_key = idempotency_key.strip()
|
||||||
|
if len(normalized_key) < 8 or len(normalized_key) > 160:
|
||||||
|
raise ValueError("Tenant erasure idempotency key is invalid.")
|
||||||
|
normalized_reason = reason.strip() if reason and reason.strip() else None
|
||||||
|
request_digest = _canonical_digest(
|
||||||
|
{
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"idempotency_key": normalized_key,
|
||||||
|
"reason": normalized_reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
existing = session.execute(
|
||||||
|
select(TenantErasureOperation).where(
|
||||||
|
TenantErasureOperation.tenant_id == tenant_id,
|
||||||
|
TenantErasureOperation.idempotency_key == normalized_key,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
if existing.request_digest != request_digest:
|
||||||
|
raise TenantErasureConflict(
|
||||||
|
"Tenant erasure idempotency key was already used for another request."
|
||||||
|
)
|
||||||
|
return existing, True
|
||||||
|
|
||||||
|
now = _aware_utc(current_time or utc_now())
|
||||||
|
policy = tenant_erasure_policy(session)
|
||||||
|
inventory = collect_tenant_erasure_inventory(
|
||||||
|
registry,
|
||||||
|
session,
|
||||||
|
tenant_id,
|
||||||
|
observed_at=now,
|
||||||
|
)
|
||||||
|
preview = inventory.to_dict()
|
||||||
|
operation = TenantErasureOperation(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
state="awaiting_approval" if inventory.allowed else "blocked",
|
||||||
|
idempotency_key=normalized_key,
|
||||||
|
request_digest=request_digest,
|
||||||
|
preview_digest=tenant_erasure_inventory_digest(inventory),
|
||||||
|
preview=preview,
|
||||||
|
previewed_at=now,
|
||||||
|
preview_expires_at=now + timedelta(seconds=policy.preview_ttl_seconds),
|
||||||
|
policy=policy.to_dict(),
|
||||||
|
approvals=[],
|
||||||
|
steps=_planned_steps(inventory),
|
||||||
|
requested_by_account_id=requested_by_account_id,
|
||||||
|
reason=normalized_reason,
|
||||||
|
destructive_started=False,
|
||||||
|
revision=1,
|
||||||
|
)
|
||||||
|
session.add(operation)
|
||||||
|
session.flush()
|
||||||
|
return operation, False
|
||||||
|
|
||||||
|
|
||||||
|
def _planned_steps(inventory: TenantErasureInventory) -> list[dict[str, object]]:
|
||||||
|
planned: list[dict[str, object]] = []
|
||||||
|
for module in inventory.modules:
|
||||||
|
pending = {step.step_id: step for step in module.steps}
|
||||||
|
resolved: set[str] = set()
|
||||||
|
while pending:
|
||||||
|
step = min(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for candidate in pending.values()
|
||||||
|
if set(candidate.depends_on).issubset(resolved)
|
||||||
|
),
|
||||||
|
key=lambda candidate: candidate.step_id,
|
||||||
|
)
|
||||||
|
planned.append(
|
||||||
|
{
|
||||||
|
"module_id": module.module_id,
|
||||||
|
**step.to_dict(),
|
||||||
|
"state": "planned",
|
||||||
|
"attempts": 0,
|
||||||
|
"result": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resolved.add(step.step_id)
|
||||||
|
pending.pop(step.step_id)
|
||||||
|
return planned
|
||||||
|
|
||||||
|
|
||||||
|
def approve_tenant_erasure_operation(
|
||||||
|
operation: TenantErasureOperation,
|
||||||
|
*,
|
||||||
|
account_id: str,
|
||||||
|
confirmation: str,
|
||||||
|
tenant_slug: str,
|
||||||
|
current_time: datetime | None = None,
|
||||||
|
) -> bool:
|
||||||
|
now = _aware_utc(current_time or utc_now())
|
||||||
|
_assert_preview_current(operation, now)
|
||||||
|
if operation.state == "blocked":
|
||||||
|
raise TenantErasureConflict("Blocked tenant erasure cannot be approved.")
|
||||||
|
if operation.state in TERMINAL_ERASURE_STATES:
|
||||||
|
raise TenantErasureConflict(
|
||||||
|
f"Tenant erasure operation is already {operation.state}."
|
||||||
|
)
|
||||||
|
if confirmation != tenant_slug:
|
||||||
|
raise TenantErasureConflict("Typed tenant confirmation does not match the tenant slug.")
|
||||||
|
approvals = list(operation.approvals or [])
|
||||||
|
if any(item.get("account_id") == account_id for item in approvals):
|
||||||
|
return True
|
||||||
|
approvals.append({"account_id": account_id, "approved_at": now.isoformat()})
|
||||||
|
operation.approvals = approvals
|
||||||
|
required = int((operation.policy or {}).get("required_approvals", 2))
|
||||||
|
operation.state = "ready" if len(approvals) >= required else "awaiting_approval"
|
||||||
|
operation.revision += 1
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_tenant_erasure_operation(
|
||||||
|
operation: TenantErasureOperation,
|
||||||
|
*,
|
||||||
|
current_time: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
del current_time
|
||||||
|
if operation.destructive_started:
|
||||||
|
raise TenantErasureConflict(
|
||||||
|
"Tenant erasure cannot be cancelled after destructive work started."
|
||||||
|
)
|
||||||
|
if operation.state == "completed":
|
||||||
|
raise TenantErasureConflict("Completed tenant erasure cannot be cancelled.")
|
||||||
|
operation.state = "cancelled"
|
||||||
|
operation.last_error = None
|
||||||
|
operation.revision += 1
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_erasure_recently_authenticated(
|
||||||
|
auth_session: object | None,
|
||||||
|
policy: TenantErasurePolicy | dict[str, Any],
|
||||||
|
*,
|
||||||
|
current_time: datetime | None = None,
|
||||||
|
) -> bool:
|
||||||
|
created_at = getattr(auth_session, "created_at", None)
|
||||||
|
if not isinstance(created_at, datetime):
|
||||||
|
return False
|
||||||
|
created_at = _aware_utc(created_at)
|
||||||
|
now = _aware_utc(current_time or utc_now())
|
||||||
|
seconds = (
|
||||||
|
policy.recent_authentication_seconds
|
||||||
|
if isinstance(policy, TenantErasurePolicy)
|
||||||
|
else int(policy.get("recent_authentication_seconds", 900))
|
||||||
|
)
|
||||||
|
elapsed = now - created_at
|
||||||
|
return timedelta(0) <= elapsed <= timedelta(seconds=seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_tenant_erasure_executable(
|
||||||
|
operation: TenantErasureOperation,
|
||||||
|
*,
|
||||||
|
confirmation: str,
|
||||||
|
tenant_slug: str,
|
||||||
|
current_time: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
now = _aware_utc(current_time or utc_now())
|
||||||
|
_assert_preview_current(operation, now)
|
||||||
|
if confirmation != tenant_slug:
|
||||||
|
raise TenantErasureConflict("Typed tenant confirmation does not match the tenant slug.")
|
||||||
|
if operation.state not in {"ready", "running", "reconciliation_required"}:
|
||||||
|
raise TenantErasureConflict(
|
||||||
|
f"Tenant erasure operation is not executable while {operation.state}."
|
||||||
|
)
|
||||||
|
required = int((operation.policy or {}).get("required_approvals", 2))
|
||||||
|
approvers = {
|
||||||
|
str(item.get("account_id"))
|
||||||
|
for item in operation.approvals or []
|
||||||
|
if item.get("account_id")
|
||||||
|
}
|
||||||
|
if len(approvers) < required:
|
||||||
|
raise TenantErasureConflict("Tenant erasure does not have enough distinct approvals.")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_tenant_erasure_preview(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
registry: object,
|
||||||
|
operation: TenantErasureOperation,
|
||||||
|
current_time: datetime | None = None,
|
||||||
|
) -> bool:
|
||||||
|
now = _aware_utc(current_time or utc_now())
|
||||||
|
_assert_preview_current(operation, now)
|
||||||
|
inventory = collect_tenant_erasure_inventory(
|
||||||
|
registry,
|
||||||
|
session,
|
||||||
|
operation.tenant_id,
|
||||||
|
observed_at=now,
|
||||||
|
)
|
||||||
|
return tenant_erasure_inventory_digest(inventory) == operation.preview_digest
|
||||||
|
|
||||||
|
|
||||||
|
def run_tenant_erasure_steps(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
registry: object,
|
||||||
|
operation: TenantErasureOperation,
|
||||||
|
current_time: datetime | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Run or reconcile provider steps, committing a checkpoint before each effect."""
|
||||||
|
|
||||||
|
now = _aware_utc(current_time or utc_now())
|
||||||
|
providers = tenant_erasure_providers(registry)
|
||||||
|
operation.state = "running"
|
||||||
|
operation.started_at = operation.started_at or now
|
||||||
|
operation.last_error = None
|
||||||
|
operation.revision += 1
|
||||||
|
session.add(operation)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
for index, original in enumerate(list(operation.steps or [])):
|
||||||
|
if original.get("state") == "completed":
|
||||||
|
continue
|
||||||
|
if not _dependencies_completed(operation.steps, original):
|
||||||
|
operation.state = "reconciliation_required"
|
||||||
|
operation.last_error = "A tenant erasure step dependency is incomplete."
|
||||||
|
operation.revision += 1
|
||||||
|
session.add(operation)
|
||||||
|
session.commit()
|
||||||
|
return False
|
||||||
|
module_id = str(original.get("module_id") or "")
|
||||||
|
step_id = str(original.get("step_id") or "")
|
||||||
|
provider = providers.get(module_id)
|
||||||
|
if provider is None:
|
||||||
|
operation.state = "reconciliation_required"
|
||||||
|
operation.last_error = f"Tenant erasure provider {module_id} is unavailable."
|
||||||
|
operation.revision += 1
|
||||||
|
session.add(operation)
|
||||||
|
session.commit()
|
||||||
|
return False
|
||||||
|
|
||||||
|
step = dict(original)
|
||||||
|
previous_state = str(step.get("state") or "planned")
|
||||||
|
step["state"] = "running"
|
||||||
|
step["started_at"] = utc_now().isoformat()
|
||||||
|
step["attempts"] = int(step.get("attempts") or 0) + 1
|
||||||
|
steps = list(operation.steps or [])
|
||||||
|
steps[index] = step
|
||||||
|
operation.steps = steps
|
||||||
|
if bool(step.get("destructive")):
|
||||||
|
operation.destructive_started = True
|
||||||
|
operation.revision += 1
|
||||||
|
session.add(operation)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
execution_key = f"{operation.id}:{module_id}:{step_id}"
|
||||||
|
try:
|
||||||
|
if previous_state in {"pending", "outcome_unknown", "running"}:
|
||||||
|
result = provider.reconcile_tenant_erasure_step(
|
||||||
|
session,
|
||||||
|
operation.tenant_id,
|
||||||
|
step_id,
|
||||||
|
execution_key,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = provider.execute_tenant_erasure_step(
|
||||||
|
session,
|
||||||
|
operation.tenant_id,
|
||||||
|
step_id,
|
||||||
|
execution_key,
|
||||||
|
)
|
||||||
|
if not isinstance(result, TenantErasureStepResult):
|
||||||
|
raise TypeError("Tenant erasure provider returned an invalid result.")
|
||||||
|
except Exception as exc:
|
||||||
|
session.rollback()
|
||||||
|
operation = session.get(TenantErasureOperation, operation.id)
|
||||||
|
if operation is None:
|
||||||
|
raise RuntimeError("Tenant erasure checkpoint disappeared.") from exc
|
||||||
|
steps = list(operation.steps or [])
|
||||||
|
failed = dict(steps[index])
|
||||||
|
failed["state"] = "outcome_unknown"
|
||||||
|
failed["result"] = {
|
||||||
|
"state": "outcome_unknown",
|
||||||
|
"summary": f"{type(exc).__name__}: provider outcome requires reconciliation",
|
||||||
|
"receipt_ref": None,
|
||||||
|
"metrics": {},
|
||||||
|
}
|
||||||
|
steps[index] = failed
|
||||||
|
operation.steps = steps
|
||||||
|
operation.state = "reconciliation_required"
|
||||||
|
operation.last_error = str(failed["result"]["summary"])
|
||||||
|
operation.revision += 1
|
||||||
|
session.add(operation)
|
||||||
|
session.commit()
|
||||||
|
return False
|
||||||
|
|
||||||
|
steps = list(operation.steps or [])
|
||||||
|
completed = dict(steps[index])
|
||||||
|
completed["state"] = result.state
|
||||||
|
completed["result"] = result.to_dict()
|
||||||
|
steps[index] = completed
|
||||||
|
operation.steps = steps
|
||||||
|
operation.revision += 1
|
||||||
|
if result.state == "completed":
|
||||||
|
session.add(operation)
|
||||||
|
session.commit()
|
||||||
|
continue
|
||||||
|
operation.state = "reconciliation_required"
|
||||||
|
operation.last_error = result.summary
|
||||||
|
session.add(operation)
|
||||||
|
session.commit()
|
||||||
|
return False
|
||||||
|
|
||||||
|
operation.state = "running"
|
||||||
|
operation.last_error = None
|
||||||
|
operation.revision += 1
|
||||||
|
session.add(operation)
|
||||||
|
session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def complete_tenant_erasure_operation(
|
||||||
|
operation: TenantErasureOperation,
|
||||||
|
*,
|
||||||
|
current_time: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
operation.state = "completed"
|
||||||
|
operation.completed_at = _aware_utc(current_time or utc_now())
|
||||||
|
operation.reason = None
|
||||||
|
operation.last_error = None
|
||||||
|
operation.revision += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _dependencies_completed(
|
||||||
|
steps: list[dict[str, Any]],
|
||||||
|
candidate: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
module_id = candidate.get("module_id")
|
||||||
|
dependencies = set(candidate.get("depends_on") or [])
|
||||||
|
completed = {
|
||||||
|
step.get("step_id")
|
||||||
|
for step in steps
|
||||||
|
if step.get("module_id") == module_id and step.get("state") == "completed"
|
||||||
|
}
|
||||||
|
return dependencies.issubset(completed)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_preview_current(
|
||||||
|
operation: TenantErasureOperation,
|
||||||
|
current_time: datetime,
|
||||||
|
) -> None:
|
||||||
|
if _aware_utc(operation.preview_expires_at) < current_time:
|
||||||
|
raise TenantErasureConflict(
|
||||||
|
"Tenant erasure preview expired; create a new operation."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _aware_utc(value: datetime) -> datetime:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=UTC)
|
||||||
|
return value.astimezone(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"TENANT_ERASURE_POLICY_KEY",
|
||||||
|
"TenantErasureConflict",
|
||||||
|
"TenantErasurePolicy",
|
||||||
|
"approve_tenant_erasure_operation",
|
||||||
|
"assert_tenant_erasure_executable",
|
||||||
|
"cancel_tenant_erasure_operation",
|
||||||
|
"complete_tenant_erasure_operation",
|
||||||
|
"create_tenant_erasure_operation",
|
||||||
|
"run_tenant_erasure_steps",
|
||||||
|
"tenant_erasure_inventory_digest",
|
||||||
|
"tenant_erasure_policy",
|
||||||
|
"tenant_erasure_recently_authenticated",
|
||||||
|
"verify_tenant_erasure_preview",
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""German translations for public structured documentation metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'tenancy.reference.admin-fields': {'consequence_classes': {'create': 'Erstellt eine neue '
|
||||||
|
'Mandantgrenze und stellt '
|
||||||
|
'seinen geschützten '
|
||||||
|
'ursprünglichen Eigentümer '
|
||||||
|
'bereit.',
|
||||||
|
'suspend': 'Blockiert die normale '
|
||||||
|
'Nutzung von Mandantn, '
|
||||||
|
'während Daten und '
|
||||||
|
'Prüfungsnachweise '
|
||||||
|
'beibehalten werden.',
|
||||||
|
'update': 'Ändert Mandanten-lokale '
|
||||||
|
'Identität, Locale oder '
|
||||||
|
'Governance-Konfiguration.'}}}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
TenantLifecyclePhase = Literal[
|
||||||
|
"created",
|
||||||
|
"suspended",
|
||||||
|
"resumed",
|
||||||
|
"deletion_requested",
|
||||||
|
"erasure_completed",
|
||||||
|
]
|
||||||
|
|
||||||
|
TENANT_EVENT_CREATED = "tenant.created"
|
||||||
|
TENANT_EVENT_SUSPENDED = "tenant.suspended"
|
||||||
|
TENANT_EVENT_RESUMED = "tenant.resumed"
|
||||||
|
TENANT_EVENT_DELETION_REQUESTED = "tenant.deletion_requested"
|
||||||
|
TENANT_EVENT_ERASURE_COMPLETED = "tenant.erasure_completed"
|
||||||
|
|
||||||
|
TENANT_LIFECYCLE_EVENTS: tuple[str, ...] = (
|
||||||
|
TENANT_EVENT_CREATED,
|
||||||
|
TENANT_EVENT_SUSPENDED,
|
||||||
|
TENANT_EVENT_RESUMED,
|
||||||
|
TENANT_EVENT_DELETION_REQUESTED,
|
||||||
|
TENANT_EVENT_ERASURE_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
_EVENT_BY_PHASE: Mapping[TenantLifecyclePhase, str] = {
|
||||||
|
"created": TENANT_EVENT_CREATED,
|
||||||
|
"suspended": TENANT_EVENT_SUSPENDED,
|
||||||
|
"resumed": TENANT_EVENT_RESUMED,
|
||||||
|
"deletion_requested": TENANT_EVENT_DELETION_REQUESTED,
|
||||||
|
"erasure_completed": TENANT_EVENT_ERASURE_COMPLETED,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TenantLifecycleEvent:
|
||||||
|
event_type: str
|
||||||
|
tenant_id: str
|
||||||
|
tenant_slug: str | None = None
|
||||||
|
tenant_name: str | None = None
|
||||||
|
actor_account_id: str | None = None
|
||||||
|
requested_by_account_id: str | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
counts: Mapping[str, int] = field(default_factory=dict)
|
||||||
|
occurred_at: datetime | None = None
|
||||||
|
details: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def audit_details(self) -> dict[str, object]:
|
||||||
|
payload: dict[str, object] = dict(self.details)
|
||||||
|
if self.tenant_slug is not None:
|
||||||
|
payload["slug"] = self.tenant_slug
|
||||||
|
if self.tenant_name is not None:
|
||||||
|
payload["name"] = self.tenant_name
|
||||||
|
if self.actor_account_id is not None:
|
||||||
|
payload["actor_account_id"] = self.actor_account_id
|
||||||
|
if self.requested_by_account_id is not None:
|
||||||
|
payload["requested_by_account_id"] = self.requested_by_account_id
|
||||||
|
if self.reason is not None:
|
||||||
|
payload["reason"] = self.reason
|
||||||
|
if self.counts:
|
||||||
|
payload["counts"] = dict(self.counts)
|
||||||
|
if self.occurred_at is not None:
|
||||||
|
payload["occurred_at"] = self.occurred_at.isoformat()
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_lifecycle_event_type(phase: TenantLifecyclePhase) -> str:
|
||||||
|
return _EVENT_BY_PHASE[phase]
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_lifecycle_event(
|
||||||
|
phase: TenantLifecyclePhase,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
tenant_slug: str | None = None,
|
||||||
|
tenant_name: str | None = None,
|
||||||
|
actor_account_id: str | None = None,
|
||||||
|
requested_by_account_id: str | None = None,
|
||||||
|
reason: str | None = None,
|
||||||
|
counts: Mapping[str, int] | None = None,
|
||||||
|
occurred_at: datetime | None = None,
|
||||||
|
details: Mapping[str, object] | None = None,
|
||||||
|
) -> TenantLifecycleEvent:
|
||||||
|
return TenantLifecycleEvent(
|
||||||
|
event_type=tenant_lifecycle_event_type(phase),
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
tenant_slug=tenant_slug,
|
||||||
|
tenant_name=tenant_name,
|
||||||
|
actor_account_id=actor_account_id,
|
||||||
|
requested_by_account_id=requested_by_account_id,
|
||||||
|
reason=reason,
|
||||||
|
counts=dict(counts or {}),
|
||||||
|
occurred_at=occurred_at,
|
||||||
|
details=dict(details or {}),
|
||||||
|
)
|
||||||
@@ -1,12 +1,41 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||||
|
from govoplan_tenancy.backend.german_structured_documentation import (
|
||||||
|
GERMAN_STRUCTURED_TRANSLATIONS,
|
||||||
|
)
|
||||||
|
|
||||||
from govoplan_core.core.access import (
|
from govoplan_core.core.access import (
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||||
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
|
DocumentationLink,
|
||||||
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleManifest,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.module_guards import (
|
||||||
|
drop_table_retirement_provider,
|
||||||
|
persistent_table_uninstall_guard,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_tenancy.backend.db.models import TenantErasureOperation
|
||||||
|
from govoplan_tenancy.backend.dsar_provider import (
|
||||||
|
TENANCY_DSAR_CAPABILITY,
|
||||||
|
TenancyDsarProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _tenant_resolver(context: ModuleContext):
|
def _tenant_resolver(context: ModuleContext):
|
||||||
@@ -27,19 +56,402 @@ def _route_factory(context: ModuleContext):
|
|||||||
return aggregate
|
return aggregate
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(_context: ModuleContext) -> TenancyDsarProvider:
|
||||||
|
return TenancyDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="tenancy",
|
id="tenancy",
|
||||||
name="Tenancy",
|
name="Tenancy",
|
||||||
version="0.1.6",
|
version="0.1.21",
|
||||||
required_capabilities=(
|
required_capabilities=(
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||||
),
|
),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(name=TENANCY_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
|
),
|
||||||
route_factory=_route_factory,
|
route_factory=_route_factory,
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_TENANCY_TENANT_RESOLVER: _tenant_resolver,
|
CAPABILITY_TENANCY_TENANT_RESOLVER: _tenant_resolver,
|
||||||
|
TENANCY_DSAR_CAPABILITY: _dsar_provider,
|
||||||
},
|
},
|
||||||
|
capability_documentation={
|
||||||
|
TENANCY_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Tenancy data-subject request provider",
|
||||||
|
summary=(
|
||||||
|
"Exports bounded tenant-erasure actor and approval evidence and "
|
||||||
|
"explains its non-executable governance retention."
|
||||||
|
),
|
||||||
|
contract_version="0.1.0",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
migration_spec=MigrationSpec(
|
||||||
|
module_id="tenancy",
|
||||||
|
metadata=Base.metadata,
|
||||||
|
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||||
|
retirement_supported=True,
|
||||||
|
retirement_provider=drop_table_retirement_provider(
|
||||||
|
TenantErasureOperation,
|
||||||
|
label="Tenancy erasure-operation evidence",
|
||||||
|
),
|
||||||
|
retirement_notes=(
|
||||||
|
"Destructive retirement removes tenant-erasure approvals and "
|
||||||
|
"checkpoint evidence only after the installer captures a database snapshot."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
uninstall_guard_providers=(
|
||||||
|
persistent_table_uninstall_guard(
|
||||||
|
TenantErasureOperation,
|
||||||
|
label="tenant-erasure operations",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="tenancy.current-context",
|
||||||
|
title="Work in the correct tenant context",
|
||||||
|
summary="The active tenant determines which tenant-scoped data, roles, settings, and module configuration are visible for a request.",
|
||||||
|
body="Accounts with access to more than one tenant can switch context through the platform tenant selector. Switching changes the active scope; it does not copy data or grant new authority. Always verify the selected tenant before creating or changing tenant-owned records.",
|
||||||
|
documentation_types=("user",),
|
||||||
|
audience=("user", "tenant_admin"),
|
||||||
|
related_modules=("access",),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Im richtigen Mandantenkontext arbeiten",
|
||||||
|
"summary": (
|
||||||
|
"Der aktive Mandant bestimmt, welche mandantenbezogenen Daten, Rollen, Einstellungen und Modulkonfigurationen für eine "
|
||||||
|
"Anfrage sichtbar sind."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Konten mit Zugriff auf mehrere Mandanten können den Kontext über die Mandantenauswahl der Plattform wechseln. Der "
|
||||||
|
"Wechsel ändert den aktiven Geltungsbereich; er kopiert keine Daten und gewährt keine neue Befugnis. Prüfen Sie den "
|
||||||
|
"ausgewählten Mandanten immer, bevor Sie mandanteneigene Datensätze anlegen oder ändern."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": ["tenancy.current-context", "tenancy.selector"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="tenancy.lifecycle-and-settings",
|
||||||
|
title="Administer tenant lifecycle and settings",
|
||||||
|
summary="Tenancy adds explicit tenant creation, activation, context resolution, and tenant-owned settings over Core's shared scope storage.",
|
||||||
|
body="A tenant is a concrete administrative and data boundary. Tenant lifecycle changes must preserve ownership and recovery guarantees for module-owned records. Retirement and suspension retain data. Destructive erasure is a separate, provider-neutral workflow with a digest-bound preview, recent interactive authentication, exact slug confirmation, distinct approvals, resumable checkpoints, and final reconciliation before the Core scope is removed. Provider absence, timeout, stale evidence, tenant data without an erasure contribution, legal holds, retention requirements, external cleanup, backup expiry, and unknown outcomes remain visible and fail closed. Execution suspends access before the first provider step; cancellation is permitted only before destructive work begins. New tenants default to the German reference language unless the administrator selects another enabled system language; existing tenant and user preferences remain unchanged. Tenant administrators can inherit or override the system side-rail order and visibility and can lock entries visible for users; system locks remain effective. Personal navigation preferences still take precedence except that they cannot hide locked entries. Tenant appearance likewise inherits the system palette until explicitly selected; an unlocked tenant default permits a personal palette, while a policy-authorized tenant lock suppresses it and a system lock always wins. Resetting the tenant palette restores inheritance rather than copying the current system value. When the system permits advanced personal color overrides, a policy-authorized tenant administrator may inherit, allow, or block them; the tenant cannot enable a system-denied policy, and palette locks still suppress the editor. Advanced documents cover both light and dark modes and are validated atomically by Core. Navigation and appearance changes never grant module entitlement, View visibility, or permissions. Tenancy contributes system tenant management and tenant settings to the shared administration workspace; without this module, the Core and Access baseline can operate in single-scope compatibility mode. Core-reserved module entitlement settings are managed only through the Admin module's tenant-module policy endpoints and are preserved when generic tenant settings are replaced.",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("system_admin", "tenant_admin", "operator"),
|
||||||
|
related_modules=("access", "admin", "audit"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("tenancy", "access"),
|
||||||
|
any_scopes=(
|
||||||
|
"access:tenant:read",
|
||||||
|
"access:tenant:update",
|
||||||
|
"access:setting:read",
|
||||||
|
"access:setting:write",
|
||||||
|
"system:tenants:erase",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Tenant administration", href="/admin", kind="runtime"
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Tenant registry API",
|
||||||
|
href="/api/v1/admin/tenants",
|
||||||
|
kind="api",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Tenant settings API",
|
||||||
|
href="/api/v1/admin/tenant/settings",
|
||||||
|
kind="api",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Tenant erasure preview API",
|
||||||
|
href="/api/v1/admin/tenants/{tenant_id}/erasure-operations",
|
||||||
|
kind="api",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Mandantenlebenszyklus und -einstellungen administrieren",
|
||||||
|
"summary": (
|
||||||
|
"Tenancy ergänzt ausdrückliche Mandantenanlage, Aktivierung, Kontextauflösung und mandanteneigene Einstellungen über "
|
||||||
|
"Cores gemeinsamen Bereichsspeicher."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Ein Mandant ist eine konkrete Administrations- und Datengrenze. Änderungen am Mandantenlebenszyklus müssen Eigentums- "
|
||||||
|
"und Wiederherstellungsgarantien für modulbezogene Datensätze bewahren. Ruhestand und Sperrung bewahren Daten. Die "
|
||||||
|
"destruktive Löschung ist ein eigener, anbieterneutraler Ablauf mit digestgebundener Vorschau, aktueller interaktiver "
|
||||||
|
"Authentifizierung, exakter Slug-Bestätigung, getrennten Freigaben, fortsetzbaren Prüfpunkten und abschließendem Abgleich, "
|
||||||
|
"bevor Core den Mandantenbereich entfernt. Fehlende oder nicht erreichbare Anbieter, veraltete Nachweise, Mandantendaten "
|
||||||
|
"ohne Löschbeitrag, rechtliche Sperren, Aufbewahrung, externe Bereinigung, Ablauf von Sicherungen und unbekannte Ergebnisse "
|
||||||
|
"bleiben sichtbar und blockieren. Die Ausführung sperrt den Zugriff vor dem ersten Anbieterschritt; ein Abbruch ist nur vor "
|
||||||
|
"destruktiver Arbeit möglich. Neue Mandanten verwenden standardmäßig die "
|
||||||
|
"deutsche Referenzsprache, sofern keine andere aktivierte Systemsprache gewählt wird; bestehende Mandanten- und "
|
||||||
|
"Benutzerpräferenzen bleiben unverändert. Mandantenadministrierende können systemweite Reihenfolge und Sichtbarkeit der "
|
||||||
|
"Seitenleiste erben oder überschreiben und Einträge für Benutzende sichtbar sperren; Systemsperren bleiben wirksam. "
|
||||||
|
"Persönliche Navigationspräferenzen behalten Vorrang, können gesperrte Einträge aber nicht ausblenden. Das Erscheinungsbild "
|
||||||
|
"erbt ebenfalls die Systempalette, bis es ausdrücklich gewählt wird. Ein ungesperrter Mandantenstandard erlaubt eine "
|
||||||
|
"persönliche Palette; eine richtlinienautorisierte Mandantensperre unterdrückt sie, und eine Systemsperre hat immer Vorrang. "
|
||||||
|
"Zurücksetzen stellt Vererbung wieder her, statt den aktuellen Systemwert zu kopieren. Erlaubt das System erweiterte "
|
||||||
|
"persönliche Farbanpassungen, darf eine richtlinienautorisierte Mandantenadministration sie erben, erlauben oder blockieren; "
|
||||||
|
"eine systemweite Ablehnung kann nicht gelockert werden und Palettensperren unterdrücken den Editor weiterhin. Erweiterte "
|
||||||
|
"Dokumente umfassen hellen und dunklen Modus und werden von Core atomar validiert. Navigation und Erscheinungsbild gewähren "
|
||||||
|
"niemals Modulberechtigung, View-Sichtbarkeit oder Zugriffsrechte. Tenancy trägt systemweite Mandantenverwaltung und "
|
||||||
|
"Mandanteneinstellungen zum gemeinsamen Admin-Arbeitsbereich bei; ohne das Modul können Core und Access in einem "
|
||||||
|
"Einzelbereichskompatibilitätsmodus arbeiten. Für Core reservierte Modulberechtigungseinstellungen werden ausschließlich "
|
||||||
|
"über Admins Mandanten-Modulrichtlinienendpunkte verwaltet und beim Ersetzen allgemeiner Mandanteneinstellungen bewahrt."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": [
|
||||||
|
"tenancy.admin.system-tenants",
|
||||||
|
"tenancy.admin.tenant-settings",
|
||||||
|
"tenancy.admin.lifecycle",
|
||||||
|
"tenancy.admin.blocked",
|
||||||
|
"tenancy.admin.tenant-erasure",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="tenancy.workflow.tenant-erasure",
|
||||||
|
title="Preview, approve, and reconcile tenant erasure",
|
||||||
|
summary=(
|
||||||
|
"System operators erase a tenant only through fresh provider evidence, "
|
||||||
|
"typed confirmation, policy-defined approvals, and resumable checkpoints."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"Create an erasure operation with a unique idempotency key and review every "
|
||||||
|
"module resource, disposition, warning, blocker, irreversible step, external "
|
||||||
|
"cleanup, key-destruction task, and backup-expiry obligation. The system setting "
|
||||||
|
"tenant_erasure_policy selects production mode, one to ten required distinct "
|
||||||
|
"approvals, preview lifetime, and recent-authentication window. Production mode "
|
||||||
|
"requires at least two approvals; the safe default is two approvals and fifteen "
|
||||||
|
"minutes for preview and authentication freshness. Each approver types the exact "
|
||||||
|
"tenant slug from a recently authenticated interactive session. Execution requires "
|
||||||
|
"the dedicated system:tenants:erase permission, repeats the confirmation, verifies "
|
||||||
|
"that the preview digest still matches, suspends the tenant, and checkpoints before "
|
||||||
|
"every provider effect. Timeouts and pending or unknown outcomes stop in "
|
||||||
|
"reconciliation_required; the reconcile action uses the same provider idempotency "
|
||||||
|
"key. Cancellation is rejected after destructive work starts. Completion removes "
|
||||||
|
"the Core scope only after a new inventory and all delete vetoes are clear, then "
|
||||||
|
"retains bounded operation and audit evidence without the typed confirmation, "
|
||||||
|
"request reason, credentials, or erased tenant content. The Tenancy DSAR "
|
||||||
|
"provider exports a subject's requester/approver role and timestamps and "
|
||||||
|
"explains why this bounded authorization and recovery evidence is retained."
|
||||||
|
),
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("system_admin", "operator", "security_reviewer"),
|
||||||
|
related_modules=("access", "audit", "policy"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("tenancy", "access"),
|
||||||
|
any_scopes=("system:tenants:erase",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Tenant erasure policy API",
|
||||||
|
href="/api/v1/admin/tenant-erasure-policy",
|
||||||
|
kind="api",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Create erasure preview",
|
||||||
|
href="/api/v1/admin/tenants/{tenant_id}/erasure-operations",
|
||||||
|
kind="api",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Mandantenlöschung vorschauen, freigeben und abgleichen",
|
||||||
|
"summary": (
|
||||||
|
"Systembetriebspersonen löschen einen Mandanten nur mit aktuellen "
|
||||||
|
"Anbieternachweisen, Texteingabebestätigung, richtlinienbestimmten "
|
||||||
|
"Freigaben und fortsetzbaren Prüfpunkten."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Erstellen Sie einen Löschvorgang mit eindeutigem Idempotenzschlüssel und prüfen Sie jede Modulressource, Behandlung, "
|
||||||
|
"Warnung, Sperre, jeden irreversiblen Schritt sowie Aufgaben für externe Bereinigung, Schlüsselvernichtung und den Ablauf "
|
||||||
|
"von Sicherungen. Die Systemeinstellung tenant_erasure_policy bestimmt Produktionsmodus, eine bis zehn getrennte Freigaben, "
|
||||||
|
"Gültigkeit der Vorschau und Zeitfenster der aktuellen Authentifizierung. Im Produktionsmodus sind mindestens zwei Freigaben "
|
||||||
|
"erforderlich; der sichere Standard sind zwei Freigaben und jeweils fünfzehn Minuten. Jede freigebende Person gibt in einer "
|
||||||
|
"aktuell authentifizierten interaktiven Sitzung den exakten Mandanten-Slug ein. Die Ausführung benötigt das eigene Recht "
|
||||||
|
"system:tenants:erase, wiederholt die Bestätigung, prüft den Vorschau-Digest, sperrt den Mandanten und schreibt vor jeder "
|
||||||
|
"Anbieterwirkung einen Prüfpunkt. Zeitüberschreitungen sowie ausstehende oder unbekannte Ergebnisse stoppen im Zustand "
|
||||||
|
"reconciliation_required; der Abgleich nutzt denselben Anbieter-Idempotenzschlüssel. Nach Beginn destruktiver Arbeit ist ein "
|
||||||
|
"Abbruch ausgeschlossen. Core entfernt den Mandantenbereich erst, wenn eine neue Inventur und alle Löschvetos frei sind. "
|
||||||
|
"Danach bleiben begrenzte Vorgangs- und Auditnachweise ohne Texteingabebestätigung, Antragsgrund, Anmeldedaten oder gelöschte "
|
||||||
|
"Mandanteninhalte erhalten. Der Tenancy-DSAR-Anbieter exportiert Rolle und Zeitstempel der betroffenen antragstellenden oder "
|
||||||
|
"freigebenden Person und erläutert, warum dieser begrenzte Autorisierungs- und Wiederherstellungsnachweis aufbewahrt wird."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": ["tenancy.admin.tenant-erasure"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="tenancy.workflow.data-subject-request",
|
||||||
|
title="Review Tenancy evidence in a data-subject request",
|
||||||
|
summary=(
|
||||||
|
"Privacy officers can export a subject's bounded role in tenant-erasure "
|
||||||
|
"operations while preserving required governance evidence."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"The Tenancy DSAR provider matches the authenticated account identifier "
|
||||||
|
"and returns a bounded set of matching tenant-erasure operations, failing "
|
||||||
|
"closed when the tenant scan or subject result limit is exceeded. "
|
||||||
|
"It reports only the operation state, the subject's requester or approver "
|
||||||
|
"role and timestamps, and whether destructive work or completion occurred. "
|
||||||
|
"An unfinished request reason is visible only to its requester; typed "
|
||||||
|
"confirmation, credentials, provider payloads, erased tenant content, and a "
|
||||||
|
"completed request reason are never exported. This authorization and recovery "
|
||||||
|
"record is immutable governance evidence, so the erasure plan marks it for "
|
||||||
|
"retention and execution returns a blocking explanation instead of deleting it. "
|
||||||
|
"Use Audit and the owning domain providers to complete the wider request."
|
||||||
|
),
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("privacy_officer", "system_admin", "security_reviewer"),
|
||||||
|
related_modules=("access", "audit"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Tenancy-Nachweise in einer Betroffenenanfrage prüfen",
|
||||||
|
"summary": (
|
||||||
|
"Datenschutzverantwortliche können die begrenzte Rolle einer betroffenen "
|
||||||
|
"Person in Mandantenlöschvorgängen exportieren und erforderliche "
|
||||||
|
"Governance-Nachweise bewahren."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Der Tenancy-DSAR-Anbieter gleicht die authentifizierte Konto-ID ab und "
|
||||||
|
"liefert eine begrenzte Menge passender Mandantenlöschvorgänge; beim "
|
||||||
|
"Überschreiten der Mandanten- oder Betroffenenbegrenzung bricht er sicher ab. "
|
||||||
|
"Ausgegeben werden nur Vorgangszustand, Rolle und Zeitstempel der "
|
||||||
|
"betroffenen antragstellenden oder freigebenden Person sowie Angaben "
|
||||||
|
"dazu, ob destruktive Arbeit oder der Abschluss erfolgt ist. Ein noch "
|
||||||
|
"offener Antragsgrund ist ausschließlich für die antragstellende Person "
|
||||||
|
"sichtbar; Texteingabebestätigung, Anmeldedaten, Anbieterinhalte, gelöschte "
|
||||||
|
"Mandantendaten und der Grund eines abgeschlossenen Antrags werden nie "
|
||||||
|
"exportiert. Dieser Autorisierungs- und Wiederherstellungsnachweis ist ein "
|
||||||
|
"unveränderlicher Governance-Beleg. Der Löschplan kennzeichnet ihn daher "
|
||||||
|
"zur Aufbewahrung und die Ausführung liefert statt einer Löschung eine "
|
||||||
|
"blockierende Begründung. Audit und die zuständigen Fachmodule vervollständigen "
|
||||||
|
"die übergreifende Anfrage."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": ["tenancy.admin.data-subject-request"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="tenancy.reference.admin-fields",
|
||||||
|
title="Tenant administration fields and consequences",
|
||||||
|
summary="Tenant identity, ownership, locale, governance overrides, and lifecycle state have different mutation and recovery consequences.",
|
||||||
|
body="A tenant slug is immutable after creation and identifies the administrative boundary. The initial owner receives the protected tenant-owner role. German is the reference and new-tenant default; locale and enabled languages are bounded by system language packages and may be changed explicitly. Tenant navigation and appearance inherit their system layers until explicitly saved. Palette choices use validated Core presets only. A tenant appearance lock requires policy-write authority, suppresses personal palette choices, and cannot relax a system lock. Governance overrides may narrow a system allowance but cannot loosen a system denial. Suspension keeps tenant-owned data and audit evidence while preventing normal use; an operator must switch away from the active tenant before suspending it. Destructive erasure uses the exact immutable slug as its confirmation phrase and cannot reuse the suspension permission.",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("system_admin", "tenant_admin", "operator"),
|
||||||
|
related_modules=("access", "admin", "audit"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Tenant administration", href="/admin", kind="runtime"
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Tenant registry API",
|
||||||
|
href="/api/v1/admin/tenants",
|
||||||
|
kind="api",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Felder und Folgen der Mandantenadministration",
|
||||||
|
"summary": (
|
||||||
|
"Mandantenidentität, Eigentum, Sprache, Governance-Überschreibungen und Lebenszykluszustand haben unterschiedliche "
|
||||||
|
"Änderungs- und Wiederherstellungsfolgen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Der Slug eines Mandanten ist nach der Anlage unveränderlich und bezeichnet die Administrationsgrenze. Der erste Owner "
|
||||||
|
"erhält die geschützte Tenant-Owner-Rolle. Deutsch ist Referenz und Standard für neue Mandanten; Spracheinstellung und "
|
||||||
|
"aktivierte Sprachen werden durch Systemsprachpakete begrenzt und können ausdrücklich geändert werden. Navigation und "
|
||||||
|
"Erscheinungsbild erben ihre Systemebenen, bis sie gespeichert werden. Paletten verwenden nur validierte Core-Vorgaben. "
|
||||||
|
"Eine Mandantensperre des Erscheinungsbilds verlangt Richtlinienschreibberechtigung, unterdrückt persönliche Paletten und "
|
||||||
|
"kann keine Systemsperre lockern. Governance-Überschreibungen dürfen eine Systemerlaubnis einschränken, aber eine "
|
||||||
|
"Systemablehnung nicht lockern. Eine Suspendierung bewahrt mandanteneigene Daten und Auditnachweise, verhindert jedoch die "
|
||||||
|
"normale Nutzung; die Betriebsperson muss vor der Suspendierung aus dem aktiven Mandanten wechseln. Die destruktive Löschung "
|
||||||
|
"verwendet den exakten unveränderlichen Slug als Bestätigung und kann nicht mit dem Sperrrecht ausgeführt werden."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"tenancy.field.slug",
|
||||||
|
"tenancy.field.initial-owner",
|
||||||
|
"tenancy.field.locale",
|
||||||
|
"tenancy.field.languages",
|
||||||
|
"tenancy.admin.tenant-settings",
|
||||||
|
"tenancy.field.governance",
|
||||||
|
"tenancy.action.suspend",
|
||||||
|
"tenancy.admin.tenant-erasure",
|
||||||
|
],
|
||||||
|
"consequence_classes": {
|
||||||
|
"create": "Creates a new tenant boundary and provisions its protected initial owner.",
|
||||||
|
"update": "Changes tenant-local identity, locale, or governance configuration.",
|
||||||
|
"suspend": "Blocks normal tenant use while retaining data and audit evidence.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id="tenancy",
|
||||||
|
package_name="@govoplan/tenancy-webui",
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="tenancy.admin.system-tenants",
|
||||||
|
module_id="tenancy",
|
||||||
|
kind="section",
|
||||||
|
label="System tenants",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="tenancy.admin.tenant-settings",
|
||||||
|
module_id="tenancy",
|
||||||
|
kind="section",
|
||||||
|
label="Tenant settings",
|
||||||
|
order=90,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
architecture=declared_module_architecture(
|
||||||
|
layer="institutional_foundation",
|
||||||
|
kind="foundation",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
documentation_ref="docs/TENANCY_MODULE_BOUNDARY.md",
|
||||||
|
test_ref="tests/test_tenant_lifecycle.py",
|
||||||
|
known_limits=(
|
||||||
|
"Cross-region tenant relocation and complete major-version recovery evidence are not implemented.",
|
||||||
|
),
|
||||||
|
owned_concepts=("tenant lifecycle", "tenant context", "tenant settings"),
|
||||||
|
non_owned_concepts=(
|
||||||
|
"account authorization",
|
||||||
|
"organization hierarchy",
|
||||||
|
"module-owned tenant data",
|
||||||
|
),
|
||||||
|
recovery_docs=("docs/TENANCY_MODULE_BOUNDARY.md",),
|
||||||
|
security_docs=("docs/TENANCY_MODULE_BOUNDARY.md",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = with_documentation_structured_translations(
|
||||||
|
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tenancy module migrations."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tenancy module migration revisions."""
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
"""Add durable tenant-erasure operations.
|
||||||
|
|
||||||
|
Revision ID: b3d8e1f4a6c2
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-08-24
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "b3d8e1f4a6c2"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = ("module:tenancy",)
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"tenancy_erasure_operations",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("state", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("idempotency_key", sa.String(length=160), nullable=False),
|
||||||
|
sa.Column("request_digest", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("preview_digest", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("preview", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("previewed_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("preview_expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("policy", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("approvals", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("steps", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("requested_by_account_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("destructive_started", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
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_tenancy_erasure_operations")),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"idempotency_key",
|
||||||
|
name="uq_tenancy_erasure_tenant_idempotency",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_tenancy_erasure_operations_tenant_id"),
|
||||||
|
"tenancy_erasure_operations",
|
||||||
|
["tenant_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_tenancy_erasure_operations_state"),
|
||||||
|
"tenancy_erasure_operations",
|
||||||
|
["state"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_tenancy_erasure_operations_preview_expires_at"),
|
||||||
|
"tenancy_erasure_operations",
|
||||||
|
["preview_expires_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_tenancy_erasure_operations_completed_at"),
|
||||||
|
"tenancy_erasure_operations",
|
||||||
|
["completed_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_tenancy_erasure_tenant_state",
|
||||||
|
"tenancy_erasure_operations",
|
||||||
|
["tenant_id", "state"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"ix_tenancy_erasure_tenant_state",
|
||||||
|
table_name="tenancy_erasure_operations",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_tenancy_erasure_operations_completed_at"),
|
||||||
|
table_name="tenancy_erasure_operations",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_tenancy_erasure_operations_preview_expires_at"),
|
||||||
|
table_name="tenancy_erasure_operations",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_tenancy_erasure_operations_state"),
|
||||||
|
table_name="tenancy_erasure_operations",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_tenancy_erasure_operations_tenant_id"),
|
||||||
|
table_name="tenancy_erasure_operations",
|
||||||
|
)
|
||||||
|
op.drop_table("tenancy_erasure_operations")
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import DsarSubjectRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_tenancy.backend.db.models import TenantErasureOperation
|
||||||
|
from govoplan_tenancy.backend.dsar_provider import TenancyDsarProvider
|
||||||
|
|
||||||
|
|
||||||
|
def _operation(now: datetime) -> TenantErasureOperation:
|
||||||
|
return TenantErasureOperation(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
state="ready",
|
||||||
|
idempotency_key="request-1234",
|
||||||
|
request_digest="a" * 64,
|
||||||
|
preview_digest="b" * 64,
|
||||||
|
preview={"schema_version": 1},
|
||||||
|
previewed_at=now,
|
||||||
|
preview_expires_at=now + timedelta(minutes=15),
|
||||||
|
policy={"required_approvals": 2},
|
||||||
|
approvals=[
|
||||||
|
{"account_id": "account-1", "approved_at": now.isoformat()},
|
||||||
|
{"account_id": "account-2", "approved_at": now.isoformat()},
|
||||||
|
],
|
||||||
|
steps=[],
|
||||||
|
requested_by_account_id="account-1",
|
||||||
|
reason="Contract ended",
|
||||||
|
destructive_started=False,
|
||||||
|
revision=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsar_exports_only_subject_actor_evidence_and_retains_it() -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
now = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
|
||||||
|
with Session(engine) as session:
|
||||||
|
operation = _operation(now)
|
||||||
|
session.add(operation)
|
||||||
|
session.commit()
|
||||||
|
provider = TenancyDsarProvider()
|
||||||
|
|
||||||
|
requester_records = provider.search_subject(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id="account-1"),
|
||||||
|
)
|
||||||
|
approver_records = provider.search_subject(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id="account-2"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert requester_records[0].data["actor_roles"] == ["requester", "approver"]
|
||||||
|
assert requester_records[0].data["request_reason"] == "Contract ended"
|
||||||
|
assert approver_records[0].data["actor_roles"] == ["approver"]
|
||||||
|
assert "request_reason" not in approver_records[0].data
|
||||||
|
assert requester_records[0].immutable_evidence
|
||||||
|
|
||||||
|
actions = provider.plan_erasure(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id="account-1"),
|
||||||
|
records=requester_records,
|
||||||
|
)
|
||||||
|
assert actions[0].kind == "retain"
|
||||||
|
assert not actions[0].executable
|
||||||
|
results = provider.execute_erasure(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id="account-1"),
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
assert results[0].status == "blocked"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dsar_rejects_conflicting_account_selectors() -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as session:
|
||||||
|
assert (
|
||||||
|
TenancyDsarProvider().search_subject(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
account_id="account-1",
|
||||||
|
external_references={"access.account": "account-2"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
== ()
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_tenancy.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class TenancyInterfaceDocumentationContractTests(unittest.TestCase):
|
||||||
|
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
german = (topic.translations or {}).get("de", {})
|
||||||
|
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
|
||||||
|
self.assertTrue(
|
||||||
|
all(str(value).strip() for value in german.values()), topic.id
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_tenancy_admin_surfaces_remain_declared(self) -> None:
|
||||||
|
frontend = manifest.frontend
|
||||||
|
self.assertIsNotNone(frontend)
|
||||||
|
surfaces = {surface.id for surface in frontend.view_surfaces} # type: ignore[union-attr]
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"tenancy.admin.system-tenants",
|
||||||
|
"tenancy.admin.tenant-settings",
|
||||||
|
},
|
||||||
|
surfaces,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_tenancy_topics_publish_stable_help_contexts(self) -> None:
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
self.assertIn("tenancy.current-context", topics)
|
||||||
|
self.assertIn("tenancy.lifecycle-and-settings", topics)
|
||||||
|
self.assertIn("tenancy.reference.admin-fields", topics)
|
||||||
|
|
||||||
|
lifecycle_contexts = set(
|
||||||
|
topics["tenancy.lifecycle-and-settings"].metadata["help_contexts"]
|
||||||
|
)
|
||||||
|
self.assertIn("tenancy.admin.system-tenants", lifecycle_contexts)
|
||||||
|
self.assertIn("tenancy.admin.tenant-settings", lifecycle_contexts)
|
||||||
|
self.assertEqual(
|
||||||
|
"workflow", topics["tenancy.lifecycle-and-settings"].metadata["kind"]
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"tenancy.action.suspend",
|
||||||
|
topics["tenancy.reference.admin-fields"].metadata["help_contexts"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
|
||||||
|
from govoplan_core.db.migrations import migrate_database
|
||||||
|
from govoplan_tenancy.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenant_erasure_migration_is_declared() -> None:
|
||||||
|
migration = importlib.import_module(
|
||||||
|
"govoplan_tenancy.backend.migrations.versions."
|
||||||
|
"b3d8e1f4a6c2_v021_tenant_erasure_operations"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert migration.revision == "b3d8e1f4a6c2"
|
||||||
|
assert migration.down_revision is None
|
||||||
|
assert migration.branch_labels == ("module:tenancy",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenancy_migration_creates_erasure_operation_table_and_head() -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-tenancy-migration-") as directory:
|
||||||
|
url = f"sqlite:///{Path(directory) / 'tenancy.db'}"
|
||||||
|
migrate_database(
|
||||||
|
database_url=url,
|
||||||
|
enabled_modules=("tenancy",),
|
||||||
|
manifest_factories=(get_manifest,),
|
||||||
|
)
|
||||||
|
engine = create_engine(url)
|
||||||
|
try:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
assert "b3d8e1f4a6c2" in set(
|
||||||
|
MigrationContext.configure(connection).get_current_heads()
|
||||||
|
)
|
||||||
|
assert {
|
||||||
|
name
|
||||||
|
for name in inspect(connection).get_table_names()
|
||||||
|
if name.startswith("tenancy_")
|
||||||
|
} == {"tenancy_erasure_operations"}
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.tenant_erasure import (
|
||||||
|
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX,
|
||||||
|
TenantErasurePreview,
|
||||||
|
TenantErasureResource,
|
||||||
|
TenantErasureStep,
|
||||||
|
TenantErasureStepResult,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_tenancy.backend.db.models import TenantErasureOperation
|
||||||
|
from govoplan_tenancy.backend.erasure import (
|
||||||
|
TenantErasureConflict,
|
||||||
|
TenantErasurePolicy,
|
||||||
|
approve_tenant_erasure_operation,
|
||||||
|
assert_tenant_erasure_executable,
|
||||||
|
cancel_tenant_erasure_operation,
|
||||||
|
create_tenant_erasure_operation,
|
||||||
|
run_tenant_erasure_steps,
|
||||||
|
tenant_erasure_recently_authenticated,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Provider:
|
||||||
|
module_id = "files"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.fail = False
|
||||||
|
self.calls: list[str] = []
|
||||||
|
|
||||||
|
def preview_tenant_erasure(self, session, tenant_id: str) -> TenantErasurePreview:
|
||||||
|
del session, tenant_id
|
||||||
|
return TenantErasurePreview(
|
||||||
|
module_id="files",
|
||||||
|
complete=True,
|
||||||
|
resources=(
|
||||||
|
TenantErasureResource(
|
||||||
|
resource_type="files",
|
||||||
|
count=1,
|
||||||
|
disposition="erase",
|
||||||
|
summary="One file is in scope.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
TenantErasureStep(
|
||||||
|
step_id="erase-files",
|
||||||
|
kind="erase",
|
||||||
|
summary="Erase files.",
|
||||||
|
destructive=True,
|
||||||
|
irreversible=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute_tenant_erasure_step(
|
||||||
|
self, session, tenant_id: str, step_id: str, idempotency_key: str
|
||||||
|
) -> TenantErasureStepResult:
|
||||||
|
del session, tenant_id, idempotency_key
|
||||||
|
self.calls.append(f"execute:{step_id}")
|
||||||
|
if self.fail:
|
||||||
|
raise TimeoutError("provider timed out")
|
||||||
|
return TenantErasureStepResult(
|
||||||
|
state="completed",
|
||||||
|
summary="Files erased.",
|
||||||
|
metrics={"deleted": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
def reconcile_tenant_erasure_step(
|
||||||
|
self, session, tenant_id: str, step_id: str, idempotency_key: str
|
||||||
|
) -> TenantErasureStepResult:
|
||||||
|
del session, tenant_id, idempotency_key
|
||||||
|
self.calls.append(f"reconcile:{step_id}")
|
||||||
|
return TenantErasureStepResult(
|
||||||
|
state="completed",
|
||||||
|
summary="File erasure reconciled.",
|
||||||
|
metrics={"deleted": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider: _Provider):
|
||||||
|
self.provider = provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (SimpleNamespace(id="files"),)
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (f"{TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX}files",)
|
||||||
|
|
||||||
|
def capability(self, name: str):
|
||||||
|
assert name.endswith("files")
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def tenant_summary_providers(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session() -> Session:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine, expire_on_commit=False) as current:
|
||||||
|
yield current
|
||||||
|
|
||||||
|
|
||||||
|
def _operation(
|
||||||
|
session: Session,
|
||||||
|
provider: _Provider,
|
||||||
|
*,
|
||||||
|
current_time: datetime,
|
||||||
|
) -> TenantErasureOperation:
|
||||||
|
with patch(
|
||||||
|
"govoplan_tenancy.backend.erasure.tenant_erasure_policy",
|
||||||
|
return_value=TenantErasurePolicy(
|
||||||
|
production_profile=True,
|
||||||
|
required_approvals=2,
|
||||||
|
preview_ttl_seconds=900,
|
||||||
|
recent_authentication_seconds=900,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
operation, replayed = create_tenant_erasure_operation(
|
||||||
|
session,
|
||||||
|
registry=_Registry(provider),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
idempotency_key="request-1234",
|
||||||
|
requested_by_account_id="account-1",
|
||||||
|
reason="Contract ended",
|
||||||
|
current_time=current_time,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
assert not replayed
|
||||||
|
return operation
|
||||||
|
|
||||||
|
|
||||||
|
def test_operation_requires_distinct_multi_party_approval_and_typed_confirmation(
|
||||||
|
session: Session,
|
||||||
|
) -> None:
|
||||||
|
now = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
|
||||||
|
operation = _operation(session, _Provider(), current_time=now)
|
||||||
|
|
||||||
|
with pytest.raises(TenantErasureConflict, match="does not match"):
|
||||||
|
approve_tenant_erasure_operation(
|
||||||
|
operation,
|
||||||
|
account_id="account-1",
|
||||||
|
confirmation="wrong",
|
||||||
|
tenant_slug="target",
|
||||||
|
current_time=now,
|
||||||
|
)
|
||||||
|
assert not approve_tenant_erasure_operation(
|
||||||
|
operation,
|
||||||
|
account_id="account-1",
|
||||||
|
confirmation="target",
|
||||||
|
tenant_slug="target",
|
||||||
|
current_time=now,
|
||||||
|
)
|
||||||
|
assert operation.state == "awaiting_approval"
|
||||||
|
assert approve_tenant_erasure_operation(
|
||||||
|
operation,
|
||||||
|
account_id="account-1",
|
||||||
|
confirmation="target",
|
||||||
|
tenant_slug="target",
|
||||||
|
current_time=now,
|
||||||
|
)
|
||||||
|
assert not approve_tenant_erasure_operation(
|
||||||
|
operation,
|
||||||
|
account_id="account-2",
|
||||||
|
confirmation="target",
|
||||||
|
tenant_slug="target",
|
||||||
|
current_time=now,
|
||||||
|
)
|
||||||
|
assert operation.state == "ready"
|
||||||
|
assert_tenant_erasure_executable(
|
||||||
|
operation,
|
||||||
|
confirmation="target",
|
||||||
|
tenant_slug="target",
|
||||||
|
current_time=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_idempotency_replays_but_rejects_changed_request(
|
||||||
|
session: Session,
|
||||||
|
) -> None:
|
||||||
|
now = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
|
||||||
|
provider = _Provider()
|
||||||
|
operation = _operation(session, provider, current_time=now)
|
||||||
|
with patch(
|
||||||
|
"govoplan_tenancy.backend.erasure.tenant_erasure_policy",
|
||||||
|
return_value=TenantErasurePolicy(),
|
||||||
|
):
|
||||||
|
replay, replayed = create_tenant_erasure_operation(
|
||||||
|
session,
|
||||||
|
registry=_Registry(provider),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
idempotency_key="request-1234",
|
||||||
|
requested_by_account_id="account-1",
|
||||||
|
reason="Contract ended",
|
||||||
|
current_time=now,
|
||||||
|
)
|
||||||
|
assert replayed
|
||||||
|
assert replay.id == operation.id
|
||||||
|
with pytest.raises(TenantErasureConflict, match="another request"):
|
||||||
|
create_tenant_erasure_operation(
|
||||||
|
session,
|
||||||
|
registry=_Registry(provider),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
idempotency_key="request-1234",
|
||||||
|
requested_by_account_id="account-1",
|
||||||
|
reason="Changed",
|
||||||
|
current_time=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_timeout_requires_reconciliation_before_completion(
|
||||||
|
session: Session,
|
||||||
|
) -> None:
|
||||||
|
now = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
|
||||||
|
provider = _Provider()
|
||||||
|
operation = _operation(session, provider, current_time=now)
|
||||||
|
operation.state = "ready"
|
||||||
|
operation.approvals = [
|
||||||
|
{"account_id": "account-1"},
|
||||||
|
{"account_id": "account-2"},
|
||||||
|
]
|
||||||
|
session.commit()
|
||||||
|
provider.fail = True
|
||||||
|
|
||||||
|
assert not run_tenant_erasure_steps(
|
||||||
|
session,
|
||||||
|
registry=_Registry(provider),
|
||||||
|
operation=operation,
|
||||||
|
current_time=now,
|
||||||
|
)
|
||||||
|
assert operation.state == "reconciliation_required"
|
||||||
|
assert operation.steps[0]["state"] == "outcome_unknown"
|
||||||
|
assert operation.destructive_started
|
||||||
|
|
||||||
|
provider.fail = False
|
||||||
|
assert run_tenant_erasure_steps(
|
||||||
|
session,
|
||||||
|
registry=_Registry(provider),
|
||||||
|
operation=operation,
|
||||||
|
current_time=now,
|
||||||
|
)
|
||||||
|
assert provider.calls == ["execute:erase-files", "reconcile:erase-files"]
|
||||||
|
assert operation.steps[0]["state"] == "completed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancellation_stops_at_destructive_boundary(session: Session) -> None:
|
||||||
|
now = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
|
||||||
|
operation = _operation(session, _Provider(), current_time=now)
|
||||||
|
cancel_tenant_erasure_operation(operation)
|
||||||
|
assert operation.state == "cancelled"
|
||||||
|
|
||||||
|
operation.state = "running"
|
||||||
|
operation.destructive_started = True
|
||||||
|
with pytest.raises(TenantErasureConflict, match="destructive work"):
|
||||||
|
cancel_tenant_erasure_operation(operation)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_authentication_is_policy_bounded() -> None:
|
||||||
|
now = datetime(2026, 8, 24, 12, 0, tzinfo=UTC)
|
||||||
|
policy = TenantErasurePolicy(recent_authentication_seconds=300)
|
||||||
|
|
||||||
|
assert tenant_erasure_recently_authenticated(
|
||||||
|
SimpleNamespace(created_at=now - timedelta(minutes=4)),
|
||||||
|
policy,
|
||||||
|
current_time=now,
|
||||||
|
)
|
||||||
|
assert not tenant_erasure_recently_authenticated(
|
||||||
|
SimpleNamespace(created_at=now - timedelta(minutes=6)),
|
||||||
|
policy,
|
||||||
|
current_time=now,
|
||||||
|
)
|
||||||
|
assert not tenant_erasure_recently_authenticated(None, policy, current_time=now)
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_policy_cannot_disable_multi_party_approval() -> None:
|
||||||
|
with pytest.raises(ValueError, match="at least two approvals"):
|
||||||
|
TenantErasurePolicy(production_profile=True, required_approvals=1)
|
||||||
|
|
||||||
|
non_production = TenantErasurePolicy(
|
||||||
|
production_profile=False,
|
||||||
|
required_approvals=1,
|
||||||
|
)
|
||||||
|
assert non_production.required_approvals == 1
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from govoplan_tenancy.backend.api.v1.routes import (
|
||||||
|
_apply_tenant_content_updates,
|
||||||
|
_apply_tenant_status_update,
|
||||||
|
_require_tenant_update_permissions,
|
||||||
|
)
|
||||||
|
from govoplan_tenancy.backend.api.v1.schemas import (
|
||||||
|
TenantCreateRequest,
|
||||||
|
TenantSettingsItem,
|
||||||
|
TenantUpdateRequest,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.module_entitlements import MODULE_ENTITLEMENTS_KEY
|
||||||
|
from govoplan_core.core.appearance import APPEARANCE_SETTINGS_KEY
|
||||||
|
from govoplan_tenancy.backend.lifecycle import (
|
||||||
|
TENANT_EVENT_CREATED,
|
||||||
|
TENANT_EVENT_DELETION_REQUESTED,
|
||||||
|
TENANT_EVENT_ERASURE_COMPLETED,
|
||||||
|
TENANT_EVENT_RESUMED,
|
||||||
|
TENANT_EVENT_SUSPENDED,
|
||||||
|
TENANT_LIFECYCLE_EVENTS,
|
||||||
|
tenant_lifecycle_event,
|
||||||
|
tenant_lifecycle_event_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakePrincipal:
|
||||||
|
def __init__(self, scopes: set[str], *, tenant_id: str = "tenant-1") -> None:
|
||||||
|
self.scopes = frozenset(scopes)
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
|
||||||
|
def has(self, required_scope: str) -> bool:
|
||||||
|
return required_scope in self.scopes
|
||||||
|
|
||||||
|
|
||||||
|
class TenantLifecycleContractTests(unittest.TestCase):
|
||||||
|
def test_new_tenant_contracts_use_german_reference_default(self) -> None:
|
||||||
|
tenant = TenantCreateRequest(slug="example", name="Example")
|
||||||
|
|
||||||
|
self.assertEqual("de", tenant.default_locale)
|
||||||
|
self.assertEqual(
|
||||||
|
"de",
|
||||||
|
TenantSettingsItem(id="tenant-1", slug="example", name="Example").default_locale,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_lifecycle_event_names_are_stable(self) -> None:
|
||||||
|
self.assertEqual("tenant.created", tenant_lifecycle_event_type("created"))
|
||||||
|
self.assertEqual("tenant.suspended", tenant_lifecycle_event_type("suspended"))
|
||||||
|
self.assertEqual("tenant.resumed", tenant_lifecycle_event_type("resumed"))
|
||||||
|
self.assertEqual("tenant.deletion_requested", tenant_lifecycle_event_type("deletion_requested"))
|
||||||
|
self.assertEqual("tenant.erasure_completed", tenant_lifecycle_event_type("erasure_completed"))
|
||||||
|
self.assertEqual(
|
||||||
|
(
|
||||||
|
TENANT_EVENT_CREATED,
|
||||||
|
TENANT_EVENT_SUSPENDED,
|
||||||
|
TENANT_EVENT_RESUMED,
|
||||||
|
TENANT_EVENT_DELETION_REQUESTED,
|
||||||
|
TENANT_EVENT_ERASURE_COMPLETED,
|
||||||
|
),
|
||||||
|
TENANT_LIFECYCLE_EVENTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_lifecycle_event_builds_audit_details_without_losing_extra_fields(self) -> None:
|
||||||
|
occurred_at = datetime(2026, 7, 11, 12, 30, tzinfo=UTC)
|
||||||
|
|
||||||
|
event = tenant_lifecycle_event(
|
||||||
|
"deletion_requested",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
tenant_slug="city",
|
||||||
|
tenant_name="City Office",
|
||||||
|
actor_account_id="account-1",
|
||||||
|
requested_by_account_id="account-2",
|
||||||
|
reason="end of contract",
|
||||||
|
counts={"files": 2},
|
||||||
|
occurred_at=occurred_at,
|
||||||
|
details={"mode": "retire"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(TENANT_EVENT_DELETION_REQUESTED, event.event_type)
|
||||||
|
self.assertEqual("tenant-1", event.tenant_id)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"mode": "retire",
|
||||||
|
"slug": "city",
|
||||||
|
"name": "City Office",
|
||||||
|
"actor_account_id": "account-1",
|
||||||
|
"requested_by_account_id": "account-2",
|
||||||
|
"reason": "end of contract",
|
||||||
|
"counts": {"files": 2},
|
||||||
|
"occurred_at": "2026-07-11T12:30:00+00:00",
|
||||||
|
},
|
||||||
|
event.audit_details(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantUpdateHelperTests(unittest.TestCase):
|
||||||
|
def test_tenant_update_permissions_separate_content_and_status_changes(self) -> None:
|
||||||
|
_require_tenant_update_permissions(
|
||||||
|
FakePrincipal({"system:tenants:suspend"}), # type: ignore[arg-type]
|
||||||
|
TenantUpdateRequest(is_active=False),
|
||||||
|
)
|
||||||
|
_require_tenant_update_permissions(
|
||||||
|
FakePrincipal({"system:tenants:update"}), # type: ignore[arg-type]
|
||||||
|
TenantUpdateRequest(name="Updated"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as missing_update:
|
||||||
|
_require_tenant_update_permissions(FakePrincipal(set()), TenantUpdateRequest(name="Updated")) # type: ignore[arg-type]
|
||||||
|
self.assertEqual(403, missing_update.exception.status_code)
|
||||||
|
self.assertEqual("Missing scope: system:tenants:update", missing_update.exception.detail)
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as missing_suspend:
|
||||||
|
_require_tenant_update_permissions(FakePrincipal(set()), TenantUpdateRequest(is_active=False)) # type: ignore[arg-type]
|
||||||
|
self.assertEqual(403, missing_suspend.exception.status_code)
|
||||||
|
self.assertEqual("Missing scope: system:tenants:suspend", missing_suspend.exception.detail)
|
||||||
|
|
||||||
|
def test_tenant_content_updates_normalize_blank_fields_and_defaults(self) -> None:
|
||||||
|
tenant = SimpleNamespace(name="Old", description="Old description", default_locale="de", settings={})
|
||||||
|
payload = TenantUpdateRequest(
|
||||||
|
name=" New tenant ",
|
||||||
|
description=" ",
|
||||||
|
default_locale=" ",
|
||||||
|
settings={"theme": "contrast"},
|
||||||
|
)
|
||||||
|
|
||||||
|
_apply_tenant_content_updates(tenant, payload) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
self.assertEqual("New tenant", tenant.name)
|
||||||
|
self.assertIsNone(tenant.description)
|
||||||
|
self.assertEqual("de", tenant.default_locale)
|
||||||
|
self.assertEqual({"theme": "contrast"}, tenant.settings)
|
||||||
|
|
||||||
|
def test_tenant_content_update_preserves_reserved_governed_settings(self) -> None:
|
||||||
|
entitlement = {"schema_version": 1, "revision": 4}
|
||||||
|
appearance = {"default_palette": "forest", "palette_locked": True}
|
||||||
|
tenant = SimpleNamespace(
|
||||||
|
name="Old",
|
||||||
|
description=None,
|
||||||
|
default_locale="en",
|
||||||
|
settings={MODULE_ENTITLEMENTS_KEY: entitlement, APPEARANCE_SETTINGS_KEY: appearance, "theme": "old"},
|
||||||
|
)
|
||||||
|
payload = TenantUpdateRequest(
|
||||||
|
settings={
|
||||||
|
"theme": "contrast",
|
||||||
|
MODULE_ENTITLEMENTS_KEY: {"revision": 999},
|
||||||
|
APPEARANCE_SETTINGS_KEY: {"default_palette": "plum", "palette_locked": False},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_apply_tenant_content_updates(tenant, payload) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
self.assertEqual("contrast", tenant.settings["theme"])
|
||||||
|
self.assertEqual(entitlement, tenant.settings[MODULE_ENTITLEMENTS_KEY])
|
||||||
|
self.assertEqual(appearance, tenant.settings[APPEARANCE_SETTINGS_KEY])
|
||||||
|
|
||||||
|
def test_tenant_status_update_prevents_suspending_current_tenant(self) -> None:
|
||||||
|
tenant = SimpleNamespace(id="tenant-1", is_active=True)
|
||||||
|
principal = FakePrincipal({"system:tenants:suspend"}, tenant_id="tenant-1")
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as captured:
|
||||||
|
_apply_tenant_status_update(tenant, TenantUpdateRequest(is_active=False), principal) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
self.assertEqual(409, captured.exception.status_code)
|
||||||
|
self.assertEqual("Switch to another tenant before suspending the active tenant.", captured.exception.detail)
|
||||||
|
|
||||||
|
def test_tenant_status_update_allows_other_tenant_suspension(self) -> None:
|
||||||
|
tenant = SimpleNamespace(id="tenant-2", is_active=True)
|
||||||
|
principal = FakePrincipal({"system:tenants:suspend"}, tenant_id="tenant-1")
|
||||||
|
|
||||||
|
_apply_tenant_status_update(tenant, TenantUpdateRequest(is_active=False), principal) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
self.assertFalse(tenant.is_active)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/tenancy-webui",
|
||||||
|
"version": "0.1.21",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test:tenancy-admin": "node scripts/test-tenancy-admin-structure.mjs",
|
||||||
|
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"import": "./src/index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
function source(path) {
|
||||||
|
return readFileSync(new URL(path, import.meta.url), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenants = source("../src/features/admin/TenantsPanel.tsx");
|
||||||
|
const settings = source("../src/features/admin/TenantSettingsPanel.tsx");
|
||||||
|
const patterns = source("../src/features/admin/interfacePatterns.ts");
|
||||||
|
const moduleSource = source("../src/module.ts");
|
||||||
|
const translations = source("../src/i18n/generatedTranslations.ts");
|
||||||
|
|
||||||
|
assert(tenants.includes("DocumentationHelpLink") && settings.includes("DocumentationHelpLink"), "Both Tenancy admin surfaces expose contextual documentation");
|
||||||
|
assert(tenants.includes("ActionBlockerHint") && settings.includes("ActionBlockerHint"), "Read-only Tenancy states identify the actor, action, and destination");
|
||||||
|
assert(tenants.includes("disabledReason") && settings.includes("disabledReason"), "Disabled Tenancy actions explain their state");
|
||||||
|
assert(tenants.includes("requestDiscard(closeEditor)"), "The tenant editor uses the shared unsaved-change guard when closing");
|
||||||
|
assert(settings.includes("requestDiscard(() => void load())"), "Tenant settings protect dirty state when reloading");
|
||||||
|
assert(tenants.includes("ConfirmDialog") && tenants.includes("confirmSuspend"), "Tenant suspension remains explicitly confirmed");
|
||||||
|
assert(tenants.includes("minimumSlots={3}"), "Tenant row actions reserve stable keyboard and visual positions");
|
||||||
|
assert(!tenants.includes("applicable:"), "Row-specific unavailable actions stay visible with an explanation");
|
||||||
|
assert(patterns.includes('topicId: "tenancy.lifecycle-and-settings"') && patterns.includes('topicId: "tenancy.reference.admin-fields"'), "Tenancy uses stable manifest-backed help references");
|
||||||
|
assert(moduleSource.includes('version: "0.1.8"'), "The WebUI contribution reports the module release version");
|
||||||
|
assert(moduleSource.includes('label: "i18n:govoplan-tenancy.tenants.1f7ae776"') && moduleSource.includes('label: "i18n:govoplan-tenancy.tenant_general_settings.db1c3ba8"'), "View-surface labels are localized");
|
||||||
|
assert(translations.includes('"i18n:govoplan-tenancy.tenant_administration_is_read_only.7c9a1011"'), "Availability explanations are present in the translation catalog");
|
||||||
|
|
||||||
|
console.log("Tenancy surfaces satisfy the recorded interface pattern-language contract.");
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleSource = readFileSync(
|
||||||
|
new URL("../src/module.ts", import.meta.url),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const tenantsSource = readFileSync(
|
||||||
|
new URL("../src/features/admin/TenantsPanel.tsx", import.meta.url),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const settingsSource = readFileSync(
|
||||||
|
new URL("../src/features/admin/TenantSettingsPanel.tsx", import.meta.url),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
moduleSource.includes('"admin.sections": adminSections'),
|
||||||
|
"Tenancy contributes its panels through admin.sections"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
moduleSource.includes('id: "system-tenants"'),
|
||||||
|
"Tenancy contributes the system tenant registry section"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
moduleSource.includes('id: "tenant-settings"'),
|
||||||
|
"Tenancy contributes the active tenant settings section"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
moduleSource.includes('surfaceId: "tenancy.admin.system-tenants"') &&
|
||||||
|
moduleSource.includes('surfaceId: "tenancy.admin.tenant-settings"'),
|
||||||
|
"Tenancy owns the view-surface namespace for both admin sections"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!moduleSource.includes("@govoplan/access-webui"),
|
||||||
|
"Tenancy does not import the optional Access WebUI package"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
tenantsSource.includes("/api/tenancy"),
|
||||||
|
"The tenant registry panel consumes the tenancy-owned API client"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
settingsSource.includes("/api/tenancy"),
|
||||||
|
"The tenant settings panel consumes the tenancy-owned API client"
|
||||||
|
);
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import type {
|
||||||
|
ApiSettings,
|
||||||
|
DeltaDeletedItem,
|
||||||
|
NavigationPreferences,
|
||||||
|
PrivacyRetentionPolicy,
|
||||||
|
TenantAdminItem,
|
||||||
|
UserUiPalette
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
apiFetch,
|
||||||
|
apiGetList,
|
||||||
|
apiQuery
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export type TenantOwnerCandidate = {
|
||||||
|
account_id: string;
|
||||||
|
email: string;
|
||||||
|
display_name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LanguagePackage = {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
native_label?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SystemSettingsItem = {
|
||||||
|
default_locale: string;
|
||||||
|
allow_tenant_custom_groups: boolean;
|
||||||
|
allow_tenant_custom_roles: boolean;
|
||||||
|
allow_tenant_api_keys: boolean;
|
||||||
|
privacy_retention_policy: PrivacyRetentionPolicy;
|
||||||
|
available_languages?: LanguagePackage[];
|
||||||
|
enabled_language_codes?: string[];
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantSettingsItem = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
default_locale: string;
|
||||||
|
available_languages: LanguagePackage[];
|
||||||
|
system_enabled_language_codes: string[];
|
||||||
|
enabled_language_codes: string[];
|
||||||
|
navigation?: NavigationPreferences | null;
|
||||||
|
appearance_palette: UserUiPalette | null;
|
||||||
|
appearance_palette_locked: boolean;
|
||||||
|
system_appearance_palette: UserUiPalette;
|
||||||
|
system_appearance_palette_locked: boolean;
|
||||||
|
effective_appearance_palette: UserUiPalette;
|
||||||
|
effective_appearance_source: "tenant" | "system" | "tenant_lock" | "system_lock";
|
||||||
|
appearance_custom_overrides_allowed: boolean | null;
|
||||||
|
system_appearance_custom_overrides_allowed: boolean;
|
||||||
|
effective_appearance_custom_overrides_allowed: boolean;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantSettingsDeltaSections = Partial<{
|
||||||
|
identity: Pick<TenantSettingsItem, "id" | "slug" | "name">;
|
||||||
|
locale: Pick<TenantSettingsItem, "default_locale">;
|
||||||
|
languages: Pick<
|
||||||
|
TenantSettingsItem,
|
||||||
|
"available_languages" | "system_enabled_language_codes" | "enabled_language_codes"
|
||||||
|
>;
|
||||||
|
navigation: TenantSettingsItem["navigation"];
|
||||||
|
appearance: Pick<TenantSettingsItem, "appearance_palette" | "appearance_palette_locked" | "system_appearance_palette" | "system_appearance_palette_locked" | "effective_appearance_palette" | "effective_appearance_source" | "appearance_custom_overrides_allowed" | "system_appearance_custom_overrides_allowed" | "effective_appearance_custom_overrides_allowed">;
|
||||||
|
settings: TenantSettingsItem["settings"];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type DeltaResponseFields = {
|
||||||
|
deleted: DeltaDeletedItem[];
|
||||||
|
watermark?: string | null;
|
||||||
|
has_more: boolean;
|
||||||
|
full: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TenantListDeltaResponse = {
|
||||||
|
tenants: TenantAdminItem[];
|
||||||
|
} & DeltaResponseFields;
|
||||||
|
|
||||||
|
export type TenantSettingsDeltaResponse = {
|
||||||
|
item?: TenantSettingsItem | null;
|
||||||
|
sections: TenantSettingsDeltaSections;
|
||||||
|
changed_sections: string[];
|
||||||
|
} & DeltaResponseFields;
|
||||||
|
|
||||||
|
export function fetchTenantsDelta(
|
||||||
|
settings: ApiSettings,
|
||||||
|
options: { since?: string | null; limit?: number } = {}
|
||||||
|
): Promise<TenantListDeltaResponse> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/admin/tenants/delta${apiQuery(options)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTenantOwnerCandidates(
|
||||||
|
settings: ApiSettings
|
||||||
|
): Promise<TenantOwnerCandidate[]> {
|
||||||
|
return apiGetList<TenantOwnerCandidate, "accounts">(
|
||||||
|
settings,
|
||||||
|
"/api/v1/admin/tenants/owner-candidates",
|
||||||
|
"accounts"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTenant(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
owner_account_id?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
default_locale?: string;
|
||||||
|
settings?: Record<string, unknown>;
|
||||||
|
allow_custom_groups?: boolean | null;
|
||||||
|
allow_custom_roles?: boolean | null;
|
||||||
|
allow_api_keys?: boolean | null;
|
||||||
|
}
|
||||||
|
): Promise<TenantAdminItem> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/tenants", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTenant(
|
||||||
|
settings: ApiSettings,
|
||||||
|
tenantId: string,
|
||||||
|
payload: Partial<{
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
default_locale: string;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
allow_custom_groups: boolean | null;
|
||||||
|
allow_custom_roles: boolean | null;
|
||||||
|
allow_api_keys: boolean | null;
|
||||||
|
is_active: boolean;
|
||||||
|
}>
|
||||||
|
): Promise<TenantAdminItem> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/admin/tenants/${encodeURIComponent(tenantId)}`,
|
||||||
|
{ method: "PATCH", body: JSON.stringify(payload) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchTenantSettingsDelta(
|
||||||
|
settings: ApiSettings,
|
||||||
|
options: { since?: string | null; limit?: number } = {}
|
||||||
|
): Promise<TenantSettingsDeltaResponse> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/admin/tenant/settings/delta${apiQuery(options)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTenantSettings(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: {
|
||||||
|
default_locale: string;
|
||||||
|
enabled_language_codes?: string[] | null;
|
||||||
|
navigation?: NavigationPreferences | null;
|
||||||
|
appearance_palette?: UserUiPalette | null;
|
||||||
|
appearance_palette_locked?: boolean;
|
||||||
|
appearance_custom_overrides_allowed?: boolean | null;
|
||||||
|
}
|
||||||
|
): Promise<TenantSettingsItem> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/tenant/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSystemSettings(
|
||||||
|
settings: ApiSettings
|
||||||
|
): Promise<SystemSettingsItem> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/system/settings");
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import {
|
||||||
|
DescriptionList,
|
||||||
|
AppearancePalettePreview,
|
||||||
|
AppearancePaletteSelect,
|
||||||
|
NavigationPreferenceEditor,
|
||||||
|
configurableNavigationItemsForModules,
|
||||||
|
dispatchPlatformModulesChanged,
|
||||||
|
usePlatformModules
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActionBlockerHint,
|
||||||
|
AdminPageLayout,
|
||||||
|
AdminSelectionList,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
FormField,
|
||||||
|
ToggleSwitch,
|
||||||
|
adminErrorMessage,
|
||||||
|
useDeltaWatermarks,
|
||||||
|
useUnsavedChanges,
|
||||||
|
useUnsavedDraftGuard,
|
||||||
|
type ApiSettings
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import { fetchTenantSettingsDelta, updateTenantSettings, type TenantSettingsDeltaSections, type TenantSettingsItem } from "../../api/tenancy";
|
||||||
|
import {
|
||||||
|
TENANCY_ADMIN_DOCUMENTATION,
|
||||||
|
TENANCY_FIELD_DOCUMENTATION,
|
||||||
|
TENANCY_INTERFACE_I18N,
|
||||||
|
tenantMutationDisabledReason
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
|
const DELTA_KEY = "tenancy:tenant-settings";
|
||||||
|
|
||||||
|
const fallback: TenantSettingsItem = {
|
||||||
|
id: "",
|
||||||
|
slug: "",
|
||||||
|
name: "",
|
||||||
|
default_locale: "de",
|
||||||
|
available_languages: [
|
||||||
|
{ code: "de", label: "German", native_label: "Deutsch" },
|
||||||
|
{ code: "en", label: "English", native_label: "English" }
|
||||||
|
],
|
||||||
|
system_enabled_language_codes: ["de", "en"],
|
||||||
|
enabled_language_codes: ["de", "en"],
|
||||||
|
navigation: null,
|
||||||
|
settings: {},
|
||||||
|
appearance_palette: null,
|
||||||
|
appearance_palette_locked: false,
|
||||||
|
system_appearance_palette: "default",
|
||||||
|
system_appearance_palette_locked: false,
|
||||||
|
effective_appearance_palette: "default",
|
||||||
|
effective_appearance_source: "system",
|
||||||
|
appearance_custom_overrides_allowed: null,
|
||||||
|
system_appearance_custom_overrides_allowed: false,
|
||||||
|
effective_appearance_custom_overrides_allowed: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TenantSettingsPanel({
|
||||||
|
settings,
|
||||||
|
canWrite,
|
||||||
|
canWritePolicy,
|
||||||
|
onAuthRefresh
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}: {settings: ApiSettings;canWrite: boolean;canWritePolicy: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||||
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
|
const { modules } = usePlatformModules();
|
||||||
|
const navigationItems = configurableNavigationItemsForModules(modules);
|
||||||
|
const [draft, setDraft] = useState<TenantSettingsItem>(fallback);
|
||||||
|
const [savedDraft, setSavedDraft] = useState<TenantSettingsItem>(fallback);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||||
|
const defaultLocaleOptions = localeOptions(draft.default_locale, draft.enabled_language_codes);
|
||||||
|
const dirty = tenantSettingsDraftKey(draft) !== tenantSettingsDraftKey(savedDraft);
|
||||||
|
const customOverridesEffectivelyAllowed =
|
||||||
|
draft.system_appearance_custom_overrides_allowed
|
||||||
|
&& draft.appearance_custom_overrides_allowed !== false
|
||||||
|
&& !draft.system_appearance_palette_locked
|
||||||
|
&& !draft.appearance_palette_locked;
|
||||||
|
const saveDisabledReason = tenantMutationDisabledReason({
|
||||||
|
busy,
|
||||||
|
permitted: canWrite,
|
||||||
|
complete: Boolean(draft.default_locale.trim() && draft.enabled_language_codes.length),
|
||||||
|
changed: dirty,
|
||||||
|
permissionReason: TENANCY_INTERFACE_I18N.settingsWriteRequired
|
||||||
|
});
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty,
|
||||||
|
onSave: save,
|
||||||
|
onDiscard: () => setDraft(savedDraft)
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const wasDirty = tenantSettingsDraftKey(draft) !== tenantSettingsDraftKey(savedDraft);
|
||||||
|
const loaded = await fetchTenantSettingsDelta(settings, { since: getDeltaWatermark(DELTA_KEY) });
|
||||||
|
setDeltaWatermark(DELTA_KEY, loaded.watermark);
|
||||||
|
if (loaded.full && loaded.item) {
|
||||||
|
setSavedDraft(loaded.item);
|
||||||
|
if (!wasDirty) setDraft(loaded.item);
|
||||||
|
} else if (loaded.changed_sections.length) {
|
||||||
|
setSavedDraft((current) => applyTenantSettingsSections(current, loaded.sections));
|
||||||
|
if (!wasDirty) setDraft((current) => applyTenantSettingsSections(current, loaded.sections));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetDeltaWatermark(DELTA_KEY);
|
||||||
|
void load();
|
||||||
|
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
|
||||||
|
|
||||||
|
async function save(): Promise<boolean> {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const saved = await updateTenantSettings(settings, {
|
||||||
|
default_locale: draft.default_locale,
|
||||||
|
enabled_language_codes: draft.enabled_language_codes,
|
||||||
|
navigation: draft.navigation,
|
||||||
|
appearance_palette: draft.appearance_palette,
|
||||||
|
appearance_palette_locked: draft.appearance_palette_locked,
|
||||||
|
appearance_custom_overrides_allowed: draft.appearance_custom_overrides_allowed
|
||||||
|
});
|
||||||
|
setDraft(saved);
|
||||||
|
setSavedDraft(saved);
|
||||||
|
resetDeltaWatermark(DELTA_KEY);
|
||||||
|
dispatchPlatformModulesChanged();
|
||||||
|
setSuccess("i18n:govoplan-tenancy.tenant_general_settings_saved.485e7681");
|
||||||
|
await onAuthRefresh();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEnabledLanguages(selected: string[]) {
|
||||||
|
const enabled = new Set(selected);
|
||||||
|
const nextEnabled = draft.system_enabled_language_codes.filter((item) => enabled.has(item));
|
||||||
|
const defaultLocale = nextEnabled.includes(draft.default_locale) ? draft.default_locale : (nextEnabled[0] ?? draft.default_locale);
|
||||||
|
setDraft({ ...draft, enabled_language_codes: nextEnabled, default_locale: defaultLocale });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminPageLayout
|
||||||
|
title="i18n:govoplan-tenancy.tenant_general_settings.db1c3ba8"
|
||||||
|
description="i18n:govoplan-tenancy.settings_for_the_active_tenant_context.ad267b86"
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
success={success}
|
||||||
|
actions={<><DocumentationHelpLink reference={TENANCY_ADMIN_DOCUMENTATION} /><Button onClick={() => requestDiscard(() => void load())} disabled={loading || busy} disabledReason={loading ? TENANCY_INTERFACE_I18N.loading : busy ? TENANCY_INTERFACE_I18N.busy : undefined}>i18n:govoplan-tenancy.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={Boolean(saveDisabledReason)} disabledReason={saveDisabledReason}>{busy ? "i18n:govoplan-tenancy.saving.ae7e8875" : "i18n:govoplan-tenancy.save_general_settings.5c90f8c4"}</Button></>}>
|
||||||
|
|
||||||
|
{!canWrite && <ActionBlockerHint
|
||||||
|
reason={{
|
||||||
|
summary: TENANCY_INTERFACE_I18N.readOnlySummary,
|
||||||
|
requiredAction: TENANCY_INTERFACE_I18N.settingsPermissionGuidance,
|
||||||
|
actor: TENANCY_INTERFACE_I18N.administratorActor,
|
||||||
|
target: TENANCY_INTERFACE_I18N.tenantSettingsTarget
|
||||||
|
}}
|
||||||
|
labels={{
|
||||||
|
requiredAction: TENANCY_INTERFACE_I18N.requiredActionLabel,
|
||||||
|
actor: TENANCY_INTERFACE_I18N.actorLabel,
|
||||||
|
target: TENANCY_INTERFACE_I18N.targetLabel
|
||||||
|
}}
|
||||||
|
documentation={TENANCY_ADMIN_DOCUMENTATION}
|
||||||
|
/>}
|
||||||
|
|
||||||
|
<div className="admin-settings-form">
|
||||||
|
<Card title="i18n:govoplan-tenancy.locale.8970f0e6">
|
||||||
|
<FormField label="i18n:govoplan-tenancy.tenant_locale.8fc19914" help={!canWrite ? TENANCY_INTERFACE_I18N.settingsWriteRequired : "i18n:govoplan-tenancy.used_as_this_tenant_s_locale_default_for_tenant_.cf298b8b"} documentation={TENANCY_FIELD_DOCUMENTATION}>
|
||||||
|
<select value={draft.default_locale} disabled={!canWrite || busy || defaultLocaleOptions.length === 0} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })}>
|
||||||
|
{defaultLocaleOptions.map((code) => {
|
||||||
|
const language = draft.available_languages.find((item) => item.code === code);
|
||||||
|
return <option key={code} value={code}>{languageOptionLabel(language ?? { code, label: code.toUpperCase() })}</option>;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<AdminSelectionList
|
||||||
|
options={draft.system_enabled_language_codes.map((code) => {
|
||||||
|
const language = draft.available_languages.find((item) => item.code === code);
|
||||||
|
return { id: code, label: code.toUpperCase(), description: languageOptionLabel(language ?? { code, label: code.toUpperCase() }), disabled: !canWrite || busy || code === draft.default_locale };
|
||||||
|
})}
|
||||||
|
selected={draft.enabled_language_codes}
|
||||||
|
onChange={setEnabledLanguages}
|
||||||
|
/>
|
||||||
|
<p className="muted small-note"><span>i18n:govoplan-tenancy.tenant_languages_help</span>{" "}<span>{TENANCY_INTERFACE_I18N.defaultLanguageRequired}</span></p>
|
||||||
|
<DescriptionList variant="inline">
|
||||||
|
<div><dt>i18n:govoplan-tenancy.tenant.3ca93c78</dt><dd>{draft.name || "-"}</dd></div>
|
||||||
|
<div><dt>i18n:govoplan-tenancy.slug.094da9b9</dt><dd>{draft.slug || "-"}</dd></div>
|
||||||
|
<div><dt>i18n:govoplan-tenancy.available.7c62a142</dt><dd>{draft.available_languages.map((item) => item.code.toUpperCase()).join(", ") || "-"}</dd></div>
|
||||||
|
</DescriptionList>
|
||||||
|
</Card>
|
||||||
|
<Card title="Tenant navigation order">
|
||||||
|
<NavigationPreferenceEditor
|
||||||
|
items={navigationItems}
|
||||||
|
value={draft.navigation}
|
||||||
|
scope="tenant"
|
||||||
|
disabled={!canWrite || busy}
|
||||||
|
onChange={(navigation) => setDraft({ ...draft, navigation })}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card title="i18n:govoplan-tenancy.appearance_defaults">
|
||||||
|
<FormField label="i18n:govoplan-tenancy.tenant_palette_default" help="i18n:govoplan-tenancy.tenant_palette_default_help">
|
||||||
|
<AppearancePaletteSelect
|
||||||
|
value={draft.appearance_palette}
|
||||||
|
onChange={(appearance_palette) => setDraft({
|
||||||
|
...draft,
|
||||||
|
appearance_palette,
|
||||||
|
effective_appearance_palette: appearance_palette ?? draft.system_appearance_palette,
|
||||||
|
effective_appearance_source: appearance_palette ? "tenant" : "system"
|
||||||
|
})}
|
||||||
|
allowInherit
|
||||||
|
disabled={!canWrite || busy || draft.system_appearance_palette_locked || (draft.appearance_palette_locked && !canWritePolicy)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={draft.appearance_palette_locked}
|
||||||
|
onChange={(appearance_palette_locked) => setDraft({ ...draft, appearance_palette_locked })}
|
||||||
|
disabled={!canWrite || !canWritePolicy || busy || draft.system_appearance_palette_locked}
|
||||||
|
help={!canWritePolicy ? "i18n:govoplan-tenancy.appearance_lock_policy_permission" : draft.system_appearance_palette_locked ? "i18n:govoplan-tenancy.system_palette_is_locked" : undefined}
|
||||||
|
label="i18n:govoplan-tenancy.lock_tenant_palette"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="i18n:govoplan-tenancy.custom_overrides_policy"
|
||||||
|
help={!canWritePolicy ? "i18n:govoplan-tenancy.appearance_policy_permission" : "i18n:govoplan-tenancy.custom_overrides_policy_help"}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
value={draft.appearance_custom_overrides_allowed === null ? "inherit" : draft.appearance_custom_overrides_allowed ? "allow" : "block"}
|
||||||
|
disabled={!canWrite || !canWritePolicy || busy}
|
||||||
|
onChange={(event) => {
|
||||||
|
const appearance_custom_overrides_allowed = event.target.value === "inherit" ? null : event.target.value === "allow";
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
appearance_custom_overrides_allowed,
|
||||||
|
effective_appearance_custom_overrides_allowed:
|
||||||
|
draft.system_appearance_custom_overrides_allowed
|
||||||
|
&& appearance_custom_overrides_allowed !== false
|
||||||
|
&& !draft.system_appearance_palette_locked
|
||||||
|
&& !draft.appearance_palette_locked
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="inherit">i18n:govoplan-tenancy.inherit_system_policy</option>
|
||||||
|
<option value="allow" disabled={!draft.system_appearance_custom_overrides_allowed}>i18n:govoplan-tenancy.allow_for_tenant_users</option>
|
||||||
|
<option value="block">i18n:govoplan-tenancy.block_for_tenant_users</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<AppearancePalettePreview palette={draft.system_appearance_palette_locked ? draft.system_appearance_palette : draft.appearance_palette ?? draft.system_appearance_palette} />
|
||||||
|
<DescriptionList variant="inline">
|
||||||
|
<div><dt>i18n:govoplan-tenancy.effective_source</dt><dd>{draft.system_appearance_palette_locked ? "i18n:govoplan-tenancy.system_lock" : draft.appearance_palette ? "i18n:govoplan-tenancy.tenant_default" : "i18n:govoplan-tenancy.system_default"}</dd></div>
|
||||||
|
<div><dt>i18n:govoplan-tenancy.user_override</dt><dd>{draft.system_appearance_palette_locked || draft.appearance_palette_locked ? "i18n:govoplan-tenancy.blocked_by_policy" : "i18n:govoplan-tenancy.allowed"}</dd></div>
|
||||||
|
<div><dt>i18n:govoplan-tenancy.advanced_color_overrides</dt><dd>{customOverridesEffectivelyAllowed ? "i18n:govoplan-tenancy.allowed" : "i18n:govoplan-tenancy.blocked_by_policy"}</dd></div>
|
||||||
|
</DescriptionList>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AdminPageLayout>);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function languageOptionLabel(language: {code: string;label: string;native_label?: string | null}): string {
|
||||||
|
return `${language.code.toUpperCase()} - ${language.native_label || language.label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localeOptions(current: string, enabled: string[]): string[] {
|
||||||
|
return [...new Set([current, ...enabled].filter((item) => item && item.trim()))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tenantSettingsDraftKey(item: TenantSettingsItem): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
default_locale: item.default_locale,
|
||||||
|
enabled_language_codes: item.enabled_language_codes,
|
||||||
|
navigation: item.navigation,
|
||||||
|
appearance_palette: item.appearance_palette,
|
||||||
|
appearance_palette_locked: item.appearance_palette_locked,
|
||||||
|
appearance_custom_overrides_allowed: item.appearance_custom_overrides_allowed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTenantSettingsSections(item: TenantSettingsItem, sections: TenantSettingsDeltaSections): TenantSettingsItem {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
...(sections.identity ?? {}),
|
||||||
|
...(sections.locale ?? {}),
|
||||||
|
...(sections.languages ?? {}),
|
||||||
|
...(sections.navigation !== undefined ? { navigation: sections.navigation } : {}),
|
||||||
|
...(sections.appearance ?? {}),
|
||||||
|
...(sections.settings ? { settings: sections.settings } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||||
|
import type { FormGrid, ApiSettings, AuthInfo, TenantAdminItem } from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
ActionBlockerHint,
|
||||||
|
AdminIconButton,
|
||||||
|
AdminPageLayout,
|
||||||
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
|
DataGrid,
|
||||||
|
Dialog,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
FormField,
|
||||||
|
StatusBadge,
|
||||||
|
TableActionGroup,
|
||||||
|
adminErrorMessage,
|
||||||
|
formatAdminDateTime as formatDateTime,
|
||||||
|
i18nMessage,
|
||||||
|
useDeltaWatermarks,
|
||||||
|
usePlatformLanguage,
|
||||||
|
useUnsavedChanges,
|
||||||
|
useUnsavedDraftGuard,
|
||||||
|
type DataGridColumn
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import { createTenant, fetchSystemSettings, fetchTenantOwnerCandidates, fetchTenantsDelta, updateTenant, type SystemSettingsItem, type TenantOwnerCandidate } from "../../api/tenancy";
|
||||||
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
import {
|
||||||
|
TENANCY_ADMIN_DOCUMENTATION,
|
||||||
|
TENANCY_FIELD_DOCUMENTATION,
|
||||||
|
TENANCY_INTERFACE_I18N,
|
||||||
|
tenantMutationDisabledReason
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
|
type OverrideValue = "inherit" | "allow" | "deny";
|
||||||
|
type TenantDraft = {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
ownerAccountId: string;
|
||||||
|
description: string;
|
||||||
|
defaultLocale: string;
|
||||||
|
isActive: boolean;
|
||||||
|
customGroups: OverrideValue;
|
||||||
|
customRoles: OverrideValue;
|
||||||
|
apiKeys: OverrideValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyDraft: TenantDraft = {
|
||||||
|
slug: "",
|
||||||
|
name: "",
|
||||||
|
ownerAccountId: "",
|
||||||
|
description: "",
|
||||||
|
defaultLocale: "de",
|
||||||
|
isActive: true,
|
||||||
|
customGroups: "inherit",
|
||||||
|
customRoles: "inherit",
|
||||||
|
apiKeys: "inherit"
|
||||||
|
};
|
||||||
|
|
||||||
|
function fromOverride(value?: boolean | null): OverrideValue {
|
||||||
|
if (value === true) return "allow";
|
||||||
|
if (value === false) return "deny";
|
||||||
|
return "inherit";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOverride(value: OverrideValue): boolean | null {
|
||||||
|
if (value === "allow") return true;
|
||||||
|
if (value === "deny") return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TenantsPanel({
|
||||||
|
settings,
|
||||||
|
auth,
|
||||||
|
canCreate,
|
||||||
|
canUpdate,
|
||||||
|
canSuspend,
|
||||||
|
onAuthRefresh
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const { requestDiscard } = useUnsavedChanges();
|
||||||
|
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||||
|
const [systemSettings, setSystemSettings] = useState<SystemSettingsItem | null>(null);
|
||||||
|
const [ownerCandidates, setOwnerCandidates] = useState<TenantOwnerCandidate[]>([]);
|
||||||
|
const tenantsRef = useRef<TenantAdminItem[]>([]);
|
||||||
|
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||||
|
const [editing, setEditing] = useState<TenantAdminItem | "new" | null>(null);
|
||||||
|
const [viewing, setViewing] = useState<TenantAdminItem | null>(null);
|
||||||
|
const [draft, setDraft] = useState<TenantDraft>(emptyDraft);
|
||||||
|
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||||
|
const [confirmSuspend, setConfirmSuspend] = useState<TenantAdminItem | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
|
||||||
|
|
||||||
|
useUnsavedDraftGuard({
|
||||||
|
dirty,
|
||||||
|
onSave: save,
|
||||||
|
onDiscard: closeEditor
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [nextTenants, nextOwnerCandidates, nextSystemSettings] = await Promise.all([
|
||||||
|
loadDeltaRows(tenantsRef.current, "tenancy:tenants", getDeltaWatermark, setDeltaWatermark, (since) => fetchTenantsDelta(settings, { since }), (response) => response.tenants, (tenant) => tenant.id, "tenant", sortTenants),
|
||||||
|
canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([]),
|
||||||
|
fetchSystemSettings(settings).catch(() => null)]
|
||||||
|
);
|
||||||
|
tenantsRef.current = nextTenants;
|
||||||
|
setTenants(nextTenants);
|
||||||
|
setOwnerCandidates(nextOwnerCandidates);
|
||||||
|
setSystemSettings(nextSystemSettings);
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tenantsRef.current = [];
|
||||||
|
resetDeltaWatermark();
|
||||||
|
void load();
|
||||||
|
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
const nextDraft = { ...emptyDraft, ownerAccountId: auth.user.account_id };
|
||||||
|
setDraft(nextDraft);
|
||||||
|
setSavedDraftKey(draftKey(nextDraft));
|
||||||
|
setEditing("new");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(tenant: TenantAdminItem) {
|
||||||
|
const nextDraft = {
|
||||||
|
slug: tenant.slug,
|
||||||
|
name: tenant.name,
|
||||||
|
ownerAccountId: "",
|
||||||
|
description: tenant.description || "",
|
||||||
|
defaultLocale: tenant.default_locale || "de",
|
||||||
|
isActive: tenant.is_active,
|
||||||
|
customGroups: fromOverride(tenant.allow_custom_groups),
|
||||||
|
customRoles: fromOverride(tenant.allow_custom_roles),
|
||||||
|
apiKeys: fromOverride(tenant.allow_api_keys)
|
||||||
|
};
|
||||||
|
setDraft(nextDraft);
|
||||||
|
setSavedDraftKey(draftKey(nextDraft));
|
||||||
|
setEditing(tenant);
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
setEditing(null);
|
||||||
|
setDraft(emptyDraft);
|
||||||
|
setSavedDraftKey(draftKey(emptyDraft));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestCloseEditor() {
|
||||||
|
if (busy) return;
|
||||||
|
requestDiscard(closeEditor);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(): Promise<boolean> {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const governance = {
|
||||||
|
allow_custom_groups: toOverride(draft.customGroups),
|
||||||
|
allow_custom_roles: toOverride(draft.customRoles),
|
||||||
|
allow_api_keys: toOverride(draft.apiKeys)
|
||||||
|
};
|
||||||
|
if (editing === "new") {
|
||||||
|
const created = await createTenant(settings, {
|
||||||
|
slug: draft.slug,
|
||||||
|
name: draft.name,
|
||||||
|
owner_account_id: draft.ownerAccountId || null,
|
||||||
|
description: draft.description || null,
|
||||||
|
default_locale: draft.defaultLocale,
|
||||||
|
settings: {},
|
||||||
|
...governance
|
||||||
|
});
|
||||||
|
const selectedOwner = ownerCandidates.find((candidate) => candidate.account_id === draft.ownerAccountId);
|
||||||
|
setSuccess(i18nMessage("i18n:govoplan-tenancy.tenant_value_created_with_value_as_owner.1c18b6fb", { value0: created.name, value1: selectedOwner?.display_name || selectedOwner?.email || translateText("i18n:govoplan-tenancy.the_selected_account.1211bfb9") }));
|
||||||
|
await onAuthRefresh();
|
||||||
|
} else if (editing) {
|
||||||
|
const payload: Parameters<typeof updateTenant>[2] = {};
|
||||||
|
if (canUpdate) {
|
||||||
|
payload.name = draft.name;
|
||||||
|
payload.description = draft.description || null;
|
||||||
|
payload.default_locale = draft.defaultLocale;
|
||||||
|
payload.allow_custom_groups = governance.allow_custom_groups;
|
||||||
|
payload.allow_custom_roles = governance.allow_custom_roles;
|
||||||
|
payload.allow_api_keys = governance.allow_api_keys;
|
||||||
|
}
|
||||||
|
if (canSuspend) payload.is_active = draft.isActive;
|
||||||
|
await updateTenant(settings, editing.id, payload);
|
||||||
|
setSuccess(i18nMessage("i18n:govoplan-tenancy.tenant_value_updated.25b2c855", { value0: draft.name }));
|
||||||
|
await onAuthRefresh();
|
||||||
|
}
|
||||||
|
setEditing(null);
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function suspend() {
|
||||||
|
if (!confirmSuspend) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await updateTenant(settings, confirmSuspend.id, { is_active: false });
|
||||||
|
setSuccess(i18nMessage("i18n:govoplan-tenancy.value_suspended.31731a28", { value0: confirmSuspend.name }));
|
||||||
|
setConfirmSuspend(null);
|
||||||
|
await onAuthRefresh();
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTenantId = (auth.active_tenant ?? auth.tenant).id;
|
||||||
|
const systemAllowsCustomGroups = systemSettings?.allow_tenant_custom_groups !== false;
|
||||||
|
const systemAllowsCustomRoles = systemSettings?.allow_tenant_custom_roles !== false;
|
||||||
|
const systemAllowsApiKeys = systemSettings?.allow_tenant_api_keys !== false;
|
||||||
|
const systemDeniedGovernance = [
|
||||||
|
systemAllowsCustomGroups ? "" : translateText("i18n:govoplan-tenancy.custom_groups.453a605c"),
|
||||||
|
systemAllowsCustomRoles ? "" : translateText("i18n:govoplan-tenancy.custom_roles.d48dc976"),
|
||||||
|
systemAllowsApiKeys ? "" : translateText("i18n:govoplan-tenancy.api_keys.94fcf3c2")
|
||||||
|
].filter(Boolean).join(", ");
|
||||||
|
const saveDisabledReason = tenantMutationDisabledReason({
|
||||||
|
busy,
|
||||||
|
permitted: editing === "new" ? canCreate : canUpdate,
|
||||||
|
complete: Boolean(draft.name.trim() && draft.slug.trim() && (editing !== "new" || draft.ownerAccountId)),
|
||||||
|
changed: editing === "new" || dirty,
|
||||||
|
permissionReason: editing === "new" ? TENANCY_INTERFACE_I18N.createRequired : TENANCY_INTERFACE_I18N.updateRequired
|
||||||
|
});
|
||||||
|
const columns = useMemo<DataGridColumn<TenantAdminItem>[]>(() => [
|
||||||
|
{ id: "name", header: "i18n:govoplan-tenancy.tenant.3ca93c78", width: "minmax(210px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div> },
|
||||||
|
{ id: "users", header: "i18n:govoplan-tenancy.users.57f2b181", width: 100, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.users ?? 0, render: (row) => `${row.counts.active_users ?? 0}/${row.counts.users ?? 0}` },
|
||||||
|
{ id: "groups", header: "i18n:govoplan-tenancy.groups.ae9629f4", width: 95, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.groups ?? 0 },
|
||||||
|
{ id: "campaigns", header: "i18n:govoplan-tenancy.campaigns.01a23a28", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.campaigns ?? 0 },
|
||||||
|
{ id: "files", header: "i18n:govoplan-tenancy.files.6ce6c512", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 0 },
|
||||||
|
{ id: "locale", header: "i18n:govoplan-tenancy.locale.8970f0e6", width: 120, minWidth: 90, maxWidth: 220, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.default_locale },
|
||||||
|
{ id: "status", header: "i18n:govoplan-tenancy.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||||
|
{ id: "actions", header: "i18n:govoplan-tenancy.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-tenancy.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
|
{ id: "edit", label: i18nMessage("i18n:govoplan-tenancy.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !canUpdate || busy, disabledReason: busy ? TENANCY_INTERFACE_I18N.busy : !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined, onClick: () => openEdit(row) },
|
||||||
|
{ id: "suspend", label: i18nMessage("i18n:govoplan-tenancy.suspend_value.03a74b32", { value0: row.name }), icon: <Trash2 />, variant: "danger", disabled: !canSuspend || row.id === activeTenantId || !row.is_active || busy, disabledReason: busy ? TENANCY_INTERFACE_I18N.busy : !canSuspend ? TENANCY_INTERFACE_I18N.suspendRequired : row.id === activeTenantId ? TENANCY_INTERFACE_I18N.activeTenant : !row.is_active ? TENANCY_INTERFACE_I18N.alreadySuspended : undefined, onClick: () => setConfirmSuspend(row) }
|
||||||
|
]} minimumSlots={3} /> }],
|
||||||
|
[activeTenantId, busy, canSuspend, canUpdate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminPageLayout
|
||||||
|
title="i18n:govoplan-tenancy.tenants.1f7ae776"
|
||||||
|
description="i18n:govoplan-tenancy.create_and_govern_tenant_spaces_suspension_retai.1b76d377"
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
success={success}
|
||||||
|
actions={<><DocumentationHelpLink reference={TENANCY_ADMIN_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading || busy} disabledReason={loading ? TENANCY_INTERFACE_I18N.loading : busy ? TENANCY_INTERFACE_I18N.busy : undefined}>i18n:govoplan-tenancy.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-tenancy.add_tenant.b8e32af0" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || busy} disabledReason={busy ? TENANCY_INTERFACE_I18N.busy : !canCreate ? TENANCY_INTERFACE_I18N.createRequired : undefined} /></>}>
|
||||||
|
|
||||||
|
{!canCreate && !canUpdate && !canSuspend && <ActionBlockerHint
|
||||||
|
reason={{
|
||||||
|
summary: TENANCY_INTERFACE_I18N.readOnlySummary,
|
||||||
|
requiredAction: TENANCY_INTERFACE_I18N.registryPermissionGuidance,
|
||||||
|
actor: TENANCY_INTERFACE_I18N.administratorActor,
|
||||||
|
target: TENANCY_INTERFACE_I18N.tenantRegistryTarget
|
||||||
|
}}
|
||||||
|
labels={{
|
||||||
|
requiredAction: TENANCY_INTERFACE_I18N.requiredActionLabel,
|
||||||
|
actor: TENANCY_INTERFACE_I18N.actorLabel,
|
||||||
|
target: TENANCY_INTERFACE_I18N.targetLabel
|
||||||
|
}}
|
||||||
|
documentation={TENANCY_ADMIN_DOCUMENTATION}
|
||||||
|
/>}
|
||||||
|
|
||||||
|
<div className="admin-table-surface"><DataGrid id="admin-tenants-v3" rows={tenants} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-tenancy.no_tenants_found.72d04cf4" /></div>
|
||||||
|
</AdminPageLayout>
|
||||||
|
|
||||||
|
<Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-tenancy.create_tenant.4dbd55d9" : "i18n:govoplan-tenancy.edit_tenant.e2ba43f9"} onClose={requestCloseEditor} className="" footer={<><Button onClick={requestCloseEditor} disabled={busy} disabledReason={busy ? TENANCY_INTERFACE_I18N.busy : undefined}>i18n:govoplan-tenancy.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={Boolean(saveDisabledReason)} disabledReason={saveDisabledReason}>{busy ? "i18n:govoplan-tenancy.saving.56a2285c" : "i18n:govoplan-tenancy.save_tenant.9eb2ac74"}</Button></>}>
|
||||||
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
|
<FormField label="i18n:govoplan-tenancy.name.709a2322" help={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} documentation={TENANCY_FIELD_DOCUMENTATION}><input value={draft.name} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||||
|
<FormField label="i18n:govoplan-tenancy.slug.094da9b9" help={editing !== "new" ? "i18n:govoplan-tenancy.the_tenant_slug_is_immutable_after_creation.7c9a1025" : !canCreate ? TENANCY_INTERFACE_I18N.createRequired : undefined} documentation={TENANCY_FIELD_DOCUMENTATION}><input value={draft.slug} disabled={editing !== "new" || !canCreate} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||||
|
{editing === "new" && <FormField label="i18n:govoplan-tenancy.initial_tenant_owner.682291a9" documentation={TENANCY_FIELD_DOCUMENTATION}><select value={draft.ownerAccountId} onChange={(event) => setDraft({ ...draft, ownerAccountId: event.target.value })}>{ownerCandidates.map((candidate) => <option key={candidate.account_id} value={candidate.account_id}>{candidate.display_name ? i18nMessage("i18n:govoplan-tenancy.value_value.c189e8bc", { value0: candidate.display_name, value1: candidate.email }) : candidate.email}</option>)}</select></FormField>}
|
||||||
|
<FormField label="i18n:govoplan-tenancy.default_locale.b99d021f" help={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} documentation={TENANCY_FIELD_DOCUMENTATION}><input value={draft.defaultLocale} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, defaultLocale: event.target.value })} /></FormField>
|
||||||
|
{editing !== "new" && <FormField label="i18n:govoplan-tenancy.status.bae7d5be" help={!canSuspend ? TENANCY_INTERFACE_I18N.suspendRequired : undefined} documentation={TENANCY_ADMIN_DOCUMENTATION}><select value={draft.isActive ? "active" : "inactive"} disabled={!canSuspend} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-tenancy.active.a733b809</option><option value="inactive">i18n:govoplan-tenancy.suspended.794696a7</option></select></FormField>}
|
||||||
|
<FormField label="i18n:govoplan-tenancy.description.55f8ebc8" help={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} documentation={TENANCY_FIELD_DOCUMENTATION}><textarea rows={4} value={draft.description} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||||
|
</FormGrid>
|
||||||
|
<h3>i18n:govoplan-tenancy.system_governance_overrides.97cdf3ce</h3>
|
||||||
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
|
<GovernanceSelect disabled={editing !== "new" && !canUpdate} disabledReason={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} allowDisabled={!systemAllowsCustomGroups} label="i18n:govoplan-tenancy.custom_tenant_groups.570ee603" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
|
||||||
|
<GovernanceSelect disabled={editing !== "new" && !canUpdate} disabledReason={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} allowDisabled={!systemAllowsCustomRoles} label="i18n:govoplan-tenancy.custom_tenant_roles.a738c37c" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
|
||||||
|
<GovernanceSelect disabled={editing !== "new" && !canUpdate} disabledReason={editing !== "new" && !canUpdate ? TENANCY_INTERFACE_I18N.updateRequired : undefined} allowDisabled={!systemAllowsApiKeys} label="i18n:govoplan-tenancy.tenant_api_keys.4b1d81f8" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
|
||||||
|
</FormGrid>
|
||||||
|
<p className="muted small-note">i18n:govoplan-tenancy.inherit_follows_the_current_system_setting_expli.60d4d868</p>
|
||||||
|
{systemDeniedGovernance && <p className="muted small-note"><span>i18n:govoplan-tenancy.explicit_allow_is_unavailable_for.8d05fd4a</span>{" "}{systemDeniedGovernance}{" "}<span>i18n:govoplan-tenancy.because_the_current_system_setting_denies_it.3f59c244</span></p>}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-tenancy.tenant_details.5976ba72" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-tenancy.close.bbfa773e</Button>}>
|
||||||
|
{viewing && <><DescriptionList>
|
||||||
|
<DescriptionItem term={<>i18n:govoplan-tenancy.tenant.3ca93c78</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-tenancy.slug.094da9b9</>}>{viewing.slug}</DescriptionItem>
|
||||||
|
<DescriptionItem term={<>i18n:govoplan-tenancy.status.bae7d5be</>}>{viewing.is_active ? "i18n:govoplan-tenancy.active.a733b809" : "i18n:govoplan-tenancy.suspended.794696a7"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-tenancy.default_locale.b99d021f</>}>{viewing.default_locale}</DescriptionItem>
|
||||||
|
<DescriptionItem term={<>i18n:govoplan-tenancy.created.accf40c8</>}>{formatDateTime(viewing.created_at)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-tenancy.updated.f2f8570d</>}>{formatDateTime(viewing.updated_at)}</DescriptionItem>
|
||||||
|
<DescriptionItem term={<>i18n:govoplan-tenancy.custom_groups.1f7b7c8f</>}>{viewing.effective_governance.allow_custom_groups ? "i18n:govoplan-tenancy.allowed.77c7b490" : "i18n:govoplan-tenancy.denied.63b16bd4"} ({overrideLabel(fromOverride(viewing.allow_custom_groups))})</DescriptionItem>
|
||||||
|
<DescriptionItem term={<>i18n:govoplan-tenancy.custom_roles.e78ef63d</>}>{viewing.effective_governance.allow_custom_roles ? "i18n:govoplan-tenancy.allowed.77c7b490" : "i18n:govoplan-tenancy.denied.63b16bd4"} ({overrideLabel(fromOverride(viewing.allow_custom_roles))})</DescriptionItem>
|
||||||
|
<DescriptionItem term={<>i18n:govoplan-tenancy.api_keys.94fcf3c2</>}>{viewing.effective_governance.allow_api_keys ? "i18n:govoplan-tenancy.allowed.77c7b490" : "i18n:govoplan-tenancy.denied.63b16bd4"} ({overrideLabel(fromOverride(viewing.allow_api_keys))})</DescriptionItem>
|
||||||
|
<DescriptionItem term={<>i18n:govoplan-tenancy.objects.72a83add</>}>{viewing.counts.users ?? 0}{" "}<span>i18n:govoplan-tenancy.users.81651889</span>{" "}{viewing.counts.groups ?? 0}{" "}<span>i18n:govoplan-tenancy.groups.07551586</span>{" "}{viewing.counts.campaigns ?? 0}{" "}<span>i18n:govoplan-tenancy.campaigns.2282ffeb</span>{" "}{viewing.counts.files ?? 0}{" "}<span>i18n:govoplan-tenancy.files_lowercase.7c9a1026</span></DescriptionItem>
|
||||||
|
</DescriptionList>{viewing.description && <p>{viewing.description}</p>}</>}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog open={Boolean(confirmSuspend)} title="i18n:govoplan-tenancy.suspend_tenant.151d283a" message={i18nMessage("i18n:govoplan-tenancy.suspend_value_existing_data_remains_retained_but.19bccd78", { value0: confirmSuspend?.name })} confirmLabel="i18n:govoplan-tenancy.suspend_tenant.151d283a" tone="danger" busy={busy} onCancel={() => setConfirmSuspend(null)} onConfirm={() => void suspend()} />
|
||||||
|
</>);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function GovernanceSelect({ label, value, onChange, disabled = false, disabledReason, allowDisabled = false }: {label: string;value: OverrideValue;onChange: (value: OverrideValue) => void;disabled?: boolean;disabledReason?: string;allowDisabled?: boolean;}) {
|
||||||
|
return <FormField label={label} help={disabledReason ?? (allowDisabled ? TENANCY_INTERFACE_I18N.governanceSystemLimit : undefined)} documentation={TENANCY_FIELD_DOCUMENTATION}><select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as OverrideValue)}><option value="inherit">i18n:govoplan-tenancy.inherit_system_setting.7f125156</option><option value="allow" disabled={allowDisabled}>i18n:govoplan-tenancy.allow_when_system_allows.4c5178cb</option><option value="deny">i18n:govoplan-tenancy.explicitly_deny.17ad945a</option></select></FormField>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overrideLabel(value: OverrideValue): string {
|
||||||
|
if (value === "allow") return TENANCY_INTERFACE_I18N.allowLabel;
|
||||||
|
if (value === "deny") return TENANCY_INTERFACE_I18N.denyLabel;
|
||||||
|
return TENANCY_INTERFACE_I18N.inheritLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftKey(draft: TenantDraft): string {
|
||||||
|
return JSON.stringify(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTenants(left: TenantAdminItem, right: TenantAdminItem): number {
|
||||||
|
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const TENANCY_ADMIN_DOCUMENTATION = {
|
||||||
|
topicId: "tenancy.lifecycle-and-settings",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const TENANCY_FIELD_DOCUMENTATION = {
|
||||||
|
topicId: "tenancy.reference.admin-fields",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const TENANCY_INTERFACE_I18N = {
|
||||||
|
loading: "i18n:govoplan-tenancy.tenant_information_is_loading.7c9a1001",
|
||||||
|
busy: "i18n:govoplan-tenancy.a_tenant_change_is_in_progress.7c9a1002",
|
||||||
|
createRequired: "i18n:govoplan-tenancy.tenant_creation_permission_is_required.7c9a1003",
|
||||||
|
updateRequired: "i18n:govoplan-tenancy.tenant_update_permission_is_required.7c9a1004",
|
||||||
|
suspendRequired: "i18n:govoplan-tenancy.tenant_suspension_permission_is_required.7c9a1005",
|
||||||
|
settingsWriteRequired: "i18n:govoplan-tenancy.tenant_settings_write_permission_is_required.7c9a1006",
|
||||||
|
completeRequiredFields: "i18n:govoplan-tenancy.complete_all_required_tenant_fields_before_saving.7c9a1007",
|
||||||
|
noPendingChanges: "i18n:govoplan-tenancy.make_a_change_before_saving.7c9a1008",
|
||||||
|
activeTenant: "i18n:govoplan-tenancy.switch_to_another_tenant_before_suspending_the_active_tenant.7c9a1009",
|
||||||
|
alreadySuspended: "i18n:govoplan-tenancy.this_tenant_is_already_suspended.7c9a1010",
|
||||||
|
readOnlySummary: "i18n:govoplan-tenancy.tenant_administration_is_read_only.7c9a1011",
|
||||||
|
requiredActionLabel: "i18n:govoplan-tenancy.required_action.7c9a1012",
|
||||||
|
actorLabel: "i18n:govoplan-tenancy.responsible_actor.7c9a1013",
|
||||||
|
targetLabel: "i18n:govoplan-tenancy.destination.7c9a1014",
|
||||||
|
administratorActor: "i18n:govoplan-tenancy.a_system_or_tenant_owner_with_the_required_permission.7c9a1015",
|
||||||
|
tenantRegistryTarget: "i18n:govoplan-tenancy.administration_tenants.7c9a1016",
|
||||||
|
tenantSettingsTarget: "i18n:govoplan-tenancy.administration_tenant_settings.7c9a1017",
|
||||||
|
registryPermissionGuidance: "i18n:govoplan-tenancy.request_a_tenant_management_permission_or_contact_a_system_owner.7c9a1018",
|
||||||
|
settingsPermissionGuidance: "i18n:govoplan-tenancy.request_tenant_settings_write_permission_or_contact_a_tenant_owner.7c9a1019",
|
||||||
|
defaultLanguageRequired: "i18n:govoplan-tenancy.the_default_language_must_remain_enabled.7c9a1020",
|
||||||
|
governanceSystemLimit: "i18n:govoplan-tenancy.system_policy_prevents_this_tenant_from_enabling_the_capability.7c9a1021",
|
||||||
|
inheritLabel: "i18n:govoplan-tenancy.inherit.7c9a1022",
|
||||||
|
allowLabel: "i18n:govoplan-tenancy.allow.7c9a1023",
|
||||||
|
denyLabel: "i18n:govoplan-tenancy.deny.7c9a1024"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function tenantMutationDisabledReason({
|
||||||
|
busy,
|
||||||
|
permitted,
|
||||||
|
complete = true,
|
||||||
|
changed = true,
|
||||||
|
permissionReason
|
||||||
|
}: {
|
||||||
|
busy: boolean;
|
||||||
|
permitted: boolean;
|
||||||
|
complete?: boolean;
|
||||||
|
changed?: boolean;
|
||||||
|
permissionReason: string;
|
||||||
|
}): string | undefined {
|
||||||
|
if (busy) return TENANCY_INTERFACE_I18N.busy;
|
||||||
|
if (!permitted) return permissionReason;
|
||||||
|
if (!complete) return TENANCY_INTERFACE_I18N.completeRequiredFields;
|
||||||
|
if (!changed) return TENANCY_INTERFACE_I18N.noPendingChanges;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { mergeDeltaRows, type DeltaDeletedItem } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export type AdminDeltaResponse = {
|
||||||
|
deleted: DeltaDeletedItem[];
|
||||||
|
watermark?: string | null;
|
||||||
|
has_more: boolean;
|
||||||
|
full: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadDeltaRows<TItem, TResponse extends AdminDeltaResponse>(
|
||||||
|
current: TItem[],
|
||||||
|
key: string,
|
||||||
|
getDeltaWatermark: (key: string) => string | null,
|
||||||
|
setDeltaWatermark: (key: string, watermark: string | null | undefined) => void,
|
||||||
|
fetchDelta: (since: string | null) => Promise<TResponse>,
|
||||||
|
rowsFromResponse: (response: TResponse) => TItem[],
|
||||||
|
getKey: (item: TItem) => string,
|
||||||
|
deletedResourceType: string,
|
||||||
|
sort?: (left: TItem, right: TItem) => number
|
||||||
|
): Promise<TItem[]> {
|
||||||
|
let nextWatermark = getDeltaWatermark(key);
|
||||||
|
let merged = current;
|
||||||
|
let hasMore = false;
|
||||||
|
do {
|
||||||
|
const response = await fetchDelta(nextWatermark);
|
||||||
|
const rows = rowsFromResponse(response);
|
||||||
|
const continuingFullSnapshot = response.full && nextWatermark?.startsWith("full:");
|
||||||
|
merged = response.full
|
||||||
|
? continuingFullSnapshot
|
||||||
|
? mergeDeltaRows(merged, rows, [], getKey, { deletedResourceType, sort })
|
||||||
|
: rows
|
||||||
|
: mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort });
|
||||||
|
nextWatermark = response.watermark ?? null;
|
||||||
|
hasMore = response.has_more;
|
||||||
|
} while (hasMore);
|
||||||
|
setDeltaWatermark(key, nextWatermark);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const generatedTranslations: PlatformTranslations = {
|
||||||
|
"en": {
|
||||||
|
"i18n:govoplan-tenancy.appearance_defaults": "Appearance defaults",
|
||||||
|
"i18n:govoplan-tenancy.tenant_palette_default": "Tenant palette default",
|
||||||
|
"i18n:govoplan-tenancy.tenant_palette_default_help": "Inherit the system palette or select the default for this tenant.",
|
||||||
|
"i18n:govoplan-tenancy.appearance_lock_policy_permission": "Policy-write permission is required to change this lock.",
|
||||||
|
"i18n:govoplan-tenancy.custom_overrides_policy": "Personal color override policy",
|
||||||
|
"i18n:govoplan-tenancy.custom_overrides_policy_help": "Inherit the system decision, or explicitly allow or block the validated advanced editor for this tenant.",
|
||||||
|
"i18n:govoplan-tenancy.appearance_policy_permission": "Policy-write permission is required to change this appearance policy.",
|
||||||
|
"i18n:govoplan-tenancy.inherit_system_policy": "Inherit system policy",
|
||||||
|
"i18n:govoplan-tenancy.allow_for_tenant_users": "Allow for tenant users",
|
||||||
|
"i18n:govoplan-tenancy.block_for_tenant_users": "Block for tenant users",
|
||||||
|
"i18n:govoplan-tenancy.advanced_color_overrides": "Advanced color overrides",
|
||||||
|
"i18n:govoplan-tenancy.system_palette_is_locked": "The system palette is locked and takes precedence.",
|
||||||
|
"i18n:govoplan-tenancy.lock_tenant_palette": "Lock the tenant palette",
|
||||||
|
"i18n:govoplan-tenancy.effective_source": "Effective source",
|
||||||
|
"i18n:govoplan-tenancy.system_lock": "System policy lock",
|
||||||
|
"i18n:govoplan-tenancy.tenant_default": "Tenant default",
|
||||||
|
"i18n:govoplan-tenancy.system_default": "System default",
|
||||||
|
"i18n:govoplan-tenancy.user_override": "Personal choice",
|
||||||
|
"i18n:govoplan-tenancy.blocked_by_policy": "Blocked by policy",
|
||||||
|
"i18n:govoplan-tenancy.allowed": "Allowed",
|
||||||
|
"i18n:govoplan-tenancy.tenant_information_is_loading.7c9a1001": "Tenant information is loading.",
|
||||||
|
"i18n:govoplan-tenancy.a_tenant_change_is_in_progress.7c9a1002": "A tenant change is in progress.",
|
||||||
|
"i18n:govoplan-tenancy.tenant_creation_permission_is_required.7c9a1003": "Tenant creation permission is required.",
|
||||||
|
"i18n:govoplan-tenancy.tenant_update_permission_is_required.7c9a1004": "Tenant update permission is required.",
|
||||||
|
"i18n:govoplan-tenancy.tenant_suspension_permission_is_required.7c9a1005": "Tenant suspension permission is required.",
|
||||||
|
"i18n:govoplan-tenancy.tenant_settings_write_permission_is_required.7c9a1006": "Tenant settings write permission is required.",
|
||||||
|
"i18n:govoplan-tenancy.complete_all_required_tenant_fields_before_saving.7c9a1007": "Complete all required tenant fields before saving.",
|
||||||
|
"i18n:govoplan-tenancy.make_a_change_before_saving.7c9a1008": "Make a change before saving.",
|
||||||
|
"i18n:govoplan-tenancy.switch_to_another_tenant_before_suspending_the_active_tenant.7c9a1009": "Switch to another tenant before suspending the active tenant.",
|
||||||
|
"i18n:govoplan-tenancy.this_tenant_is_already_suspended.7c9a1010": "This tenant is already suspended.",
|
||||||
|
"i18n:govoplan-tenancy.tenant_administration_is_read_only.7c9a1011": "Tenant administration is read-only.",
|
||||||
|
"i18n:govoplan-tenancy.required_action.7c9a1012": "Required action",
|
||||||
|
"i18n:govoplan-tenancy.responsible_actor.7c9a1013": "Responsible actor",
|
||||||
|
"i18n:govoplan-tenancy.destination.7c9a1014": "Destination",
|
||||||
|
"i18n:govoplan-tenancy.a_system_or_tenant_owner_with_the_required_permission.7c9a1015": "A system or tenant owner with the required permission",
|
||||||
|
"i18n:govoplan-tenancy.administration_tenants.7c9a1016": "Administration > Tenants",
|
||||||
|
"i18n:govoplan-tenancy.administration_tenant_settings.7c9a1017": "Administration > Tenant settings",
|
||||||
|
"i18n:govoplan-tenancy.request_a_tenant_management_permission_or_contact_a_system_owner.7c9a1018": "Request a tenant-management permission or contact a system owner.",
|
||||||
|
"i18n:govoplan-tenancy.request_tenant_settings_write_permission_or_contact_a_tenant_owner.7c9a1019": "Request tenant-settings write permission or contact a tenant owner.",
|
||||||
|
"i18n:govoplan-tenancy.the_default_language_must_remain_enabled.7c9a1020": "The default language must remain enabled.",
|
||||||
|
"i18n:govoplan-tenancy.system_policy_prevents_this_tenant_from_enabling_the_capability.7c9a1021": "System policy prevents this tenant from enabling the capability.",
|
||||||
|
"i18n:govoplan-tenancy.inherit.7c9a1022": "inherit",
|
||||||
|
"i18n:govoplan-tenancy.allow.7c9a1023": "allow",
|
||||||
|
"i18n:govoplan-tenancy.deny.7c9a1024": "deny",
|
||||||
|
"i18n:govoplan-tenancy.the_tenant_slug_is_immutable_after_creation.7c9a1025": "The tenant slug is immutable after creation.",
|
||||||
|
"i18n:govoplan-tenancy.files_lowercase.7c9a1026": "files",
|
||||||
|
"i18n:govoplan-tenancy.actions.c3cd636a": "Actions",
|
||||||
|
"i18n:govoplan-tenancy.active.a733b809": "Active",
|
||||||
|
"i18n:govoplan-tenancy.add_tenant.b8e32af0": "Add tenant",
|
||||||
|
"i18n:govoplan-tenancy.allow_when_system_allows.4c5178cb": "Erlauben, wenn systemweit zulässig",
|
||||||
|
"i18n:govoplan-tenancy.allowed.77c7b490": "Erlaubt",
|
||||||
|
"i18n:govoplan-tenancy.api_keys.94fcf3c2": "API keys",
|
||||||
|
"i18n:govoplan-tenancy.available.7c62a142": "Available",
|
||||||
|
"i18n:govoplan-tenancy.because_the_current_system_setting_denies_it.3f59c244": "da die aktuelle Systemeinstellung dies verweigert.",
|
||||||
|
"i18n:govoplan-tenancy.campaigns.01a23a28": "Campaigns",
|
||||||
|
"i18n:govoplan-tenancy.campaigns.2282ffeb": "Kampagnen,",
|
||||||
|
"i18n:govoplan-tenancy.cancel.77dfd213": "Cancel",
|
||||||
|
"i18n:govoplan-tenancy.close.bbfa773e": "Close",
|
||||||
|
"i18n:govoplan-tenancy.create_and_govern_tenant_spaces_suspension_retai.1b76d377": "Mandantenbereiche erstellen und verwalten. Eine Sperrung bewahrt Kampagnen, Dateien und Nachweise; der Mandant der aktuellen Sitzung kann nicht gesperrt werden.",
|
||||||
|
"i18n:govoplan-tenancy.create_tenant.4dbd55d9": "Create tenant",
|
||||||
|
"i18n:govoplan-tenancy.created.accf40c8": "Created",
|
||||||
|
"i18n:govoplan-tenancy.custom_groups.1f7b7c8f": "Eigene Gruppen",
|
||||||
|
"i18n:govoplan-tenancy.custom_groups.453a605c": "eigene Gruppen",
|
||||||
|
"i18n:govoplan-tenancy.custom_roles.d48dc976": "eigene Rollen",
|
||||||
|
"i18n:govoplan-tenancy.custom_roles.e78ef63d": "Eigene Rollen",
|
||||||
|
"i18n:govoplan-tenancy.custom_tenant_groups.570ee603": "Eigene Mandantengruppen",
|
||||||
|
"i18n:govoplan-tenancy.custom_tenant_roles.a738c37c": "Eigene Mandantenrollen",
|
||||||
|
"i18n:govoplan-tenancy.default_locale.b99d021f": "Default locale",
|
||||||
|
"i18n:govoplan-tenancy.denied.63b16bd4": "Verweigert",
|
||||||
|
"i18n:govoplan-tenancy.description.55f8ebc8": "Description",
|
||||||
|
"i18n:govoplan-tenancy.edit_tenant.e2ba43f9": "Edit tenant",
|
||||||
|
"i18n:govoplan-tenancy.edit_value.fad75899": "{value0} bearbeiten",
|
||||||
|
"i18n:govoplan-tenancy.explicit_allow_is_unavailable_for.8d05fd4a": "Eine ausdrückliche Erlaubnis ist nicht verfügbar für",
|
||||||
|
"i18n:govoplan-tenancy.explicitly_deny.17ad945a": "Ausdrücklich verweigern",
|
||||||
|
"i18n:govoplan-tenancy.files.6ce6c512": "Files",
|
||||||
|
"i18n:govoplan-tenancy.general.9239ee2c": "General",
|
||||||
|
"i18n:govoplan-tenancy.groups.07551586": "Gruppen,",
|
||||||
|
"i18n:govoplan-tenancy.groups.ae9629f4": "Groups",
|
||||||
|
"i18n:govoplan-tenancy.inherit_follows_the_current_system_setting_expli.60d4d868": "Vererben folgt der aktuellen Systemeinstellung. Eine ausdrückliche Verweigerung schränkt ein; eine ausdrückliche Erlaubnis gilt nur, solange sie systemweit zulässig ist.",
|
||||||
|
"i18n:govoplan-tenancy.inherit_system_setting.7f125156": "Systemeinstellung vererben",
|
||||||
|
"i18n:govoplan-tenancy.initial_tenant_owner.682291a9": "Anfängliche Mandanteneigentümerschaft",
|
||||||
|
"i18n:govoplan-tenancy.inspect_value.9d5d1071": "{value0} anzeigen",
|
||||||
|
"i18n:govoplan-tenancy.locale.8970f0e6": "Locale",
|
||||||
|
"i18n:govoplan-tenancy.name.709a2322": "Name",
|
||||||
|
"i18n:govoplan-tenancy.no_tenants_found.72d04cf4": "Keine Mandanten gefunden.",
|
||||||
|
"i18n:govoplan-tenancy.objects.72a83add": "Objekte",
|
||||||
|
"i18n:govoplan-tenancy.reload.cce71553": "Reload",
|
||||||
|
"i18n:govoplan-tenancy.save_general_settings.5c90f8c4": "Allgemeine Einstellungen speichern",
|
||||||
|
"i18n:govoplan-tenancy.save_tenant.9eb2ac74": "Save tenant",
|
||||||
|
"i18n:govoplan-tenancy.saving.56a2285c": "Speichern…",
|
||||||
|
"i18n:govoplan-tenancy.saving.ae7e8875": "Speichern...",
|
||||||
|
"i18n:govoplan-tenancy.settings_for_the_active_tenant_context.ad267b86": "Einstellungen für den aktiven Mandantenkontext.",
|
||||||
|
"i18n:govoplan-tenancy.slug.094da9b9": "Slug",
|
||||||
|
"i18n:govoplan-tenancy.status.bae7d5be": "Status",
|
||||||
|
"i18n:govoplan-tenancy.suspend_tenant.151d283a": "Mandant sperren",
|
||||||
|
"i18n:govoplan-tenancy.suspend_value.03a74b32": "{value0} sperren",
|
||||||
|
"i18n:govoplan-tenancy.suspend_value_existing_data_remains_retained_but.19bccd78": "{value0} sperren? Bestehende Daten bleiben erhalten, aber Mitglieder können den Mandanten nicht mehr verwenden.",
|
||||||
|
"i18n:govoplan-tenancy.suspended.794696a7": "Gesperrt",
|
||||||
|
"i18n:govoplan-tenancy.system_governance_overrides.97cdf3ce": "Systemvorgaben",
|
||||||
|
"i18n:govoplan-tenancy.tenancy": "Tenancy",
|
||||||
|
"i18n:govoplan-tenancy.tenant.3ca93c78": "Tenant",
|
||||||
|
"i18n:govoplan-tenancy.tenant_api_keys.4b1d81f8": "Tenant API keys",
|
||||||
|
"i18n:govoplan-tenancy.tenant_details.5976ba72": "Mandantendetails",
|
||||||
|
"i18n:govoplan-tenancy.tenant_general_settings.db1c3ba8": "Allgemeine Mandanteneinstellungen",
|
||||||
|
"i18n:govoplan-tenancy.tenant_general_settings_saved.485e7681": "Allgemeine Mandanteneinstellungen gespeichert.",
|
||||||
|
"i18n:govoplan-tenancy.tenant_languages_help": "Tenant languages can only be selected from languages enabled by the system. Users can choose from the tenant-enabled set.",
|
||||||
|
"i18n:govoplan-tenancy.tenant_locale.8fc19914": "Mandantensprache",
|
||||||
|
"i18n:govoplan-tenancy.tenant_value_created_with_value_as_owner.1c18b6fb": "Mandant {value0} mit {value1} als Eigentümerschaft erstellt.",
|
||||||
|
"i18n:govoplan-tenancy.tenant_value_updated.25b2c855": "Mandant {value0} aktualisiert.",
|
||||||
|
"i18n:govoplan-tenancy.tenants.1f7ae776": "Tenants",
|
||||||
|
"i18n:govoplan-tenancy.the_selected_account.1211bfb9": "das ausgewählte Konto",
|
||||||
|
"i18n:govoplan-tenancy.updated.f2f8570d": "Updated",
|
||||||
|
"i18n:govoplan-tenancy.used_as_this_tenant_s_locale_default_for_tenant_.cf298b8b": "Wird als Standardsprache dieses Mandanten für mandantenbezogene Ansichten und Formatierungen verwendet.",
|
||||||
|
"i18n:govoplan-tenancy.users.57f2b181": "Users",
|
||||||
|
"i18n:govoplan-tenancy.users.81651889": "Benutzer,",
|
||||||
|
"i18n:govoplan-tenancy.value_suspended.31731a28": "{value0} gesperrt.",
|
||||||
|
"i18n:govoplan-tenancy.value_value.c189e8bc": "{value0} ({value1})"
|
||||||
|
},
|
||||||
|
"de": {
|
||||||
|
"i18n:govoplan-tenancy.appearance_defaults": "Darstellungsstandards",
|
||||||
|
"i18n:govoplan-tenancy.tenant_palette_default": "Mandantenstandard für die Farbpalette",
|
||||||
|
"i18n:govoplan-tenancy.tenant_palette_default_help": "Systempalette übernehmen oder einen Standard für diesen Mandanten auswählen.",
|
||||||
|
"i18n:govoplan-tenancy.appearance_lock_policy_permission": "Zum Ändern dieser Sperre ist das Recht zum Bearbeiten von Richtlinien erforderlich.",
|
||||||
|
"i18n:govoplan-tenancy.custom_overrides_policy": "Richtlinie für persönliche Farbanpassungen",
|
||||||
|
"i18n:govoplan-tenancy.custom_overrides_policy_help": "Die Systementscheidung übernehmen oder den geprüften erweiterten Editor für diesen Mandanten ausdrücklich zulassen oder sperren.",
|
||||||
|
"i18n:govoplan-tenancy.appearance_policy_permission": "Zum Ändern dieser Darstellungsrichtlinie ist das Recht zum Bearbeiten von Richtlinien erforderlich.",
|
||||||
|
"i18n:govoplan-tenancy.inherit_system_policy": "Systemrichtlinie übernehmen",
|
||||||
|
"i18n:govoplan-tenancy.allow_for_tenant_users": "Für Mandantenbenutzer zulassen",
|
||||||
|
"i18n:govoplan-tenancy.block_for_tenant_users": "Für Mandantenbenutzer sperren",
|
||||||
|
"i18n:govoplan-tenancy.advanced_color_overrides": "Erweiterte Farbanpassungen",
|
||||||
|
"i18n:govoplan-tenancy.system_palette_is_locked": "Die Systempalette ist verbindlich und hat Vorrang.",
|
||||||
|
"i18n:govoplan-tenancy.lock_tenant_palette": "Mandantenpalette verbindlich festlegen",
|
||||||
|
"i18n:govoplan-tenancy.effective_source": "Wirksame Quelle",
|
||||||
|
"i18n:govoplan-tenancy.system_lock": "Systemrichtlinie",
|
||||||
|
"i18n:govoplan-tenancy.tenant_default": "Mandantenstandard",
|
||||||
|
"i18n:govoplan-tenancy.system_default": "Systemstandard",
|
||||||
|
"i18n:govoplan-tenancy.user_override": "Persönliche Auswahl",
|
||||||
|
"i18n:govoplan-tenancy.blocked_by_policy": "Durch Richtlinie gesperrt",
|
||||||
|
"i18n:govoplan-tenancy.allowed": "Zulässig",
|
||||||
|
"i18n:govoplan-tenancy.tenant_information_is_loading.7c9a1001": "Mandanteninformationen werden geladen.",
|
||||||
|
"i18n:govoplan-tenancy.a_tenant_change_is_in_progress.7c9a1002": "Eine Mandantenänderung wird gerade ausgeführt.",
|
||||||
|
"i18n:govoplan-tenancy.tenant_creation_permission_is_required.7c9a1003": "Die Berechtigung zum Erstellen von Mandanten ist erforderlich.",
|
||||||
|
"i18n:govoplan-tenancy.tenant_update_permission_is_required.7c9a1004": "Die Berechtigung zum Bearbeiten von Mandanten ist erforderlich.",
|
||||||
|
"i18n:govoplan-tenancy.tenant_suspension_permission_is_required.7c9a1005": "Die Berechtigung zum Sperren von Mandanten ist erforderlich.",
|
||||||
|
"i18n:govoplan-tenancy.tenant_settings_write_permission_is_required.7c9a1006": "Die Schreibberechtigung für Mandanteneinstellungen ist erforderlich.",
|
||||||
|
"i18n:govoplan-tenancy.complete_all_required_tenant_fields_before_saving.7c9a1007": "Füllen Sie vor dem Speichern alle erforderlichen Mandantenfelder aus.",
|
||||||
|
"i18n:govoplan-tenancy.make_a_change_before_saving.7c9a1008": "Nehmen Sie vor dem Speichern eine Änderung vor.",
|
||||||
|
"i18n:govoplan-tenancy.switch_to_another_tenant_before_suspending_the_active_tenant.7c9a1009": "Wechseln Sie zu einem anderen Mandanten, bevor Sie den aktiven Mandanten sperren.",
|
||||||
|
"i18n:govoplan-tenancy.this_tenant_is_already_suspended.7c9a1010": "Dieser Mandant ist bereits gesperrt.",
|
||||||
|
"i18n:govoplan-tenancy.tenant_administration_is_read_only.7c9a1011": "Die Mandantenverwaltung ist schreibgeschützt.",
|
||||||
|
"i18n:govoplan-tenancy.required_action.7c9a1012": "Erforderliche Aktion",
|
||||||
|
"i18n:govoplan-tenancy.responsible_actor.7c9a1013": "Zuständige Stelle",
|
||||||
|
"i18n:govoplan-tenancy.destination.7c9a1014": "Ziel",
|
||||||
|
"i18n:govoplan-tenancy.a_system_or_tenant_owner_with_the_required_permission.7c9a1015": "Eine System- oder Mandanteneigentümerschaft mit der erforderlichen Berechtigung",
|
||||||
|
"i18n:govoplan-tenancy.administration_tenants.7c9a1016": "Administration > Mandanten",
|
||||||
|
"i18n:govoplan-tenancy.administration_tenant_settings.7c9a1017": "Administration > Mandanteneinstellungen",
|
||||||
|
"i18n:govoplan-tenancy.request_a_tenant_management_permission_or_contact_a_system_owner.7c9a1018": "Fordern Sie eine Mandantenverwaltungsberechtigung an oder wenden Sie sich an eine Systemeigentümerschaft.",
|
||||||
|
"i18n:govoplan-tenancy.request_tenant_settings_write_permission_or_contact_a_tenant_owner.7c9a1019": "Fordern Sie die Schreibberechtigung für Mandanteneinstellungen an oder wenden Sie sich an eine Mandanteneigentümerschaft.",
|
||||||
|
"i18n:govoplan-tenancy.the_default_language_must_remain_enabled.7c9a1020": "Die Standardsprache muss aktiviert bleiben.",
|
||||||
|
"i18n:govoplan-tenancy.system_policy_prevents_this_tenant_from_enabling_the_capability.7c9a1021": "Die Systemrichtlinie verhindert, dass dieser Mandant die Funktion aktiviert.",
|
||||||
|
"i18n:govoplan-tenancy.inherit.7c9a1022": "vererbt",
|
||||||
|
"i18n:govoplan-tenancy.allow.7c9a1023": "erlauben",
|
||||||
|
"i18n:govoplan-tenancy.deny.7c9a1024": "verweigern",
|
||||||
|
"i18n:govoplan-tenancy.the_tenant_slug_is_immutable_after_creation.7c9a1025": "Der Mandanten-Slug kann nach der Erstellung nicht geändert werden.",
|
||||||
|
"i18n:govoplan-tenancy.files_lowercase.7c9a1026": "Dateien",
|
||||||
|
"i18n:govoplan-tenancy.actions.c3cd636a": "Aktionen",
|
||||||
|
"i18n:govoplan-tenancy.active.a733b809": "Aktiv",
|
||||||
|
"i18n:govoplan-tenancy.add_tenant.b8e32af0": "Mandant hinzufügen",
|
||||||
|
"i18n:govoplan-tenancy.allow_when_system_allows.4c5178cb": "Allow when system allows",
|
||||||
|
"i18n:govoplan-tenancy.allowed.77c7b490": "Allowed",
|
||||||
|
"i18n:govoplan-tenancy.api_keys.94fcf3c2": "API-Schlüssel",
|
||||||
|
"i18n:govoplan-tenancy.available.7c62a142": "Verfuegbar",
|
||||||
|
"i18n:govoplan-tenancy.because_the_current_system_setting_denies_it.3f59c244": "because the current system setting denies it.",
|
||||||
|
"i18n:govoplan-tenancy.campaigns.01a23a28": "Kampagnen",
|
||||||
|
"i18n:govoplan-tenancy.campaigns.2282ffeb": "campaigns,",
|
||||||
|
"i18n:govoplan-tenancy.cancel.77dfd213": "Abbrechen",
|
||||||
|
"i18n:govoplan-tenancy.close.bbfa773e": "Schließen",
|
||||||
|
"i18n:govoplan-tenancy.create_and_govern_tenant_spaces_suspension_retai.1b76d377": "Create and govern tenant spaces. Suspension retains campaigns, files and audit evidence; the tenant backing the current session cannot be suspended.",
|
||||||
|
"i18n:govoplan-tenancy.create_tenant.4dbd55d9": "Mandant erstellen",
|
||||||
|
"i18n:govoplan-tenancy.created.accf40c8": "Erstellt",
|
||||||
|
"i18n:govoplan-tenancy.custom_groups.1f7b7c8f": "Custom groups",
|
||||||
|
"i18n:govoplan-tenancy.custom_groups.453a605c": "custom groups",
|
||||||
|
"i18n:govoplan-tenancy.custom_roles.d48dc976": "custom roles",
|
||||||
|
"i18n:govoplan-tenancy.custom_roles.e78ef63d": "Custom roles",
|
||||||
|
"i18n:govoplan-tenancy.custom_tenant_groups.570ee603": "Custom tenant groups",
|
||||||
|
"i18n:govoplan-tenancy.custom_tenant_roles.a738c37c": "Custom tenant roles",
|
||||||
|
"i18n:govoplan-tenancy.default_locale.b99d021f": "Standardsprache",
|
||||||
|
"i18n:govoplan-tenancy.denied.63b16bd4": "Denied",
|
||||||
|
"i18n:govoplan-tenancy.description.55f8ebc8": "Beschreibung",
|
||||||
|
"i18n:govoplan-tenancy.edit_tenant.e2ba43f9": "Mandant bearbeiten",
|
||||||
|
"i18n:govoplan-tenancy.edit_value.fad75899": "Edit {value0}",
|
||||||
|
"i18n:govoplan-tenancy.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
|
||||||
|
"i18n:govoplan-tenancy.explicitly_deny.17ad945a": "Explicitly deny",
|
||||||
|
"i18n:govoplan-tenancy.files.6ce6c512": "Dateien",
|
||||||
|
"i18n:govoplan-tenancy.general.9239ee2c": "Allgemein",
|
||||||
|
"i18n:govoplan-tenancy.groups.07551586": "groups,",
|
||||||
|
"i18n:govoplan-tenancy.groups.ae9629f4": "Gruppen",
|
||||||
|
"i18n:govoplan-tenancy.inherit_follows_the_current_system_setting_expli.60d4d868": "Inherit follows the current system setting. Explicit deny narrows access; explicit allow is valid only while the system setting allows it.",
|
||||||
|
"i18n:govoplan-tenancy.inherit_system_setting.7f125156": "Inherit system setting",
|
||||||
|
"i18n:govoplan-tenancy.initial_tenant_owner.682291a9": "Initial tenant owner",
|
||||||
|
"i18n:govoplan-tenancy.inspect_value.9d5d1071": "Inspect {value0}",
|
||||||
|
"i18n:govoplan-tenancy.locale.8970f0e6": "Locale",
|
||||||
|
"i18n:govoplan-tenancy.name.709a2322": "Name",
|
||||||
|
"i18n:govoplan-tenancy.no_tenants_found.72d04cf4": "No tenants found.",
|
||||||
|
"i18n:govoplan-tenancy.objects.72a83add": "Objects",
|
||||||
|
"i18n:govoplan-tenancy.reload.cce71553": "Neu laden",
|
||||||
|
"i18n:govoplan-tenancy.save_general_settings.5c90f8c4": "Save general settings",
|
||||||
|
"i18n:govoplan-tenancy.save_tenant.9eb2ac74": "Mandant speichern",
|
||||||
|
"i18n:govoplan-tenancy.saving.56a2285c": "Saving…",
|
||||||
|
"i18n:govoplan-tenancy.saving.ae7e8875": "Saving...",
|
||||||
|
"i18n:govoplan-tenancy.settings_for_the_active_tenant_context.ad267b86": "Settings for the active tenant context.",
|
||||||
|
"i18n:govoplan-tenancy.slug.094da9b9": "Slug",
|
||||||
|
"i18n:govoplan-tenancy.status.bae7d5be": "Status",
|
||||||
|
"i18n:govoplan-tenancy.suspend_tenant.151d283a": "Suspend tenant",
|
||||||
|
"i18n:govoplan-tenancy.suspend_value.03a74b32": "Suspend {value0}",
|
||||||
|
"i18n:govoplan-tenancy.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.",
|
||||||
|
"i18n:govoplan-tenancy.suspended.794696a7": "Suspended",
|
||||||
|
"i18n:govoplan-tenancy.system_governance_overrides.97cdf3ce": "System governance overrides",
|
||||||
|
"i18n:govoplan-tenancy.tenancy": "Mandantenfähigkeit",
|
||||||
|
"i18n:govoplan-tenancy.tenant.3ca93c78": "Mandant",
|
||||||
|
"i18n:govoplan-tenancy.tenant_api_keys.4b1d81f8": "Mandanten-API-Schlüssel",
|
||||||
|
"i18n:govoplan-tenancy.tenant_details.5976ba72": "Tenant details",
|
||||||
|
"i18n:govoplan-tenancy.tenant_general_settings.db1c3ba8": "Tenant general settings",
|
||||||
|
"i18n:govoplan-tenancy.tenant_general_settings_saved.485e7681": "Tenant general settings saved.",
|
||||||
|
"i18n:govoplan-tenancy.tenant_languages_help": "Mandantensprachen koennen nur aus den systemweit aktivierten Sprachen gewaehlt werden. Benutzer koennen aus den fuer den Mandanten aktivierten Sprachen waehlen.",
|
||||||
|
"i18n:govoplan-tenancy.tenant_locale.8fc19914": "Tenant locale",
|
||||||
|
"i18n:govoplan-tenancy.tenant_value_created_with_value_as_owner.1c18b6fb": "Tenant {value0} created with {value1} as Owner.",
|
||||||
|
"i18n:govoplan-tenancy.tenant_value_updated.25b2c855": "Tenant {value0} updated.",
|
||||||
|
"i18n:govoplan-tenancy.tenants.1f7ae776": "Mandanten",
|
||||||
|
"i18n:govoplan-tenancy.the_selected_account.1211bfb9": "the selected account",
|
||||||
|
"i18n:govoplan-tenancy.updated.f2f8570d": "Aktualisiert",
|
||||||
|
"i18n:govoplan-tenancy.used_as_this_tenant_s_locale_default_for_tenant_.cf298b8b": "Used as this tenant's locale default for tenant-aware views and future formatting defaults.",
|
||||||
|
"i18n:govoplan-tenancy.users.57f2b181": "Benutzer",
|
||||||
|
"i18n:govoplan-tenancy.users.81651889": "users,",
|
||||||
|
"i18n:govoplan-tenancy.value_suspended.31731a28": "{value0} suspended.",
|
||||||
|
"i18n:govoplan-tenancy.value_value.c189e8bc": "{value0} ({value1})"
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export { default } from "./module";
|
||||||
|
export * from "./module";
|
||||||
|
export * from "./api/tenancy";
|
||||||
|
export { default as TenantsPanel } from "./features/admin/TenantsPanel";
|
||||||
|
export { default as TenantSettingsPanel } from "./features/admin/TenantSettingsPanel";
|
||||||
|
export type {
|
||||||
|
PlatformWebModule,
|
||||||
|
PlatformRouteContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import type {
|
||||||
|
AdminSectionsUiCapability,
|
||||||
|
PlatformWebModule
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
|
|
||||||
|
const TenantsPanel = lazy(() => import("./features/admin/TenantsPanel"));
|
||||||
|
const TenantSettingsPanel = lazy(
|
||||||
|
() => import("./features/admin/TenantSettingsPanel")
|
||||||
|
);
|
||||||
|
|
||||||
|
const adminSections: AdminSectionsUiCapability = {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "system-tenants",
|
||||||
|
moduleId: "tenancy",
|
||||||
|
kind: "management",
|
||||||
|
surfaceId: "tenancy.admin.system-tenants",
|
||||||
|
label: "i18n:govoplan-tenancy.tenants.1f7ae776",
|
||||||
|
group: "SYSTEM",
|
||||||
|
order: 10,
|
||||||
|
anyOf: ["system:tenants:read"],
|
||||||
|
render: ({ settings, auth, refreshAuth }) =>
|
||||||
|
createElement(TenantsPanel, {
|
||||||
|
settings,
|
||||||
|
auth,
|
||||||
|
canCreate: auth.scopes.includes("system:tenants:create"),
|
||||||
|
canUpdate: auth.scopes.includes("system:tenants:update"),
|
||||||
|
canSuspend: auth.scopes.includes("system:tenants:suspend"),
|
||||||
|
onAuthRefresh: refreshAuth
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tenant-settings",
|
||||||
|
moduleId: "tenancy",
|
||||||
|
kind: "settings",
|
||||||
|
surfaceId: "tenancy.admin.tenant-settings",
|
||||||
|
label: "i18n:govoplan-tenancy.general.9239ee2c",
|
||||||
|
group: "TENANT",
|
||||||
|
order: 90,
|
||||||
|
anyOf: ["admin:settings:read"],
|
||||||
|
render: ({ settings, auth, refreshAuth }) =>
|
||||||
|
createElement(TenantSettingsPanel, {
|
||||||
|
settings,
|
||||||
|
canWrite: auth.scopes.includes("admin:settings:write"),
|
||||||
|
canWritePolicy: auth.scopes.includes("admin:policies:write"),
|
||||||
|
onAuthRefresh: refreshAuth
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tenancyModule: PlatformWebModule = {
|
||||||
|
id: "tenancy",
|
||||||
|
label: "i18n:govoplan-tenancy.tenancy",
|
||||||
|
version: "0.1.8",
|
||||||
|
optionalDependencies: ["access"],
|
||||||
|
translations: {
|
||||||
|
en: generatedTranslations.en,
|
||||||
|
de: generatedTranslations.de
|
||||||
|
},
|
||||||
|
viewSurfaces: [
|
||||||
|
{
|
||||||
|
id: "tenancy.admin.system-tenants",
|
||||||
|
moduleId: "tenancy",
|
||||||
|
kind: "section",
|
||||||
|
label: "i18n:govoplan-tenancy.tenants.1f7ae776",
|
||||||
|
order: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tenancy.admin.tenant-settings",
|
||||||
|
moduleId: "tenancy",
|
||||||
|
kind: "section",
|
||||||
|
label: "i18n:govoplan-tenancy.tenant_general_settings.db1c3ba8",
|
||||||
|
order: 90
|
||||||
|
}
|
||||||
|
],
|
||||||
|
uiCapabilities: {
|
||||||
|
"admin.sections": adminSections
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default tenancyModule;
|
||||||
Reference in New Issue
Block a user