Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f8a05f8b9 | ||
|
|
e55434f406 | ||
|
|
a889071b71 | ||
|
|
8a43b9b676 | ||
|
|
206873b62a | ||
|
|
2d1b1e356e | ||
|
|
fa0c85e03a | ||
|
|
94b604a3af | ||
|
|
d3daf42bd9 | ||
|
|
6052dde760 | ||
|
|
566b3b83ad | ||
|
|
e4bae0121d | ||
|
|
5c6f446cd5 | ||
|
|
2be1dbc132 | ||
|
|
38fc22c06b | ||
|
|
3661fdd370 | ||
|
|
3966c7f33c | ||
|
|
9ba69286f7 | ||
|
|
55d87a7812 | ||
|
|
a861338b9a | ||
|
|
0b9e3751c2 | ||
|
|
e04671034f | ||
|
|
44799b15e5 | ||
|
|
367ffc4564 | ||
|
|
a7ed28f6a3 | ||
|
|
794fcf90f8 | ||
|
|
d5db5c2378 | ||
|
|
4653542247 | ||
|
|
668f7cf108 | ||
|
|
e710cf5fb8 | ||
|
|
998d47ae94 | ||
|
|
1409dbf94d | ||
|
|
fe247999e9 | ||
|
|
9776f862f8 | ||
|
|
e58dcbdd7b | ||
|
|
aeda457fb1 | ||
|
|
a70cc375c2 | ||
|
|
e8ea347654 | ||
|
|
a758c8f2da | ||
|
|
6295cfa840 | ||
|
|
b6c2c89adf | ||
|
|
6b0dd8beab | ||
|
|
984a015704 | ||
|
|
3df4fc5bff | ||
|
|
1b57df7753 | ||
|
|
bf1ecc54b3 | ||
|
|
fdfcfbb440 |
@@ -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
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# GovOPlaN Access Codex Guide
|
# GovOPlaN Access Codex Guide
|
||||||
|
|
||||||
|
## Documentation Contract
|
||||||
|
|
||||||
|
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
|
||||||
|
- Keep feature content here; `govoplan-docs` projects it without importing Access internals.
|
||||||
|
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
This repository owns the GovOPlaN access platform module seed: identity,
|
This repository owns the GovOPlaN access platform module seed: identity,
|
||||||
|
|||||||
@@ -63,8 +63,12 @@ This module will own:
|
|||||||
- compatibility FastAPI auth dependency API at `govoplan_access.auth`
|
- compatibility FastAPI auth dependency API at `govoplan_access.auth`
|
||||||
- provider-facing auth facade consumed by sibling modules at `govoplan_core.auth`
|
- provider-facing auth facade consumed by sibling modules at `govoplan_core.auth`
|
||||||
- access administration, tenant provisioning, and governance materializer
|
- access administration, tenant provisioning, and governance materializer
|
||||||
capabilities
|
capabilities, including the bounded `access.governanceProjection.v1` bulk
|
||||||
|
reconciliation contract used by Admin for idempotent per-assignment outcomes
|
||||||
- access-owned migrations
|
- access-owned migrations
|
||||||
|
- a provider-neutral tenant-erasure contribution that removes tenant-scoped
|
||||||
|
credentials and authorization projections while preserving shared global
|
||||||
|
accounts and identities
|
||||||
|
|
||||||
The governance-template routes under `/admin/system/governance-templates` are
|
The governance-template routes under `/admin/system/governance-templates` are
|
||||||
contributed by `govoplan-admin`; access must not register those routes.
|
contributed by `govoplan-admin`; access must not register those routes.
|
||||||
@@ -76,13 +80,33 @@ adds tenant administration plus tenant resolver behavior when installed.
|
|||||||
## Principal Context
|
## Principal Context
|
||||||
|
|
||||||
The stable principal DTO is `govoplan_core.core.access.PrincipalRef`. Access
|
The stable principal DTO is `govoplan_core.core.access.PrincipalRef`. Access
|
||||||
resolves sessions, API keys, and future service accounts into that DTO and
|
resolves sessions, API keys, and service-account credentials into that DTO and
|
||||||
serializes it as `principal` in auth API responses. Feature modules should use
|
serializes it as `principal` in auth API responses. Feature modules should use
|
||||||
that DTO, primitive IDs, or the core `govoplan_core.auth` dependency facade
|
that DTO, primitive IDs, or the core `govoplan_core.auth` dependency facade
|
||||||
instead of importing access ORM models or backend dependency internals.
|
instead of importing access ORM models or backend dependency internals.
|
||||||
|
|
||||||
The detailed module boundary and serialization fields are documented in
|
The detailed module boundary and serialization fields are documented in
|
||||||
[docs/ACCESS_MODULE_BOUNDARY.md](docs/ACCESS_MODULE_BOUNDARY.md).
|
[docs/ACCESS_MODULE_BOUNDARY.md](docs/ACCESS_MODULE_BOUNDARY.md).
|
||||||
|
The Access-owned administration surfaces, consequence classes, shared control
|
||||||
|
contract, contextual-help references, and verification evidence are recorded
|
||||||
|
in [docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||||
|
|
||||||
|
For scheduled and event-driven work, Access provides
|
||||||
|
`auth.automationPrincipalProvider`. Automation records store only an owner
|
||||||
|
account/membership reference and an explicit least-privilege scope grant, not
|
||||||
|
a session or API token. At delivery time Access rebuilds the principal from
|
||||||
|
current roles, groups, functions, and delegations and intersects that
|
||||||
|
authorization with the stored grant. Missing, inactive, moved, or
|
||||||
|
under-authorized owners fail closed before module work starts.
|
||||||
|
|
||||||
|
Tenant administrators manage non-login service accounts under
|
||||||
|
`Admin > Tenant > Service accounts`. Each service account has a revisioned
|
||||||
|
scope ceiling and independently revocable API credentials. Credential secrets
|
||||||
|
are shown once; runtime authorization intersects the credential grant with the
|
||||||
|
current ceiling. Rotation creates a replacement and revokes the previous
|
||||||
|
credential atomically, while retirement revokes every active credential. See
|
||||||
|
[docs/SERVICE_ACCOUNTS.md](docs/SERVICE_ACCOUNTS.md) for the API and operational
|
||||||
|
contract.
|
||||||
|
|
||||||
## WebUI Package
|
## WebUI Package
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ contracts.
|
|||||||
- tenant owner provisioning and default access bootstrap
|
- tenant owner provisioning and default access bootstrap
|
||||||
- materializing governance templates into access-owned groups and roles
|
- materializing governance templates into access-owned groups and roles
|
||||||
- access-owned SQLAlchemy metadata and migrations for `access_*` tables
|
- access-owned SQLAlchemy metadata and migrations for `access_*` tables
|
||||||
|
- the `tenancy.erasure_provider.access` contribution, which previews and
|
||||||
|
idempotently removes only target-tenant credentials and authorization rows
|
||||||
|
while retaining global accounts and identities shared with other tenants
|
||||||
|
|
||||||
The active access tables use the `access_*` namespace while the model classes
|
The active access tables use the `access_*` namespace while the model classes
|
||||||
live in this module: `access_accounts`, `access_users`, `access_groups`,
|
live in this module: `access_accounts`, `access_users`, `access_groups`,
|
||||||
@@ -64,6 +67,22 @@ Access declares tenancy as an optional module integration. It uses the
|
|||||||
core-owned `core_scopes` table as the scope table, but it must not import
|
core-owned `core_scopes` table as the scope table, but it must not import
|
||||||
`govoplan_tenancy` or require the tenancy package to start.
|
`govoplan_tenancy` or require the tenancy package to start.
|
||||||
|
|
||||||
|
## Tenant-Erasure Boundary
|
||||||
|
|
||||||
|
Access implements the Core tenant-erasure provider contract without importing
|
||||||
|
Tenancy. Its preview counts every Access table with a tenant boundary. The
|
||||||
|
first destructive step removes target-tenant sessions and API keys; the second
|
||||||
|
removes service accounts, memberships, groups, tenant roles, organization
|
||||||
|
units, functions, assignments, and delegations in dependency-safe order.
|
||||||
|
Both steps are database-transactional and idempotent, so reconciliation can
|
||||||
|
repeat them after an interrupted response.
|
||||||
|
|
||||||
|
Global accounts, system-role assignments, identities, and identity-account
|
||||||
|
links are intentionally retained: they are installation-wide facts and may be
|
||||||
|
used by another tenant. Provider previews and receipts contain counts and
|
||||||
|
stable references only, never password hashes, session tokens, API-key hashes,
|
||||||
|
email addresses, or other credential material.
|
||||||
|
|
||||||
## Core-Only Startup Contract
|
## Core-Only Startup Contract
|
||||||
|
|
||||||
A core-only installation must be able to start far enough to expose process
|
A core-only installation must be able to start far enough to expose process
|
||||||
@@ -146,8 +165,11 @@ The backend foundation exposes these administration routes:
|
|||||||
- `/api/v1/admin/function-assignments`
|
- `/api/v1/admin/function-assignments`
|
||||||
- `/api/v1/admin/function-delegations`
|
- `/api/v1/admin/function-delegations`
|
||||||
|
|
||||||
Dedicated WebUI management panels and explicit acting-in-place context
|
Dedicated WebUI management panels remain follow-up work on top of these routes.
|
||||||
selection are still follow-up work on top of these routes.
|
Interactive acting-in-place selection is available through
|
||||||
|
`/api/v1/auth/acting-contexts` and `/api/v1/auth/switch-acting-context`; it
|
||||||
|
persists the exact selected assignment and represented account on the session,
|
||||||
|
audits each switch, and fails closed when the assignment is no longer effective.
|
||||||
|
|
||||||
## Removed Compatibility Paths
|
## Removed Compatibility Paths
|
||||||
|
|
||||||
@@ -177,7 +199,24 @@ admin routers as base routers.
|
|||||||
|
|
||||||
Governance-template metadata CRUD is not access-owned. It is contributed by
|
Governance-template metadata CRUD is not access-owned. It is contributed by
|
||||||
`govoplan-admin`; access only materializes those templates into access-owned
|
`govoplan-admin`; access only materializes those templates into access-owned
|
||||||
groups and roles through the `access.governanceMaterializer` capability.
|
groups and roles. The compatibility `access.governanceMaterializer` capability
|
||||||
|
remains available for single-assignment callers. New Admin orchestration uses
|
||||||
|
`access.governanceProjection.v1`: a bounded request of stable template and
|
||||||
|
assignment DTOs that bulk-loads managed rows and assignment blockers, applies
|
||||||
|
idempotent create/update/remove reconciliation, and returns one provenance-rich
|
||||||
|
outcome per assignment. Admin never imports Access ORM models.
|
||||||
|
|
||||||
|
The configuration-package Admin routes remain in Access as a compatibility
|
||||||
|
surface. Their preflight context is assembled from the active Core registry,
|
||||||
|
including module-owned external-provider declarations. This allows an
|
||||||
|
integration package to validate installed provider authority and maturity
|
||||||
|
without importing provider modules into Access. For dry-run, apply, and export,
|
||||||
|
Access also asks the active registry for tenant-scoped, sanitized runtime
|
||||||
|
provider state using the request database transaction. Package preflight can
|
||||||
|
therefore select an exact stable binding and evaluate its authority, health,
|
||||||
|
freshness, and recovery readiness. When no provider state is available,
|
||||||
|
preflight reports it as unverified rather than inferring health from
|
||||||
|
installation.
|
||||||
|
|
||||||
## Verification References
|
## Verification References
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ Implemented backend foundation:
|
|||||||
delegation identifiers when those facts exist.
|
delegation identifiers when those facts exist.
|
||||||
- The access manifest registers `access.semanticDirectory` and
|
- The access manifest registers `access.semanticDirectory` and
|
||||||
`access.explanation` capabilities.
|
`access.explanation` capabilities.
|
||||||
|
- Interactive sessions can list `/api/v1/auth/acting-contexts` and explicitly
|
||||||
|
select or clear one with `/api/v1/auth/switch-acting-context`. Every switch is
|
||||||
|
audited. API keys cannot select an acting context, and a stale, expired,
|
||||||
|
revoked, or account-mismatched assignment fails closed.
|
||||||
|
|
||||||
Remaining rollout:
|
Remaining rollout:
|
||||||
|
|
||||||
@@ -169,9 +173,6 @@ Remaining rollout:
|
|||||||
projection until migration is complete.
|
projection until migration is complete.
|
||||||
2. Add dedicated WebUI management panels for identities, organization units,
|
2. Add dedicated WebUI management panels for identities, organization units,
|
||||||
functions, assignments, and delegations.
|
functions, assignments, and delegations.
|
||||||
3. Add explicit acting-in-place context selection; `act_in_place` delegation
|
3. Retrofit postbox, workflow, portal, and audit consumers to use identity,
|
||||||
facts are stored now but do not silently grant permissions without a selected
|
|
||||||
acting context.
|
|
||||||
4. Retrofit postbox, workflow, portal, and audit consumers to use identity,
|
|
||||||
organization, and access explanation capabilities rather than local access
|
organization, and access explanation capabilities rather than local access
|
||||||
assumptions.
|
assumptions.
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Access interface pattern migration
|
||||||
|
|
||||||
|
This document records the Access-owned surfaces covered by the platform
|
||||||
|
interface pattern language. Shared primitives remain owned by Core and
|
||||||
|
optional Mail, Files, Organizations, IDM, and Docs behavior is consumed only
|
||||||
|
through declared capabilities or metadata.
|
||||||
|
|
||||||
|
## Surface inventory
|
||||||
|
|
||||||
|
| Surface | Archetype | Authority and state model |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/admin` tree and unavailable state | Tree-navigated administration workspace | The effective principal and active View determine which branches exist. A missing administration grant is an explained blocker, not an empty route. |
|
||||||
|
| System and tenant users | Server-authoritative directory plus list/detail editor | Delta reads refresh accounts and memberships. Create, update, assignment, suspension, and final-owner safeguards remain independent permissions. |
|
||||||
|
| System and tenant roles | Governed definition directory | Built-in and system-managed definitions remain visible but immutable. Assigned roles cannot be deleted. |
|
||||||
|
| Tenant groups | Governed definition and membership editor | Definition, membership, and role-assignment rights remain independent. Required system groups cannot be deactivated. |
|
||||||
|
| Tenant API keys | Immutable-secret lifecycle directory | A key is created once, its secret is shown once, and revocation is consequential and confirmed. |
|
||||||
|
| Function mappings | Governed cross-module mapping editor | Organizations supplies function choices, IDM supplies accepted facts, and Access maps facts to assignable roles. |
|
||||||
|
| Credential scopes | Adaptive configuration panel | Core owns the reusable credential manager. Access supplies system, tenant, group, and user ownership choices. |
|
||||||
|
| Mail and Files scope panels | Optional capability host | Access supplies owner selection; the owning module supplies configuration UI. A missing capability names the required module, actor, and destination. |
|
||||||
|
|
||||||
|
## Consequence classes
|
||||||
|
|
||||||
|
- Reload, inspect, filter, select, and open-help actions are reversible.
|
||||||
|
- User, group, role, mapping, and credential edits are governed mutations and
|
||||||
|
expose permission or validation blockers before submission.
|
||||||
|
- Account or membership deactivation, group deactivation, role deletion,
|
||||||
|
mapping deletion, API-key revocation, and credential deletion are
|
||||||
|
consequential actions and use the shared confirmation contract.
|
||||||
|
- Secret values and temporary passwords are never placed in list rows or
|
||||||
|
persistent notices. One-time values remain inside dedicated dialogs.
|
||||||
|
|
||||||
|
## Interaction evidence
|
||||||
|
|
||||||
|
- `WorkspaceLayout`, headerless `PageLayout`, `AdminPageLayout`, `TreeSubnav`,
|
||||||
|
`DataGrid`, `Dialog`, `ConfirmDialog`,
|
||||||
|
`TableActionGroup`, `PasswordField`, `ActionBlockerHint`, and
|
||||||
|
`DocumentationHelpLink` come from Core.
|
||||||
|
- The administration tree and its contributed panels now share Core-owned pane
|
||||||
|
sizing, scrolling, content inset, responsive collapse, region labels, and
|
||||||
|
contextual-help identity; Access no longer carries a raw workspace or page
|
||||||
|
frame exception.
|
||||||
|
- Dialog focus trapping and restoration, disabled-action tooltips, keyboard
|
||||||
|
ordering, responsive grid overflow, and alert semantics therefore inherit
|
||||||
|
the tested Core behavior.
|
||||||
|
- All Access-owned labels added by this migration are present in the English
|
||||||
|
and German module catalogs.
|
||||||
|
- The WebUI structural test rejects browser-native confirmation calls, private
|
||||||
|
sibling imports, missing contextual-help references, and unexplained
|
||||||
|
optional-module blockers.
|
||||||
|
|
||||||
|
## Documentation contexts
|
||||||
|
|
||||||
|
- `access.workflow.grant-user-access` covers the user, group, and role path.
|
||||||
|
- `access.reference.admin-access-fields` covers accounts, roles, API keys, and
|
||||||
|
their backing administration fields.
|
||||||
|
- `access.workflow.manage-api-keys` owns exact help for accountable ownership,
|
||||||
|
bounded scopes and expiry, one-time secret custody, and immediate revocation.
|
||||||
|
- `access.workflow.manage-service-account-credentials` owns exact help for the
|
||||||
|
account ceiling, activation state, credential rotation/revocation, one-time
|
||||||
|
secret custody, concurrency, and retirement consequences.
|
||||||
|
- `access.reference.external-function-role-mappings` explains the
|
||||||
|
Organizations, IDM, and Access responsibility split.
|
||||||
|
- Files and Mail blockers link to documentation supplied by the owning module.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Service accounts
|
||||||
|
|
||||||
|
Service accounts are tenant-owned, non-login principals for automation. Their
|
||||||
|
backing account and membership cannot use a password or browser session.
|
||||||
|
|
||||||
|
## Authorization model
|
||||||
|
|
||||||
|
The service account defines a revisioned scope ceiling. Every credential has
|
||||||
|
its own narrower scope grant. On every authenticated request, Access checks
|
||||||
|
that the tenant, service account, backing account, membership, and credential
|
||||||
|
are active, then grants only the intersection of the current ceiling and the
|
||||||
|
credential scopes. Reducing the ceiling therefore takes effect without
|
||||||
|
reissuing a credential.
|
||||||
|
|
||||||
|
Administrators may grant only scopes they currently hold. Credential creation
|
||||||
|
also follows the tenant API-key governance switch. Secrets are returned once;
|
||||||
|
the database stores a one-way hash and a non-authenticating prefix.
|
||||||
|
|
||||||
|
## Administration
|
||||||
|
|
||||||
|
Open `Admin > Tenant > Service accounts` to create, edit, deactivate, activate,
|
||||||
|
or retire a principal. The detail dialog lists active, expired, and revoked
|
||||||
|
credentials and exposes create, rotate, and revoke actions.
|
||||||
|
|
||||||
|
Every write includes `expected_revision`. A concurrent change returns `409`
|
||||||
|
and the UI reloads the account before another action. Rotation creates the new
|
||||||
|
credential and revokes the old one in a single transaction. Retirement
|
||||||
|
deactivates the principal and revokes all active credentials.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `GET/POST /api/v1/admin/service-accounts`
|
||||||
|
- `GET/PATCH /api/v1/admin/service-accounts/{service_account_id}`
|
||||||
|
- `POST /api/v1/admin/service-accounts/{service_account_id}/retire`
|
||||||
|
- `GET/POST /api/v1/admin/service-accounts/{service_account_id}/credentials`
|
||||||
|
- `POST /api/v1/admin/service-accounts/{service_account_id}/credentials/{credential_id}/rotate`
|
||||||
|
- `POST /api/v1/admin/service-accounts/{service_account_id}/credentials/{credential_id}/revoke`
|
||||||
|
|
||||||
|
Credential list responses never contain a secret. Create and rotate responses
|
||||||
|
contain it once. Audit records include identifiers, prefixes, scopes, and the
|
||||||
|
new service-account revision, but never the secret or its hash.
|
||||||
|
|
||||||
|
## Contextual help
|
||||||
|
|
||||||
|
F1 on the service-account page, its editors, scope controls, one-time secret,
|
||||||
|
rotation and revocation actions, activation state, or retirement confirmation
|
||||||
|
resolves to the Access-owned `access.workflow.manage-service-account-credentials`
|
||||||
|
topic. The German reference content distinguishes reversible deactivation from
|
||||||
|
retirement, explains immediate client impact, and states that secrets cannot be
|
||||||
|
recovered. Tenant API-key controls resolve separately to
|
||||||
|
`access.workflow.manage-api-keys`, because their effective authorization also
|
||||||
|
depends on the accountable human owner's current permissions.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Session And Device Management
|
||||||
|
|
||||||
|
Authenticated users can inspect their active browser sessions under **Settings
|
||||||
|
> Sessions and devices**. Each row exposes only a stable session identifier,
|
||||||
|
current-session marker, bounded user-agent label, creation time, last activity,
|
||||||
|
expiry, and lifecycle state. Session tokens, token and CSRF hashes, cookies, IP
|
||||||
|
addresses, and unrelated request metadata are never returned.
|
||||||
|
|
||||||
|
Users may revoke one other session or all other active sessions. The current
|
||||||
|
session is deliberately protected by these operations; use normal logout to end
|
||||||
|
it. Repeating a revocation is safe. Revoked sessions fail authentication on the
|
||||||
|
next request, including when a principal summary was previously cached.
|
||||||
|
|
||||||
|
Tenant administrators may list sessions only for a membership in their governed
|
||||||
|
tenant and may revoke only a session belonging to that membership and tenant.
|
||||||
|
The mutation requires both the central membership-update permission and an
|
||||||
|
interactive-session password re-authorization. API-key administration and
|
||||||
|
cross-tenant session disclosure fail closed.
|
||||||
|
|
||||||
|
Audit events retain the actor, target session or account, action, and revoked
|
||||||
|
count where applicable. They do not copy client labels, network addresses, or
|
||||||
|
credentials. Expired and revoked sessions are retained according to Access data
|
||||||
|
retention and are omitted from the active-session list.
|
||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/access-webui",
|
"name": "@govoplan/access-webui",
|
||||||
"version": "0.1.11",
|
"version": "0.1.24",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -18,11 +18,11 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.11",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router": ">=8.3.0 <9"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-access"
|
name = "govoplan-access"
|
||||||
version = "0.1.11"
|
version = "0.1.24"
|
||||||
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
|
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.11",
|
"govoplan-core>=0.1.43",
|
||||||
"redis>=5,<6",
|
"redis>=5,<6",
|
||||||
"SQLAlchemy>=2,<3",
|
"SQLAlchemy>=2,<3",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN access platform module."""
|
"""GovOPlaN access platform module."""
|
||||||
|
|
||||||
__version__ = "0.1.11"
|
__version__ = "0.1.24"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Iterable, Mapping, Sequence
|
from collections.abc import Iterable, Mapping, Sequence
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import case, func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_access.backend.db.models import Account, ApiKey, Group, Role, User
|
from govoplan_access.backend.db.models import Account, ApiKey, Group, Role, User
|
||||||
@@ -12,14 +12,99 @@ from govoplan_core.core.access import AccessAdministration
|
|||||||
class SqlAccessAdministration(AccessAdministration):
|
class SqlAccessAdministration(AccessAdministration):
|
||||||
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
|
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
|
||||||
db = _session(session)
|
db = _session(session)
|
||||||
|
users, active_users = (
|
||||||
|
db.query(
|
||||||
|
func.count(User.id),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(case((User.is_active.is_(True), 1), else_=0)),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter(User.tenant_id == tenant_id)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
api_keys, active_api_keys = (
|
||||||
|
db.query(
|
||||||
|
func.count(ApiKey.id),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(case((ApiKey.revoked_at.is_(None), 1), else_=0)),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter(ApiKey.tenant_id == tenant_id)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"users": db.query(User).filter(User.tenant_id == tenant_id).count(),
|
"users": int(users),
|
||||||
"active_users": db.query(User).filter(User.tenant_id == tenant_id, User.is_active.is_(True)).count(),
|
"active_users": int(active_users),
|
||||||
"groups": db.query(Group).filter(Group.tenant_id == tenant_id).count(),
|
"groups": db.query(Group).filter(Group.tenant_id == tenant_id).count(),
|
||||||
"api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id).count(),
|
"api_keys": int(api_keys),
|
||||||
"active_api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(),
|
"active_api_keys": int(active_api_keys),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def tenant_counts_many(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
tenant_ids: Sequence[str],
|
||||||
|
) -> Mapping[str, Mapping[str, int]]:
|
||||||
|
ids = tuple(dict.fromkeys(str(tenant_id) for tenant_id in tenant_ids if tenant_id))
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
db = _session(session)
|
||||||
|
counts: dict[str, dict[str, int]] = {
|
||||||
|
tenant_id: {
|
||||||
|
"users": 0,
|
||||||
|
"active_users": 0,
|
||||||
|
"groups": 0,
|
||||||
|
"api_keys": 0,
|
||||||
|
"active_api_keys": 0,
|
||||||
|
}
|
||||||
|
for tenant_id in ids
|
||||||
|
}
|
||||||
|
user_rows = (
|
||||||
|
db.query(
|
||||||
|
User.tenant_id,
|
||||||
|
func.count(User.id),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(case((User.is_active.is_(True), 1), else_=0)),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter(User.tenant_id.in_(ids))
|
||||||
|
.group_by(User.tenant_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for tenant_id, users, active_users in user_rows:
|
||||||
|
counts[tenant_id]["users"] = int(users)
|
||||||
|
counts[tenant_id]["active_users"] = int(active_users)
|
||||||
|
|
||||||
|
group_rows = (
|
||||||
|
db.query(Group.tenant_id, func.count(Group.id))
|
||||||
|
.filter(Group.tenant_id.in_(ids))
|
||||||
|
.group_by(Group.tenant_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for tenant_id, groups in group_rows:
|
||||||
|
counts[tenant_id]["groups"] = int(groups)
|
||||||
|
|
||||||
|
api_key_rows = (
|
||||||
|
db.query(
|
||||||
|
ApiKey.tenant_id,
|
||||||
|
func.count(ApiKey.id),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(case((ApiKey.revoked_at.is_(None), 1), else_=0)),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter(ApiKey.tenant_id.in_(ids))
|
||||||
|
.group_by(ApiKey.tenant_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for tenant_id, api_keys, active_api_keys in api_key_rows:
|
||||||
|
counts[tenant_id]["api_keys"] = int(api_keys)
|
||||||
|
counts[tenant_id]["active_api_keys"] = int(active_api_keys)
|
||||||
|
return counts
|
||||||
|
|
||||||
def system_account_count(self, session: object) -> int:
|
def system_account_count(self, session: object) -> int:
|
||||||
db = _session(session)
|
db = _session(session)
|
||||||
return db.query(Account).count()
|
return db.query(Account).count()
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
|||||||
)
|
)
|
||||||
from govoplan_access.backend.security.sessions import (
|
from govoplan_access.backend.security.sessions import (
|
||||||
collect_direct_user_roles,
|
collect_direct_user_roles,
|
||||||
collect_system_roles,
|
|
||||||
collect_user_groups,
|
collect_user_groups,
|
||||||
collect_user_scopes,
|
collect_user_scopes,
|
||||||
)
|
)
|
||||||
@@ -48,7 +47,11 @@ from govoplan_access.backend.db.models import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||||
from govoplan_core.core.organizations import OrganizationDirectory
|
from govoplan_core.core.organizations import OrganizationDirectory
|
||||||
from govoplan_access.backend.permissions.catalog import effective_permission_count, expand_scopes
|
from govoplan_access.backend.permissions.catalog import (
|
||||||
|
effective_permission_count,
|
||||||
|
expand_scopes,
|
||||||
|
scopes_grant,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _http_admin_error(exc: Exception) -> HTTPException:
|
def _http_admin_error(exc: Exception) -> HTTPException:
|
||||||
@@ -358,44 +361,249 @@ def _user_item(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
|
def _system_membership_rows(
|
||||||
memberships = (
|
session: Session,
|
||||||
|
account_ids: list[str],
|
||||||
|
) -> list[tuple[User, Tenant]]:
|
||||||
|
return (
|
||||||
session.query(User, Tenant)
|
session.query(User, Tenant)
|
||||||
.join(Tenant, Tenant.id == User.tenant_id)
|
.join(Tenant, Tenant.id == User.tenant_id)
|
||||||
.filter(User.account_id == account.id)
|
.filter(User.account_id.in_(account_ids))
|
||||||
.order_by(Tenant.name.asc())
|
.order_by(User.account_id.asc(), Tenant.name.asc(), User.id.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
owner_ids_by_tenant = {
|
|
||||||
tenant.id: tenant_owner_user_ids(session, tenant.id)
|
|
||||||
for _, tenant in memberships
|
def _memberships_by_account(
|
||||||
|
membership_rows: list[tuple[User, Tenant]],
|
||||||
|
) -> dict[str, list[tuple[User, Tenant]]]:
|
||||||
|
memberships_by_account: dict[str, list[tuple[User, Tenant]]] = defaultdict(list)
|
||||||
|
for user, tenant in membership_rows:
|
||||||
|
memberships_by_account[user.account_id].append((user, tenant))
|
||||||
|
return memberships_by_account
|
||||||
|
|
||||||
|
|
||||||
|
def _system_direct_roles_by_user(
|
||||||
|
session: Session,
|
||||||
|
user_ids: list[str],
|
||||||
|
) -> dict[str, list[Role]]:
|
||||||
|
roles_by_user: dict[str, list[Role]] = defaultdict(list)
|
||||||
|
if not user_ids:
|
||||||
|
return roles_by_user
|
||||||
|
rows = (
|
||||||
|
session.query(UserRoleAssignment.user_id, Role)
|
||||||
|
.join(Role, Role.id == UserRoleAssignment.role_id)
|
||||||
|
.filter(UserRoleAssignment.user_id.in_(user_ids))
|
||||||
|
.order_by(UserRoleAssignment.user_id.asc(), Role.name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for user_id, role in rows:
|
||||||
|
roles_by_user[user_id].append(role)
|
||||||
|
return roles_by_user
|
||||||
|
|
||||||
|
|
||||||
|
def _system_groups_by_user(
|
||||||
|
session: Session,
|
||||||
|
user_ids: list[str],
|
||||||
|
) -> dict[str, list[Group]]:
|
||||||
|
groups_by_user: dict[str, list[Group]] = defaultdict(list)
|
||||||
|
if not user_ids:
|
||||||
|
return groups_by_user
|
||||||
|
rows = (
|
||||||
|
session.query(UserGroupMembership.user_id, Group)
|
||||||
|
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||||
|
.filter(
|
||||||
|
UserGroupMembership.user_id.in_(user_ids),
|
||||||
|
Group.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(UserGroupMembership.user_id.asc(), Group.name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for user_id, group in rows:
|
||||||
|
groups_by_user[user_id].append(group)
|
||||||
|
return groups_by_user
|
||||||
|
|
||||||
|
|
||||||
|
def _system_group_roles_by_user(
|
||||||
|
session: Session,
|
||||||
|
user_ids: list[str],
|
||||||
|
) -> dict[str, list[Role]]:
|
||||||
|
group_roles_by_user: dict[str, list[Role]] = defaultdict(list)
|
||||||
|
if not user_ids:
|
||||||
|
return group_roles_by_user
|
||||||
|
rows = (
|
||||||
|
session.query(UserGroupMembership.user_id, Role)
|
||||||
|
.join(
|
||||||
|
GroupRoleAssignment,
|
||||||
|
GroupRoleAssignment.group_id == UserGroupMembership.group_id,
|
||||||
|
)
|
||||||
|
.join(Role, Role.id == GroupRoleAssignment.role_id)
|
||||||
|
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||||
|
.filter(
|
||||||
|
UserGroupMembership.user_id.in_(user_ids),
|
||||||
|
Group.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for user_id, role in rows:
|
||||||
|
group_roles_by_user[user_id].append(role)
|
||||||
|
return group_roles_by_user
|
||||||
|
|
||||||
|
|
||||||
|
def _system_owner_ids_by_tenant(
|
||||||
|
membership_rows: list[tuple[User, Tenant]],
|
||||||
|
*,
|
||||||
|
accounts_by_id: dict[str, Account],
|
||||||
|
direct_roles_by_user: dict[str, list[Role]],
|
||||||
|
group_roles_by_user: dict[str, list[Role]],
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
owner_ids_by_tenant: dict[str, set[str]] = defaultdict(set)
|
||||||
|
for user, tenant in membership_rows:
|
||||||
|
account = accounts_by_id[user.account_id]
|
||||||
|
if not user.is_active or not account.is_active:
|
||||||
|
continue
|
||||||
|
effective_permissions = [
|
||||||
|
permission
|
||||||
|
for role in direct_roles_by_user[user.id] + group_roles_by_user[user.id]
|
||||||
|
for permission in (role.permissions or [])
|
||||||
|
]
|
||||||
|
if (
|
||||||
|
scopes_grant(effective_permissions, "admin:roles:write")
|
||||||
|
and scopes_grant(effective_permissions, "campaign:send")
|
||||||
|
):
|
||||||
|
owner_ids_by_tenant[tenant.id].add(user.id)
|
||||||
|
return owner_ids_by_tenant
|
||||||
|
|
||||||
|
|
||||||
|
def _system_roles_for_accounts(
|
||||||
|
session: Session,
|
||||||
|
account_ids: list[str],
|
||||||
|
) -> tuple[dict[str, list[Role]], dict[str, int]]:
|
||||||
|
system_roles_by_account: dict[str, list[Role]] = defaultdict(list)
|
||||||
|
system_role_ids: set[str] = set()
|
||||||
|
rows = (
|
||||||
|
session.query(SystemRoleAssignment.account_id, Role)
|
||||||
|
.join(Role, Role.id == SystemRoleAssignment.role_id)
|
||||||
|
.filter(
|
||||||
|
SystemRoleAssignment.account_id.in_(account_ids),
|
||||||
|
Role.tenant_id.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(SystemRoleAssignment.account_id.asc(), Role.name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for account_id, role in rows:
|
||||||
|
system_roles_by_account[account_id].append(role)
|
||||||
|
system_role_ids.add(role.id)
|
||||||
|
return (
|
||||||
|
system_roles_by_account,
|
||||||
|
_system_role_assignment_counts(session, sorted(system_role_ids)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _system_membership_item(
|
||||||
|
user: User,
|
||||||
|
tenant: Tenant,
|
||||||
|
*,
|
||||||
|
roles_by_user: dict[str, list[Role]],
|
||||||
|
groups_by_user: dict[str, list[Group]],
|
||||||
|
owner_ids_by_tenant: dict[str, set[str]],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
tenant_owner_ids = owner_ids_by_tenant[tenant.id]
|
||||||
|
return {
|
||||||
|
"tenant_id": tenant.id,
|
||||||
|
"tenant_name": tenant.name,
|
||||||
|
"user_id": user.id,
|
||||||
|
"is_active": user.is_active and tenant.is_active,
|
||||||
|
"role_ids": [role.id for role in roles_by_user[user.id]],
|
||||||
|
"group_ids": [group.id for group in groups_by_user[user.id]],
|
||||||
|
"is_owner": user.id in tenant_owner_ids,
|
||||||
|
"is_last_active_owner": (
|
||||||
|
user.id in tenant_owner_ids and len(tenant_owner_ids) == 1
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _system_account_response_item(
|
||||||
|
session: Session,
|
||||||
|
account: Account,
|
||||||
|
*,
|
||||||
|
memberships: list[tuple[User, Tenant]],
|
||||||
|
roles_by_user: dict[str, list[Role]],
|
||||||
|
groups_by_user: dict[str, list[Group]],
|
||||||
|
owner_ids_by_tenant: dict[str, set[str]],
|
||||||
|
system_roles: list[Role],
|
||||||
|
system_role_counts: dict[str, int],
|
||||||
|
) -> SystemAccountItem:
|
||||||
return SystemAccountItem(
|
return SystemAccountItem(
|
||||||
account_id=account.id,
|
account_id=account.id,
|
||||||
email=account.email,
|
email=account.email,
|
||||||
display_name=account.display_name,
|
display_name=account.display_name,
|
||||||
is_active=account.is_active,
|
is_active=account.is_active,
|
||||||
memberships=[
|
memberships=[
|
||||||
{
|
_system_membership_item(
|
||||||
"tenant_id": tenant.id,
|
user,
|
||||||
"tenant_name": tenant.name,
|
tenant,
|
||||||
"user_id": user.id,
|
roles_by_user=roles_by_user,
|
||||||
"is_active": user.is_active and tenant.is_active,
|
groups_by_user=groups_by_user,
|
||||||
"role_ids": [role.id for role in collect_direct_user_roles(session, user)],
|
owner_ids_by_tenant=owner_ids_by_tenant,
|
||||||
"group_ids": [group.id for group in collect_user_groups(session, user)],
|
)
|
||||||
"is_owner": user.id in owner_ids_by_tenant[tenant.id],
|
|
||||||
"is_last_active_owner": (
|
|
||||||
user.id in owner_ids_by_tenant[tenant.id]
|
|
||||||
and len(owner_ids_by_tenant[tenant.id]) == 1
|
|
||||||
),
|
|
||||||
}
|
|
||||||
for user, tenant in memberships
|
for user, tenant in memberships
|
||||||
],
|
],
|
||||||
roles=[_role_summary(session, role) for role in collect_system_roles(session, account)],
|
roles=[
|
||||||
|
_role_summary(
|
||||||
|
session,
|
||||||
|
role,
|
||||||
|
system_role_assignment_counts=system_role_counts,
|
||||||
|
)
|
||||||
|
for role in system_roles
|
||||||
|
],
|
||||||
last_login_at=account.last_login_at,
|
last_login_at=account.last_login_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _system_account_items(
|
||||||
|
session: Session,
|
||||||
|
accounts: list[Account],
|
||||||
|
) -> list[SystemAccountItem]:
|
||||||
|
if not accounts:
|
||||||
|
return []
|
||||||
|
account_ids = [account.id for account in accounts]
|
||||||
|
accounts_by_id = {account.id: account for account in accounts}
|
||||||
|
membership_rows = _system_membership_rows(session, account_ids)
|
||||||
|
memberships_by_account = _memberships_by_account(membership_rows)
|
||||||
|
user_ids = [user.id for user, _tenant in membership_rows]
|
||||||
|
roles_by_user = _system_direct_roles_by_user(session, user_ids)
|
||||||
|
groups_by_user = _system_groups_by_user(session, user_ids)
|
||||||
|
group_roles_by_user = _system_group_roles_by_user(session, user_ids)
|
||||||
|
owner_ids_by_tenant = _system_owner_ids_by_tenant(
|
||||||
|
membership_rows,
|
||||||
|
accounts_by_id=accounts_by_id,
|
||||||
|
direct_roles_by_user=roles_by_user,
|
||||||
|
group_roles_by_user=group_roles_by_user,
|
||||||
|
)
|
||||||
|
system_roles_by_account, system_role_counts = _system_roles_for_accounts(
|
||||||
|
session,
|
||||||
|
account_ids,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
_system_account_response_item(
|
||||||
|
session,
|
||||||
|
account,
|
||||||
|
memberships=memberships_by_account[account.id],
|
||||||
|
roles_by_user=roles_by_user,
|
||||||
|
groups_by_user=groups_by_user,
|
||||||
|
owner_ids_by_tenant=owner_ids_by_tenant,
|
||||||
|
system_roles=system_roles_by_account[account.id],
|
||||||
|
system_role_counts=system_role_counts,
|
||||||
|
)
|
||||||
|
for account in accounts
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
|
||||||
|
return _system_account_items(session, [account])[0]
|
||||||
|
|
||||||
|
|
||||||
def _api_key_item(session: Session, item: ApiKey, *, accounts_by_user_id: dict[str, Account] | None = None) -> ApiKeyAdminItem:
|
def _api_key_item(session: Session, item: ApiKey, *, accounts_by_user_id: dict[str, Account] | None = None) -> ApiKeyAdminItem:
|
||||||
if accounts_by_user_id is not None:
|
if accounts_by_user_id is not None:
|
||||||
account = accounts_by_user_id.get(item.user_id)
|
account = accounts_by_user_id.get(item.user_id)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any, Literal
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||||
|
from govoplan_core.i18n import REFERENCE_LANGUAGE_CODE
|
||||||
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem
|
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem
|
||||||
|
|
||||||
|
|
||||||
@@ -36,12 +37,43 @@ class AdminOverviewResponse(BaseModel):
|
|||||||
capabilities: list[str] = Field(default_factory=list)
|
capabilities: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSessionItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
current: bool
|
||||||
|
status: Literal["active", "expired", "revoked"]
|
||||||
|
created_at: datetime
|
||||||
|
last_seen_at: datetime | None = None
|
||||||
|
expires_at: datetime
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
client: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSessionListResponse(BaseModel):
|
||||||
|
sessions: list[AdminSessionItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSessionRevocationRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
current_password: str = Field(min_length=1, max_length=1024)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSessionRevocationResponse(BaseModel):
|
||||||
|
session: AdminSessionItem
|
||||||
|
revoked: bool
|
||||||
|
|
||||||
|
|
||||||
class TenantAdminItem(BaseModel):
|
class TenantAdminItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
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
|
||||||
@@ -81,7 +113,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
|
||||||
@@ -105,7 +137,11 @@ 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)
|
||||||
@@ -193,7 +229,7 @@ class OrganizationUnitItem(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class OrganizationUnitListResponse(BaseModel):
|
class OrganizationUnitListResponse(PagedListResponse):
|
||||||
organization_units: list[OrganizationUnitItem]
|
organization_units: list[OrganizationUnitItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -232,7 +268,7 @@ class FunctionAdminItem(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class FunctionListResponse(BaseModel):
|
class FunctionListResponse(PagedListResponse):
|
||||||
functions: list[FunctionAdminItem]
|
functions: list[FunctionAdminItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -247,7 +283,7 @@ class ExternalFunctionRoleMappingItem(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class ExternalFunctionRoleMappingListResponse(BaseModel):
|
class ExternalFunctionRoleMappingListResponse(PagedListResponse):
|
||||||
mappings: list[ExternalFunctionRoleMappingItem]
|
mappings: list[ExternalFunctionRoleMappingItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -318,7 +354,7 @@ class FunctionAssignmentAdminItem(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class FunctionAssignmentListResponse(BaseModel):
|
class FunctionAssignmentListResponse(PagedListResponse):
|
||||||
assignments: list[FunctionAssignmentAdminItem]
|
assignments: list[FunctionAssignmentAdminItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -368,7 +404,7 @@ class FunctionDelegationAdminItem(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class FunctionDelegationListResponse(BaseModel):
|
class FunctionDelegationListResponse(PagedListResponse):
|
||||||
delegations: list[FunctionDelegationAdminItem]
|
delegations: list[FunctionDelegationAdminItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -505,6 +541,21 @@ class ResourceAccessExplanationResponse(BaseModel):
|
|||||||
provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list)
|
provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceAccessExplanationSubjectItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
email: str | None = None
|
||||||
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceAccessExplanationSubjectsResponse(BaseModel):
|
||||||
|
mode: Literal["current_user", "cross_user"]
|
||||||
|
can_select_other_users: bool
|
||||||
|
reason: str
|
||||||
|
source: str
|
||||||
|
required_scope: str | None = None
|
||||||
|
users: list[ResourceAccessExplanationSubjectItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class UserListResponse(PagedListResponse):
|
class UserListResponse(PagedListResponse):
|
||||||
users: list[UserAdminItem]
|
users: list[UserAdminItem]
|
||||||
|
|
||||||
@@ -799,6 +850,7 @@ class ConfigurationPackageApplyResponse(BaseModel):
|
|||||||
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
created_refs: dict[str, str] = Field(default_factory=dict)
|
created_refs: dict[str, str] = Field(default_factory=dict)
|
||||||
updated_refs: dict[str, str] = Field(default_factory=dict)
|
updated_refs: dict[str, str] = Field(default_factory=dict)
|
||||||
|
rollback: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ConfigurationPackageExportRequest(BaseModel):
|
class ConfigurationPackageExportRequest(BaseModel):
|
||||||
@@ -814,10 +866,11 @@ class ConfigurationPackageExportResponse(BaseModel):
|
|||||||
fragments: list[dict[str, Any]] = Field(default_factory=list)
|
fragments: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
data_requirements: list[dict[str, Any]] = Field(default_factory=list)
|
data_requirements: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
provenance: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class SystemSettingsItem(BaseModel):
|
class SystemSettingsItem(BaseModel):
|
||||||
default_locale: str = "en"
|
default_locale: str = REFERENCE_LANGUAGE_CODE
|
||||||
allow_tenant_custom_groups: bool = True
|
allow_tenant_custom_groups: bool = True
|
||||||
allow_tenant_custom_roles: bool = True
|
allow_tenant_custom_roles: bool = True
|
||||||
allow_tenant_api_keys: bool = True
|
allow_tenant_api_keys: bool = True
|
||||||
@@ -876,6 +929,113 @@ class AdminApiKeyCreateResponse(ApiKeyAdminItem):
|
|||||||
secret: str
|
secret: str
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
scope_ceiling: list[str] = Field(default_factory=list)
|
||||||
|
is_active: bool
|
||||||
|
revision: int
|
||||||
|
created_by_account_id: str | None = None
|
||||||
|
updated_by_account_id: str | None = None
|
||||||
|
retired_at: datetime | None = None
|
||||||
|
credential_count: int = 0
|
||||||
|
active_credential_count: int = 0
|
||||||
|
last_credential_used_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountListResponse(BaseModel):
|
||||||
|
items: list[ServiceAccountItem]
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=4000)
|
||||||
|
scope_ceiling: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountUpdateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=4000)
|
||||||
|
scope_ceiling: list[str] | None = Field(
|
||||||
|
default=None,
|
||||||
|
max_length=200,
|
||||||
|
)
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountRetireRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountCredentialItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
prefix: str
|
||||||
|
scopes: list[str] = Field(default_factory=list)
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
last_used_at: datetime | None = None
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountCredentialListResponse(BaseModel):
|
||||||
|
service_account_revision: int
|
||||||
|
items: list[ServiceAccountCredentialItem]
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountCredentialCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
scopes: list[str] = Field(min_length=1, max_length=200)
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountCredentialRotateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
scopes: list[str] | None = Field(
|
||||||
|
default=None,
|
||||||
|
min_length=1,
|
||||||
|
max_length=200,
|
||||||
|
)
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountCredentialRevokeRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountCredentialMutationResponse(BaseModel):
|
||||||
|
service_account_revision: int
|
||||||
|
credential: ServiceAccountCredentialItem
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountCredentialSecretResponse(
|
||||||
|
ServiceAccountCredentialMutationResponse
|
||||||
|
):
|
||||||
|
secret: str
|
||||||
|
|
||||||
|
|
||||||
class AuditAdminItem(BaseModel):
|
class AuditAdminItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
scope: Literal["tenant", "system"] = "tenant"
|
scope: Literal["tenant", "system"] = "tenant"
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import datetime
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.api.v1.schemas import (
|
from govoplan_core.api.v1.schemas import (
|
||||||
@@ -13,6 +16,7 @@ from govoplan_core.api.v1.schemas import (
|
|||||||
AuthShellResponse,
|
AuthShellResponse,
|
||||||
AuthSessionResponse,
|
AuthSessionResponse,
|
||||||
AuthSessionUserInfo,
|
AuthSessionUserInfo,
|
||||||
|
EffectiveAppearanceInfo,
|
||||||
GroupInfo,
|
GroupInfo,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
@@ -21,15 +25,23 @@ from govoplan_core.api.v1.schemas import (
|
|||||||
ProfileUpdateRequest,
|
ProfileUpdateRequest,
|
||||||
RoleInfo,
|
RoleInfo,
|
||||||
SwitchTenantRequest,
|
SwitchTenantRequest,
|
||||||
|
SwitchActingContextRequest,
|
||||||
TenantInfo,
|
TenantInfo,
|
||||||
TenantMembershipInfo,
|
TenantMembershipInfo,
|
||||||
UserInfo,
|
UserInfo,
|
||||||
UserUiPreferences,
|
UserUiPreferences,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.access import AuthMethod, PrincipalRef
|
from govoplan_core.core.access import AuthMethod, PrincipalRef
|
||||||
|
from govoplan_core.core.appearance import resolve_effective_appearance
|
||||||
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
|
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||||
|
from govoplan_core.core.idm import (
|
||||||
|
CAPABILITY_IDM_DIRECTORY,
|
||||||
|
IdmDirectory,
|
||||||
|
OrganizationFunctionAssignmentRef,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, require_scope
|
||||||
from govoplan_core.admin.settings import get_system_settings
|
from govoplan_core.admin.settings import get_system_settings
|
||||||
from govoplan_core.audit.logging import audit_event
|
from govoplan_core.audit.logging import audit_event
|
||||||
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
||||||
@@ -57,6 +69,7 @@ from govoplan_access.backend.security.login_throttle import (
|
|||||||
LoginThrottleDecision,
|
LoginThrottleDecision,
|
||||||
build_login_throttle,
|
build_login_throttle,
|
||||||
)
|
)
|
||||||
|
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||||
from govoplan_access.backend.security.sessions import (
|
from govoplan_access.backend.security.sessions import (
|
||||||
authenticate_session_token,
|
authenticate_session_token,
|
||||||
collect_user_authorization_context,
|
collect_user_authorization_context,
|
||||||
@@ -68,10 +81,121 @@ from govoplan_access.backend.security.sessions import (
|
|||||||
create_auth_session,
|
create_auth_session,
|
||||||
verify_auth_session_csrf,
|
verify_auth_session_csrf,
|
||||||
)
|
)
|
||||||
|
from govoplan_access.backend.session_management import (
|
||||||
|
SessionSummary,
|
||||||
|
list_account_sessions,
|
||||||
|
revoke_account_session,
|
||||||
|
revoke_other_account_sessions,
|
||||||
|
session_summary,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class ActingContextInfo(BaseModel):
|
||||||
|
assignment_id: str
|
||||||
|
acting_for_account_id: str
|
||||||
|
function_id: str
|
||||||
|
organization_unit_id: str
|
||||||
|
valid_from: datetime | None = None
|
||||||
|
valid_until: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ActingContextListResponse(BaseModel):
|
||||||
|
contexts: list[ActingContextInfo] = Field(default_factory=list)
|
||||||
|
active_assignment_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AccountSessionInfo(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
current: bool
|
||||||
|
status: Literal["active", "expired", "revoked"]
|
||||||
|
created_at: datetime
|
||||||
|
last_seen_at: datetime | None = None
|
||||||
|
expires_at: datetime
|
||||||
|
revoked_at: datetime | None = None
|
||||||
|
client: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AccountSessionListResponse(BaseModel):
|
||||||
|
sessions: list[AccountSessionInfo] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountSessionRevocationResponse(BaseModel):
|
||||||
|
session: AccountSessionInfo
|
||||||
|
revoked: bool
|
||||||
|
|
||||||
|
|
||||||
|
class OtherSessionRevocationResponse(BaseModel):
|
||||||
|
revoked_count: int
|
||||||
|
|
||||||
|
|
||||||
|
def _account_session_info(item: SessionSummary) -> AccountSessionInfo:
|
||||||
|
return AccountSessionInfo(**asdict(item))
|
||||||
|
|
||||||
|
|
||||||
|
def _interactive_session(principal: ApiPrincipal) -> AuthSession:
|
||||||
|
if principal.auth_session is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Session management requires an interactive browser session.",
|
||||||
|
)
|
||||||
|
return principal.auth_session
|
||||||
|
|
||||||
|
|
||||||
|
def _acting_assignments(
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
||||||
|
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||||
|
if not isinstance(registry, PlatformRegistry) or not registry.has_capability(
|
||||||
|
CAPABILITY_IDM_DIRECTORY
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
directory = registry.require_capability(CAPABILITY_IDM_DIRECTORY)
|
||||||
|
if not isinstance(directory, IdmDirectory):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Invalid capability: {CAPABILITY_IDM_DIRECTORY}",
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
item
|
||||||
|
for item in directory.organization_function_assignments_for_account(
|
||||||
|
principal.account_id,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
)
|
||||||
|
if item.source == "acting_for"
|
||||||
|
and item.acting_for_account_id
|
||||||
|
and item.status == "active"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _acting_context_response(
|
||||||
|
assignments: tuple[OrganizationFunctionAssignmentRef, ...],
|
||||||
|
*,
|
||||||
|
active_assignment_id: str | None,
|
||||||
|
) -> ActingContextListResponse:
|
||||||
|
available_ids = {item.id for item in assignments}
|
||||||
|
return ActingContextListResponse(
|
||||||
|
contexts=[
|
||||||
|
ActingContextInfo(
|
||||||
|
assignment_id=item.id,
|
||||||
|
acting_for_account_id=str(item.acting_for_account_id),
|
||||||
|
function_id=item.function_id,
|
||||||
|
organization_unit_id=item.organization_unit_id,
|
||||||
|
valid_from=item.valid_from,
|
||||||
|
valid_until=item.valid_until,
|
||||||
|
)
|
||||||
|
for item in assignments
|
||||||
|
],
|
||||||
|
active_assignment_id=(
|
||||||
|
active_assignment_id if active_assignment_id in available_ids else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class AuthContext:
|
class AuthContext:
|
||||||
account: Account
|
account: Account
|
||||||
@@ -133,12 +257,23 @@ def _user_ui_preferences(settings_payload: object) -> UserUiPreferences:
|
|||||||
return UserUiPreferences()
|
return UserUiPreferences()
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_appearance_info(session: Session, *, tenant: Tenant, user: User) -> EffectiveAppearanceInfo:
|
||||||
|
system_item = get_system_settings(session)
|
||||||
|
decision = resolve_effective_appearance(
|
||||||
|
system_settings=system_item.settings,
|
||||||
|
tenant_settings=tenant.settings,
|
||||||
|
user_settings=user.settings,
|
||||||
|
)
|
||||||
|
return EffectiveAppearanceInfo.model_validate(decision.as_dict())
|
||||||
|
|
||||||
|
|
||||||
def _user_info(
|
def _user_info(
|
||||||
user: User,
|
user: User,
|
||||||
account: Account,
|
account: Account,
|
||||||
*,
|
*,
|
||||||
preferred_language: str | None = None,
|
preferred_language: str | None = None,
|
||||||
enabled_language_codes: list[str] | None = None,
|
enabled_language_codes: list[str] | None = None,
|
||||||
|
appearance: EffectiveAppearanceInfo | None = None,
|
||||||
) -> UserInfo:
|
) -> UserInfo:
|
||||||
return UserInfo(
|
return UserInfo(
|
||||||
id=user.id,
|
id=user.id,
|
||||||
@@ -151,6 +286,7 @@ def _user_info(
|
|||||||
preferred_language=preferred_language,
|
preferred_language=preferred_language,
|
||||||
enabled_language_codes=enabled_language_codes or [],
|
enabled_language_codes=enabled_language_codes or [],
|
||||||
ui_preferences=_user_ui_preferences(user.settings),
|
ui_preferences=_user_ui_preferences(user.settings),
|
||||||
|
appearance=appearance or EffectiveAppearanceInfo(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -471,7 +607,7 @@ def _shell_response(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
return AuthShellResponse(
|
return AuthShellResponse(
|
||||||
user=_user_info(user, account),
|
user=_user_info(user, account, appearance=_effective_appearance_info(session, tenant=tenant, user=user)),
|
||||||
tenant=active_tenant,
|
tenant=active_tenant,
|
||||||
active_tenant=active_tenant,
|
active_tenant=active_tenant,
|
||||||
tenants=memberships,
|
tenants=memberships,
|
||||||
@@ -484,6 +620,12 @@ def _shell_response(
|
|||||||
auth_method=auth_method, # type: ignore[arg-type]
|
auth_method=auth_method, # type: ignore[arg-type]
|
||||||
api_key_id=api_key.id if api_key else None,
|
api_key_id=api_key.id if api_key else None,
|
||||||
session_id=auth_session.id if auth_session else None,
|
session_id=auth_session.id if auth_session else None,
|
||||||
|
acting_assignment_id=(
|
||||||
|
auth_session.acting_assignment_id if auth_session else None
|
||||||
|
),
|
||||||
|
acting_for_account_id=(
|
||||||
|
auth_session.acting_for_account_id if auth_session else None
|
||||||
|
),
|
||||||
email=account.email,
|
email=account.email,
|
||||||
display_name=account.display_name or user.display_name,
|
display_name=account.display_name or user.display_name,
|
||||||
),
|
),
|
||||||
@@ -541,6 +683,7 @@ def _profile_response(session: Session, context: AuthContext) -> AuthProfileResp
|
|||||||
context.account,
|
context.account,
|
||||||
preferred_language=preferred_language,
|
preferred_language=preferred_language,
|
||||||
enabled_language_codes=user_enabled,
|
enabled_language_codes=user_enabled,
|
||||||
|
appearance=_effective_appearance_info(session, tenant=context.tenant, user=context.user),
|
||||||
),
|
),
|
||||||
tenant=active_tenant,
|
tenant=active_tenant,
|
||||||
active_tenant=active_tenant,
|
active_tenant=active_tenant,
|
||||||
@@ -603,6 +746,8 @@ def _me_response(
|
|||||||
api_key_id: str | None = None,
|
api_key_id: str | None = None,
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
service_account_id: str | None = None,
|
service_account_id: str | None = None,
|
||||||
|
acting_assignment_id: str | None = None,
|
||||||
|
acting_for_account_id: str | None = None,
|
||||||
include_system: bool = True,
|
include_system: bool = True,
|
||||||
include_all_memberships: bool = True,
|
include_all_memberships: bool = True,
|
||||||
identity_directory: IdentityDirectory | None = None,
|
identity_directory: IdentityDirectory | None = None,
|
||||||
@@ -653,7 +798,13 @@ def _me_response(
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
return MeResponse(
|
return MeResponse(
|
||||||
user=_user_info(user, account, preferred_language=preferred_language, enabled_language_codes=user_enabled),
|
user=_user_info(
|
||||||
|
user,
|
||||||
|
account,
|
||||||
|
preferred_language=preferred_language,
|
||||||
|
enabled_language_codes=user_enabled,
|
||||||
|
appearance=_effective_appearance_info(session, tenant=tenant, user=user),
|
||||||
|
),
|
||||||
tenant=active_tenant,
|
tenant=active_tenant,
|
||||||
active_tenant=active_tenant,
|
active_tenant=active_tenant,
|
||||||
tenants=memberships,
|
tenants=memberships,
|
||||||
@@ -675,6 +826,8 @@ def _me_response(
|
|||||||
api_key_id=api_key_id,
|
api_key_id=api_key_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
service_account_id=service_account_id,
|
service_account_id=service_account_id,
|
||||||
|
acting_assignment_id=acting_assignment_id,
|
||||||
|
acting_for_account_id=acting_for_account_id,
|
||||||
email=account.email,
|
email=account.email,
|
||||||
display_name=account.display_name or user.display_name,
|
display_name=account.display_name or user.display_name,
|
||||||
).to_dict()
|
).to_dict()
|
||||||
@@ -794,6 +947,8 @@ def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session =
|
|||||||
api_key_id=principal.api_key_id,
|
api_key_id=principal.api_key_id,
|
||||||
session_id=principal.session_id,
|
session_id=principal.session_id,
|
||||||
service_account_id=principal.principal.service_account_id,
|
service_account_id=principal.principal.service_account_id,
|
||||||
|
acting_assignment_id=principal.acting_assignment_id,
|
||||||
|
acting_for_account_id=principal.acting_for_account_id,
|
||||||
include_system=principal.auth_session is not None,
|
include_system=principal.auth_session is not None,
|
||||||
include_all_memberships=principal.auth_session is not None,
|
include_all_memberships=principal.auth_session is not None,
|
||||||
identity_id=principal.principal.identity_id,
|
identity_id=principal.principal.identity_id,
|
||||||
@@ -857,6 +1012,37 @@ def update_profile(
|
|||||||
if payload.ui_preferences is None:
|
if payload.ui_preferences is None:
|
||||||
next_settings["ui"] = UserUiPreferences().model_dump()
|
next_settings["ui"] = UserUiPreferences().model_dump()
|
||||||
else:
|
else:
|
||||||
|
appearance = _effective_appearance_info(session, tenant=context.tenant, user=context.user)
|
||||||
|
stored_preferences = _user_ui_preferences(context.user.settings)
|
||||||
|
stored_palette = stored_preferences.palette
|
||||||
|
if (
|
||||||
|
appearance.locked
|
||||||
|
and payload.ui_preferences.palette is not None
|
||||||
|
and payload.ui_preferences.palette != stored_palette
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="The effective appearance policy locks the palette.",
|
||||||
|
)
|
||||||
|
requested_overrides = payload.ui_preferences.appearance_overrides
|
||||||
|
if (
|
||||||
|
"appearance_overrides" in payload.ui_preferences.model_fields_set
|
||||||
|
and not appearance.custom_overrides_allowed
|
||||||
|
and requested_overrides is not None
|
||||||
|
and requested_overrides != stored_preferences.appearance_overrides
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="The effective appearance policy does not allow personal custom overrides.",
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
payload.ui_preferences.navigation is not None
|
||||||
|
and payload.ui_preferences.navigation.locked
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="Personal navigation preferences cannot lock entries.",
|
||||||
|
)
|
||||||
next_ui = _user_ui_preferences(next_settings).model_dump()
|
next_ui = _user_ui_preferences(next_settings).model_dump()
|
||||||
next_ui.update(payload.ui_preferences.model_dump(exclude_unset=True))
|
next_ui.update(payload.ui_preferences.model_dump(exclude_unset=True))
|
||||||
next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump()
|
next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump()
|
||||||
@@ -914,6 +1100,171 @@ def switch_tenant(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions", response_model=AccountSessionListResponse)
|
||||||
|
def list_own_sessions(
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("access:session:manage_own")),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> AccountSessionListResponse:
|
||||||
|
current = _interactive_session(principal)
|
||||||
|
items = list_account_sessions(
|
||||||
|
session,
|
||||||
|
account_id=principal.account_id,
|
||||||
|
current_session_id=current.id,
|
||||||
|
)
|
||||||
|
return AccountSessionListResponse(
|
||||||
|
sessions=[_account_session_info(item) for item in items]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sessions/revoke-others",
|
||||||
|
response_model=OtherSessionRevocationResponse,
|
||||||
|
)
|
||||||
|
def revoke_own_other_sessions(
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("access:session:manage_own")),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> OtherSessionRevocationResponse:
|
||||||
|
current = _interactive_session(principal)
|
||||||
|
revoked_ids = revoke_other_account_sessions(
|
||||||
|
session,
|
||||||
|
account_id=principal.account_id,
|
||||||
|
current_session_id=current.id,
|
||||||
|
)
|
||||||
|
if revoked_ids:
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
action="access.sessions.other_sessions_revoked",
|
||||||
|
object_type="access_account",
|
||||||
|
object_id=principal.account_id,
|
||||||
|
details={"revoked_count": len(revoked_ids)},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
return OtherSessionRevocationResponse(revoked_count=len(revoked_ids))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sessions/{session_id}/revoke",
|
||||||
|
response_model=AccountSessionRevocationResponse,
|
||||||
|
)
|
||||||
|
def revoke_own_session(
|
||||||
|
session_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("access:session:manage_own")),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> AccountSessionRevocationResponse:
|
||||||
|
current = _interactive_session(principal)
|
||||||
|
try:
|
||||||
|
item, changed = revoke_account_session(
|
||||||
|
session,
|
||||||
|
account_id=principal.account_id,
|
||||||
|
session_id=session_id,
|
||||||
|
protected_session_id=current.id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Session not found.",
|
||||||
|
)
|
||||||
|
if changed:
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
action="access.session.revoked",
|
||||||
|
object_type="access_auth_session",
|
||||||
|
object_id=item.id,
|
||||||
|
details={"actor_kind": "self"},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
return AccountSessionRevocationResponse(
|
||||||
|
session=_account_session_info(
|
||||||
|
session_summary(item, current_session_id=current.id)
|
||||||
|
),
|
||||||
|
revoked=changed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/acting-contexts", response_model=ActingContextListResponse)
|
||||||
|
def list_acting_contexts(
|
||||||
|
request: Request,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> ActingContextListResponse:
|
||||||
|
if principal.auth_session is None:
|
||||||
|
return ActingContextListResponse()
|
||||||
|
assignments = _acting_assignments(request, principal=principal)
|
||||||
|
return _acting_context_response(
|
||||||
|
assignments,
|
||||||
|
active_assignment_id=principal.auth_session.acting_assignment_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/switch-acting-context", response_model=ActingContextListResponse)
|
||||||
|
def switch_acting_context(
|
||||||
|
payload: SwitchActingContextRequest,
|
||||||
|
request: Request,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> ActingContextListResponse:
|
||||||
|
if principal.auth_session is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="API keys cannot select an acting context.",
|
||||||
|
)
|
||||||
|
assignments = _acting_assignments(request, principal=principal)
|
||||||
|
selected = next(
|
||||||
|
(item for item in assignments if item.id == payload.assignment_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if payload.assignment_id is not None and selected is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="The acting assignment is not currently available to this account.",
|
||||||
|
)
|
||||||
|
previous = principal.auth_session.acting_assignment_id
|
||||||
|
principal.auth_session.acting_assignment_id = selected.id if selected else None
|
||||||
|
principal.auth_session.acting_for_account_id = (
|
||||||
|
selected.acting_for_account_id if selected else None
|
||||||
|
)
|
||||||
|
principal.auth_session.last_seen_at = utc_now()
|
||||||
|
session.add(principal.auth_session)
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
action="access.acting_context.switched",
|
||||||
|
object_type="idm_function_assignment",
|
||||||
|
object_id=selected.id if selected else previous,
|
||||||
|
details={
|
||||||
|
"previous_assignment_id": previous,
|
||||||
|
"selected_assignment_id": selected.id if selected else None,
|
||||||
|
"acting_for_account_id": (
|
||||||
|
selected.acting_for_account_id if selected else None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
source_module="access",
|
||||||
|
resource_type="acting_context",
|
||||||
|
resource_id=principal.auth_session.id,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
return _acting_context_response(
|
||||||
|
assignments,
|
||||||
|
active_assignment_id=selected.id if selected else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
def logout(
|
def logout(
|
||||||
response: Response,
|
response: Response,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import asdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_access.backend.admin.governance import (
|
from govoplan_access.backend.admin.governance import (
|
||||||
@@ -57,10 +59,15 @@ from govoplan_access.backend.api.v1.admin_common import (
|
|||||||
_set_system_memberships,
|
_set_system_memberships,
|
||||||
_system_role_assignment_counts,
|
_system_role_assignment_counts,
|
||||||
_system_account_item,
|
_system_account_item,
|
||||||
|
_system_account_items,
|
||||||
_tenant_role_assignment_counts,
|
_tenant_role_assignment_counts,
|
||||||
_user_item,
|
_user_item,
|
||||||
)
|
)
|
||||||
from govoplan_access.backend.api.v1.admin_schemas import (
|
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||||
|
AdminSessionItem,
|
||||||
|
AdminSessionListResponse,
|
||||||
|
AdminSessionRevocationRequest,
|
||||||
|
AdminSessionRevocationResponse,
|
||||||
AdminApiKeyCreateRequest,
|
AdminApiKeyCreateRequest,
|
||||||
AdminApiKeyCreateResponse,
|
AdminApiKeyCreateResponse,
|
||||||
ApiKeyAdminItem,
|
ApiKeyAdminItem,
|
||||||
@@ -119,6 +126,8 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
|||||||
RoleSummary,
|
RoleSummary,
|
||||||
RoleUpdateRequest,
|
RoleUpdateRequest,
|
||||||
ResourceAccessExplanationResponse,
|
ResourceAccessExplanationResponse,
|
||||||
|
ResourceAccessExplanationSubjectItem,
|
||||||
|
ResourceAccessExplanationSubjectsResponse,
|
||||||
SystemAccountCreateRequest,
|
SystemAccountCreateRequest,
|
||||||
SystemAccountCreateResponse,
|
SystemAccountCreateResponse,
|
||||||
SystemAccountItem,
|
SystemAccountItem,
|
||||||
@@ -136,6 +145,14 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
|||||||
UserUpdateRequest,
|
UserUpdateRequest,
|
||||||
)
|
)
|
||||||
from govoplan_access.backend.security.api_keys import create_api_key
|
from govoplan_access.backend.security.api_keys import create_api_key
|
||||||
|
from govoplan_access.backend.security.passwords import verify_password
|
||||||
|
from govoplan_access.backend.session_management import (
|
||||||
|
SessionSummary,
|
||||||
|
list_account_sessions,
|
||||||
|
revoke_account_session,
|
||||||
|
session_summary,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope
|
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope
|
||||||
from govoplan_core.audit.logging import audit_event, audit_from_principal
|
from govoplan_core.audit.logging import audit_event, audit_from_principal
|
||||||
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY, SqlAccessConfigurationProvider
|
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY, SqlAccessConfigurationProvider
|
||||||
@@ -145,11 +162,16 @@ from govoplan_core.core.configuration_packages import (
|
|||||||
CONFIGURATION_PROVIDER_CAPABILITY,
|
CONFIGURATION_PROVIDER_CAPABILITY,
|
||||||
ConfigurationExportSelection,
|
ConfigurationExportSelection,
|
||||||
ConfigurationPreflightContext,
|
ConfigurationPreflightContext,
|
||||||
|
ConfigurationProvider,
|
||||||
apply_configuration_package,
|
apply_configuration_package,
|
||||||
dry_run_configuration_package,
|
dry_run_configuration_package,
|
||||||
export_configuration_package,
|
export_configuration_package,
|
||||||
validate_configuration_package_catalog,
|
validate_configuration_package_catalog,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.infrastructure_capabilities import (
|
||||||
|
InfrastructureCapabilityReceiptError,
|
||||||
|
load_infrastructure_capability_receipt,
|
||||||
|
)
|
||||||
from govoplan_core.core.configuration_control import (
|
from govoplan_core.core.configuration_control import (
|
||||||
CONFIGURATION_CHANGE_RECORD_RESOURCE,
|
CONFIGURATION_CHANGE_RECORD_RESOURCE,
|
||||||
CONFIGURATION_CHANGE_REQUEST_RESOURCE,
|
CONFIGURATION_CHANGE_REQUEST_RESOURCE,
|
||||||
@@ -163,7 +185,19 @@ from govoplan_core.core.configuration_control import (
|
|||||||
record_configuration_change_applied,
|
record_configuration_change_applied,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.configuration_safety import configuration_safety_catalog, plan_configuration_change
|
from govoplan_core.core.configuration_safety import configuration_safety_catalog, plan_configuration_change
|
||||||
from govoplan_core.core.access import CAPABILITY_ACCESS_EXPLANATION, AccessExplanationService, AccessDecisionProvenance, PrincipalRef
|
from govoplan_core.core.provider_governance import (
|
||||||
|
ExternalProviderStateContext,
|
||||||
|
collect_external_provider_states,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_ACCESS_EXPLANATION,
|
||||||
|
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS,
|
||||||
|
AccessDecisionProvenance,
|
||||||
|
AccessExplanationService,
|
||||||
|
AccessExplanationSubjectDecision,
|
||||||
|
AccessExplanationSubjectPolicy,
|
||||||
|
PrincipalRef,
|
||||||
|
)
|
||||||
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
||||||
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory, OrganizationFunctionAssignmentRef
|
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory, OrganizationFunctionAssignmentRef
|
||||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, ORGANIZATIONS_MODULE_ID, OrganizationDirectory
|
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, ORGANIZATIONS_MODULE_ID, OrganizationDirectory
|
||||||
@@ -176,6 +210,7 @@ from govoplan_core.core.change_sequence import (
|
|||||||
sequence_entries_since,
|
sequence_entries_since,
|
||||||
sequence_watermark_is_expired,
|
sequence_watermark_is_expired,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||||
from govoplan_access.backend.db.models import (
|
from govoplan_access.backend.db.models import (
|
||||||
Account,
|
Account,
|
||||||
ApiKey,
|
ApiKey,
|
||||||
@@ -357,6 +392,16 @@ def _record_access_change(
|
|||||||
actor_id=principal.user.id,
|
actor_id=principal.user.id,
|
||||||
payload=payload or {},
|
payload=payload or {},
|
||||||
)
|
)
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_module=ACCESS_MODULE_ID,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
actor_type="user",
|
||||||
|
actor_id=principal.user.id,
|
||||||
|
reason=operation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _require_any_permission(principal: ApiPrincipal, *scopes: str) -> None:
|
def _require_any_permission(principal: ApiPrincipal, *scopes: str) -> None:
|
||||||
@@ -484,14 +529,42 @@ def _validate_parent_organization_unit(
|
|||||||
raise AdminValidationError("Parent organization unit does not belong to the tenant.")
|
raise AdminValidationError("Parent organization unit does not belong to the tenant.")
|
||||||
|
|
||||||
|
|
||||||
def _function_item(session: Session, item: Function) -> FunctionAdminItem:
|
def _function_role_ids_by_function_id(
|
||||||
role_ids = [
|
session: Session,
|
||||||
row[0]
|
function_ids: Iterable[str],
|
||||||
for row in session.query(FunctionRoleAssignment.role_id)
|
) -> dict[str, list[str]]:
|
||||||
.filter(FunctionRoleAssignment.function_id == item.id)
|
requested = tuple(dict.fromkeys(function_ids))
|
||||||
.order_by(FunctionRoleAssignment.created_at.asc())
|
result: dict[str, list[str]] = {function_id: [] for function_id in requested}
|
||||||
|
if not requested:
|
||||||
|
return result
|
||||||
|
rows = (
|
||||||
|
session.query(
|
||||||
|
FunctionRoleAssignment.function_id,
|
||||||
|
FunctionRoleAssignment.role_id,
|
||||||
|
)
|
||||||
|
.filter(FunctionRoleAssignment.function_id.in_(requested))
|
||||||
|
.order_by(
|
||||||
|
FunctionRoleAssignment.function_id.asc(),
|
||||||
|
FunctionRoleAssignment.created_at.asc(),
|
||||||
|
)
|
||||||
.all()
|
.all()
|
||||||
]
|
)
|
||||||
|
for function_id, role_id in rows:
|
||||||
|
result.setdefault(function_id, []).append(role_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _function_item(
|
||||||
|
session: Session,
|
||||||
|
item: Function,
|
||||||
|
*,
|
||||||
|
role_ids_by_function_id: dict[str, list[str]] | None = None,
|
||||||
|
) -> FunctionAdminItem:
|
||||||
|
if role_ids_by_function_id is None:
|
||||||
|
role_ids_by_function_id = _function_role_ids_by_function_id(
|
||||||
|
session,
|
||||||
|
(item.id,),
|
||||||
|
)
|
||||||
return FunctionAdminItem(
|
return FunctionAdminItem(
|
||||||
id=item.id,
|
id=item.id,
|
||||||
tenant_id=item.tenant_id,
|
tenant_id=item.tenant_id,
|
||||||
@@ -499,7 +572,7 @@ def _function_item(session: Session, item: Function) -> FunctionAdminItem:
|
|||||||
slug=item.slug,
|
slug=item.slug,
|
||||||
name=item.name,
|
name=item.name,
|
||||||
description=item.description,
|
description=item.description,
|
||||||
role_ids=role_ids,
|
role_ids=role_ids_by_function_id.get(item.id, []),
|
||||||
delegable=item.delegable,
|
delegable=item.delegable,
|
||||||
act_in_place_allowed=item.act_in_place_allowed,
|
act_in_place_allowed=item.act_in_place_allowed,
|
||||||
is_active=item.is_active,
|
is_active=item.is_active,
|
||||||
@@ -611,6 +684,40 @@ def _access_explanation_service_or_error() -> AccessExplanationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _access_explanation_subject_decision(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> AccessExplanationSubjectDecision:
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None or not registry.has_capability(
|
||||||
|
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS
|
||||||
|
):
|
||||||
|
return AccessExplanationSubjectDecision(
|
||||||
|
allow_other_users=False,
|
||||||
|
reason="Access explanations are limited to the signed-in user because no subject policy is active.",
|
||||||
|
source="access.safe_default",
|
||||||
|
provenance={"tenant_id": tenant_id, "mode": "current_user"},
|
||||||
|
)
|
||||||
|
capability = registry.require_capability(
|
||||||
|
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS
|
||||||
|
)
|
||||||
|
if not isinstance(capability, AccessExplanationSubjectPolicy):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=(
|
||||||
|
"Invalid capability: "
|
||||||
|
f"{CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return capability.decide_subject_selection(
|
||||||
|
session,
|
||||||
|
principal.principal,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _idm_assignments_for_user(
|
def _idm_assignments_for_user(
|
||||||
idm_directory: IdmDirectory | None,
|
idm_directory: IdmDirectory | None,
|
||||||
user: User,
|
user: User,
|
||||||
@@ -875,9 +982,10 @@ def configuration_package_catalog_validation(
|
|||||||
@router.post("/configuration-packages/dry-run", response_model=ConfigurationPackageDryRunResponse)
|
@router.post("/configuration-packages/dry-run", response_model=ConfigurationPackageDryRunResponse)
|
||||||
def configuration_package_dry_run_endpoint(
|
def configuration_package_dry_run_endpoint(
|
||||||
payload: ConfigurationPackageRunRequest,
|
payload: ConfigurationPackageRunRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")),
|
principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")),
|
||||||
):
|
):
|
||||||
result = dry_run_configuration_package(payload.package, _configuration_providers(), _configuration_context(principal, tenant_id=payload.tenant_id, supplied_data=payload.supplied_data))
|
result = dry_run_configuration_package(payload.package, _configuration_providers(), _configuration_context(principal, tenant_id=payload.tenant_id, supplied_data=payload.supplied_data, session=session))
|
||||||
return ConfigurationPackageDryRunResponse(
|
return ConfigurationPackageDryRunResponse(
|
||||||
diagnostics=[item.to_dict() for item in result.diagnostics],
|
diagnostics=[item.to_dict() for item in result.diagnostics],
|
||||||
required_data=[item.to_dict() for item in result.required_data],
|
required_data=[item.to_dict() for item in result.required_data],
|
||||||
@@ -903,7 +1011,7 @@ def configuration_package_apply_endpoint(
|
|||||||
)
|
)
|
||||||
except ConfigurationControlError as exc:
|
except ConfigurationControlError as exc:
|
||||||
raise _configuration_control_http_error(exc) from exc
|
raise _configuration_control_http_error(exc) from exc
|
||||||
result = apply_configuration_package(payload.package, _configuration_providers(), _configuration_context(principal, tenant_id=payload.tenant_id, supplied_data=payload.supplied_data))
|
result = apply_configuration_package(payload.package, _configuration_providers(), _configuration_context(principal, tenant_id=payload.tenant_id, supplied_data=payload.supplied_data, session=session))
|
||||||
record_configuration_change_applied(
|
record_configuration_change_applied(
|
||||||
session,
|
session,
|
||||||
key="configuration_packages.apply",
|
key="configuration_packages.apply",
|
||||||
@@ -928,12 +1036,14 @@ def configuration_package_apply_endpoint(
|
|||||||
diagnostics=[item.to_dict() for item in result.diagnostics],
|
diagnostics=[item.to_dict() for item in result.diagnostics],
|
||||||
created_refs=dict(result.created_refs),
|
created_refs=dict(result.created_refs),
|
||||||
updated_refs=dict(result.updated_refs),
|
updated_refs=dict(result.updated_refs),
|
||||||
|
rollback=result.rollback.to_dict() if result.rollback is not None else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/configuration-packages/export", response_model=ConfigurationPackageExportResponse)
|
@router.post("/configuration-packages/export", response_model=ConfigurationPackageExportResponse)
|
||||||
def configuration_package_export_endpoint(
|
def configuration_package_export_endpoint(
|
||||||
payload: ConfigurationPackageExportRequest,
|
payload: ConfigurationPackageExportRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")),
|
principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")),
|
||||||
):
|
):
|
||||||
selection = ConfigurationExportSelection(
|
selection = ConfigurationExportSelection(
|
||||||
@@ -942,38 +1052,80 @@ def configuration_package_export_endpoint(
|
|||||||
module_ids=tuple(payload.module_ids),
|
module_ids=tuple(payload.module_ids),
|
||||||
object_refs=tuple(payload.object_refs),
|
object_refs=tuple(payload.object_refs),
|
||||||
)
|
)
|
||||||
result = export_configuration_package(_configuration_providers(), selection, _configuration_context(principal, tenant_id=payload.tenant_id))
|
result = export_configuration_package(_configuration_providers(), selection, _configuration_context(principal, tenant_id=payload.tenant_id, session=session))
|
||||||
return ConfigurationPackageExportResponse(
|
return ConfigurationPackageExportResponse(
|
||||||
fragments=[item.to_dict() for item in result.fragments],
|
fragments=[item.to_dict() for item in result.fragments],
|
||||||
data_requirements=[item.to_dict() for item in result.data_requirements],
|
data_requirements=[item.to_dict() for item in result.data_requirements],
|
||||||
diagnostics=[item.to_dict() for item in result.diagnostics],
|
diagnostics=[item.to_dict() for item in result.diagnostics],
|
||||||
|
provenance=result.provenance.to_dict() if result.provenance is not None else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _configuration_providers() -> tuple[object, ...]:
|
def _configuration_providers() -> tuple[object, ...]:
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
if registry is not None and hasattr(registry, "has_capability") and registry.has_capability(ACCESS_CONFIGURATION_CAPABILITY):
|
providers: dict[str, ConfigurationProvider] = {}
|
||||||
capability = registry.capability(ACCESS_CONFIGURATION_CAPABILITY)
|
if registry is not None and hasattr(registry, "capability_names"):
|
||||||
if capability is not None:
|
for capability_name in registry.capability_names():
|
||||||
return (capability,)
|
if not capability_name.endswith(".configuration"):
|
||||||
return (SqlAccessConfigurationProvider(),)
|
continue
|
||||||
|
capability = registry.capability(capability_name)
|
||||||
|
if isinstance(capability, ConfigurationProvider):
|
||||||
|
providers[capability.module_id] = capability
|
||||||
|
providers.setdefault("access", SqlAccessConfigurationProvider())
|
||||||
|
return tuple(providers[module_id] for module_id in sorted(providers))
|
||||||
|
|
||||||
|
|
||||||
def _configuration_context(principal: ApiPrincipal, *, tenant_id: str | None = None, supplied_data: dict[str, Any] | None = None) -> ConfigurationPreflightContext:
|
def _configuration_context(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
supplied_data: dict[str, Any] | None = None,
|
||||||
|
session: Session | None = None,
|
||||||
|
) -> ConfigurationPreflightContext:
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
installed_modules: dict[str, str] = {"access": "0.1.6"}
|
installed_modules: dict[str, str] = {"access": "0.1.6"}
|
||||||
capabilities = {CONFIGURATION_PROVIDER_CAPABILITY, ACCESS_CONFIGURATION_CAPABILITY}
|
capabilities = {CONFIGURATION_PROVIDER_CAPABILITY, ACCESS_CONFIGURATION_CAPABILITY}
|
||||||
|
external_provider_declarations: dict[str, dict[str, object]] = {}
|
||||||
|
external_provider_states: dict[str, dict[str, object]] = {}
|
||||||
|
infrastructure_receipt = None
|
||||||
|
infrastructure_receipt_error = None
|
||||||
|
try:
|
||||||
|
infrastructure_receipt = load_infrastructure_capability_receipt()
|
||||||
|
except InfrastructureCapabilityReceiptError as exc:
|
||||||
|
infrastructure_receipt_error = str(exc)
|
||||||
if registry is not None and hasattr(registry, "manifests"):
|
if registry is not None and hasattr(registry, "manifests"):
|
||||||
manifests = registry.manifests()
|
manifests = registry.manifests()
|
||||||
installed_modules = {manifest.id: manifest.version for manifest in manifests}
|
installed_modules = {manifest.id: manifest.version for manifest in manifests}
|
||||||
if hasattr(registry, "has_capability") and registry.has_capability(ACCESS_CONFIGURATION_CAPABILITY):
|
if hasattr(registry, "capability_names"):
|
||||||
capabilities.add(ACCESS_CONFIGURATION_CAPABILITY)
|
capabilities.update(registry.capability_names())
|
||||||
|
if hasattr(registry, "external_provider_declarations"):
|
||||||
|
external_provider_declarations = {
|
||||||
|
declaration.id: declaration.to_dict()
|
||||||
|
for declaration in registry.external_provider_declarations()
|
||||||
|
}
|
||||||
|
if session is not None and hasattr(
|
||||||
|
registry,
|
||||||
|
"external_provider_state_providers",
|
||||||
|
):
|
||||||
|
external_provider_states = collect_external_provider_states(
|
||||||
|
registry.external_provider_state_providers(),
|
||||||
|
ExternalProviderStateContext(
|
||||||
|
session=session,
|
||||||
|
tenant_id=tenant_id or principal.tenant_id,
|
||||||
|
principal=principal,
|
||||||
|
),
|
||||||
|
)
|
||||||
return ConfigurationPreflightContext(
|
return ConfigurationPreflightContext(
|
||||||
tenant_id=tenant_id or principal.tenant_id,
|
tenant_id=tenant_id or principal.tenant_id,
|
||||||
operator_user_id=principal.user.id,
|
operator_user_id=principal.user.id,
|
||||||
|
operator_scopes=frozenset(getattr(principal, "scopes", ())),
|
||||||
supplied_data=supplied_data or {},
|
supplied_data=supplied_data or {},
|
||||||
installed_modules=installed_modules,
|
installed_modules=installed_modules,
|
||||||
capabilities=frozenset(capabilities),
|
capabilities=frozenset(capabilities),
|
||||||
|
external_provider_declarations=external_provider_declarations,
|
||||||
|
external_provider_states=external_provider_states,
|
||||||
|
infrastructure_receipt=infrastructure_receipt,
|
||||||
|
infrastructure_receipt_error=infrastructure_receipt_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1266,17 +1418,26 @@ def deactivate_identity(
|
|||||||
@router.get("/organization-units", response_model=OrganizationUnitListResponse)
|
@router.get("/organization-units", response_model=OrganizationUnitListResponse)
|
||||||
def list_organization_units(
|
def list_organization_units(
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=500, ge=1, le=1000),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||||
):
|
):
|
||||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||||
items = (
|
query = (
|
||||||
session.query(OrganizationUnit)
|
session.query(OrganizationUnit)
|
||||||
.filter(OrganizationUnit.tenant_id == tenant.id)
|
.filter(OrganizationUnit.tenant_id == tenant.id)
|
||||||
.order_by(OrganizationUnit.name.asc())
|
.order_by(OrganizationUnit.name.asc())
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
return OrganizationUnitListResponse(organization_units=[_organization_unit_item(item) for item in items])
|
items, pagination = _page_query(
|
||||||
|
query,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return OrganizationUnitListResponse(
|
||||||
|
organization_units=[_organization_unit_item(item) for item in items],
|
||||||
|
**pagination,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/organization-units", response_model=OrganizationUnitItem, status_code=status.HTTP_201_CREATED)
|
@router.post("/organization-units", response_model=OrganizationUnitItem, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -1394,6 +1555,8 @@ def deactivate_organization_unit(
|
|||||||
def list_functions(
|
def list_functions(
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
organization_unit_id: str | None = None,
|
organization_unit_id: str | None = None,
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=500, ge=1, le=1000),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||||
):
|
):
|
||||||
@@ -1401,8 +1564,26 @@ def list_functions(
|
|||||||
query = session.query(Function).filter(Function.tenant_id == tenant.id)
|
query = session.query(Function).filter(Function.tenant_id == tenant.id)
|
||||||
if organization_unit_id:
|
if organization_unit_id:
|
||||||
query = query.filter(Function.organization_unit_id == organization_unit_id)
|
query = query.filter(Function.organization_unit_id == organization_unit_id)
|
||||||
functions = query.order_by(Function.name.asc()).all()
|
functions, pagination = _page_query(
|
||||||
return FunctionListResponse(functions=[_function_item(session, item) for item in functions])
|
query.order_by(Function.name.asc()),
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
role_ids_by_function_id = _function_role_ids_by_function_id(
|
||||||
|
session,
|
||||||
|
(item.id for item in functions),
|
||||||
|
)
|
||||||
|
return FunctionListResponse(
|
||||||
|
functions=[
|
||||||
|
_function_item(
|
||||||
|
session,
|
||||||
|
item,
|
||||||
|
role_ids_by_function_id=role_ids_by_function_id,
|
||||||
|
)
|
||||||
|
for item in functions
|
||||||
|
],
|
||||||
|
**pagination,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/functions", response_model=FunctionAdminItem, status_code=status.HTTP_201_CREATED)
|
@router.post("/functions", response_model=FunctionAdminItem, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -1535,6 +1716,8 @@ def list_external_function_role_mappings(
|
|||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
source_module: str | None = None,
|
source_module: str | None = None,
|
||||||
function_id: str | None = None,
|
function_id: str | None = None,
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=500, ge=1, le=1000),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||||
):
|
):
|
||||||
@@ -1544,23 +1727,57 @@ def list_external_function_role_mappings(
|
|||||||
query = query.filter(ExternalFunctionRoleAssignment.source_module == source_module.strip())
|
query = query.filter(ExternalFunctionRoleAssignment.source_module == source_module.strip())
|
||||||
if function_id:
|
if function_id:
|
||||||
query = query.filter(ExternalFunctionRoleAssignment.function_id == function_id.strip())
|
query = query.filter(ExternalFunctionRoleAssignment.function_id == function_id.strip())
|
||||||
items = query.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc()).all()
|
items, pagination = _page_query(
|
||||||
return ExternalFunctionRoleMappingListResponse(mappings=[_external_function_role_mapping_item(item) for item in items])
|
query.order_by(
|
||||||
|
ExternalFunctionRoleAssignment.source_module.asc(),
|
||||||
|
ExternalFunctionRoleAssignment.function_id.asc(),
|
||||||
|
),
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return ExternalFunctionRoleMappingListResponse(
|
||||||
|
mappings=[_external_function_role_mapping_item(item) for item in items],
|
||||||
|
**pagination,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _full_external_function_role_mappings_delta_response(session: Session, tenant: Tenant) -> ExternalFunctionRoleMappingListDeltaResponse:
|
def _full_external_function_role_mappings_delta_response(
|
||||||
items = (
|
session: Session,
|
||||||
|
tenant: Tenant,
|
||||||
|
*,
|
||||||
|
cursor: tuple[int, int] | None = None,
|
||||||
|
limit: int = 500,
|
||||||
|
) -> ExternalFunctionRoleMappingListDeltaResponse:
|
||||||
|
snapshot_sequence = (
|
||||||
|
cursor[1]
|
||||||
|
if cursor is not None
|
||||||
|
else max_sequence_id(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
module_id=ACCESS_MODULE_ID,
|
||||||
|
collections=(ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
page = cursor[0] if cursor is not None else 1
|
||||||
|
query = (
|
||||||
session.query(ExternalFunctionRoleAssignment)
|
session.query(ExternalFunctionRoleAssignment)
|
||||||
.filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id)
|
.filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id)
|
||||||
.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc())
|
.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc())
|
||||||
.all()
|
)
|
||||||
|
items, pagination, watermark, has_more = _full_delta_page(
|
||||||
|
query,
|
||||||
|
page=page,
|
||||||
|
page_size=limit,
|
||||||
|
scope="external-function-role-mappings",
|
||||||
|
snapshot_sequence=snapshot_sequence,
|
||||||
)
|
)
|
||||||
return ExternalFunctionRoleMappingListDeltaResponse(
|
return ExternalFunctionRoleMappingListDeltaResponse(
|
||||||
mappings=[_external_function_role_mapping_item(item) for item in items],
|
mappings=[_external_function_role_mapping_item(item) for item in items],
|
||||||
deleted=[],
|
deleted=[],
|
||||||
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,)),
|
watermark=watermark,
|
||||||
has_more=False,
|
has_more=has_more,
|
||||||
full=True,
|
full=True,
|
||||||
|
**pagination,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1620,8 +1837,17 @@ def list_external_function_role_mappings_delta(
|
|||||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||||
):
|
):
|
||||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||||
if since is None:
|
full_cursor = _decode_full_delta_cursor(
|
||||||
return _full_external_function_role_mappings_delta_response(session, tenant)
|
since,
|
||||||
|
scope="external-function-role-mappings",
|
||||||
|
)
|
||||||
|
if since is None or full_cursor is not None:
|
||||||
|
return _full_external_function_role_mappings_delta_response(
|
||||||
|
session,
|
||||||
|
tenant,
|
||||||
|
cursor=full_cursor,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
return _external_function_role_mappings_delta_response(session, tenant, since=since, limit=limit)
|
return _external_function_role_mappings_delta_response(session, tenant, since=since, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@@ -1735,6 +1961,8 @@ def list_function_assignments(
|
|||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
account_id: str | None = None,
|
account_id: str | None = None,
|
||||||
function_id: str | None = None,
|
function_id: str | None = None,
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=500, ge=1, le=1000),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:assign")),
|
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:assign")),
|
||||||
):
|
):
|
||||||
@@ -1744,8 +1972,15 @@ def list_function_assignments(
|
|||||||
query = query.filter(FunctionAssignment.account_id == account_id)
|
query = query.filter(FunctionAssignment.account_id == account_id)
|
||||||
if function_id:
|
if function_id:
|
||||||
query = query.filter(FunctionAssignment.function_id == function_id)
|
query = query.filter(FunctionAssignment.function_id == function_id)
|
||||||
assignments = query.order_by(FunctionAssignment.created_at.desc()).all()
|
assignments, pagination = _page_query(
|
||||||
return FunctionAssignmentListResponse(assignments=[_function_assignment_item(item) for item in assignments])
|
query.order_by(FunctionAssignment.created_at.desc()),
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return FunctionAssignmentListResponse(
|
||||||
|
assignments=[_function_assignment_item(item) for item in assignments],
|
||||||
|
**pagination,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/function-assignments", response_model=FunctionAssignmentAdminItem, status_code=status.HTTP_201_CREATED)
|
@router.post("/function-assignments", response_model=FunctionAssignmentAdminItem, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -1888,6 +2123,8 @@ def deactivate_function_assignment(
|
|||||||
def list_function_delegations(
|
def list_function_delegations(
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
account_id: str | None = None,
|
account_id: str | None = None,
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=500, ge=1, le=1000),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:delegate")),
|
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:delegate")),
|
||||||
):
|
):
|
||||||
@@ -1895,8 +2132,15 @@ def list_function_delegations(
|
|||||||
query = session.query(FunctionDelegation).filter(FunctionDelegation.tenant_id == tenant.id)
|
query = session.query(FunctionDelegation).filter(FunctionDelegation.tenant_id == tenant.id)
|
||||||
if account_id:
|
if account_id:
|
||||||
query = query.filter((FunctionDelegation.delegator_account_id == account_id) | (FunctionDelegation.delegate_account_id == account_id))
|
query = query.filter((FunctionDelegation.delegator_account_id == account_id) | (FunctionDelegation.delegate_account_id == account_id))
|
||||||
delegations = query.order_by(FunctionDelegation.created_at.desc()).all()
|
delegations, pagination = _page_query(
|
||||||
return FunctionDelegationListResponse(delegations=[_function_delegation_item(item) for item in delegations])
|
query.order_by(FunctionDelegation.created_at.desc()),
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return FunctionDelegationListResponse(
|
||||||
|
delegations=[_function_delegation_item(item) for item in delegations],
|
||||||
|
**pagination,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/function-delegations", response_model=FunctionDelegationAdminItem, status_code=status.HTTP_201_CREATED)
|
@router.post("/function-delegations", response_model=FunctionDelegationAdminItem, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -2196,6 +2440,188 @@ def get_user_access_explanation(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_session_item(item: SessionSummary) -> AdminSessionItem:
|
||||||
|
return AdminSessionItem(**asdict(item))
|
||||||
|
|
||||||
|
|
||||||
|
def _require_session_admin_reauthorization(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
current_password: str,
|
||||||
|
) -> None:
|
||||||
|
if principal.auth_session is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Administrative session revocation requires an interactive session.",
|
||||||
|
)
|
||||||
|
if not verify_password(current_password, principal.account.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Current password re-authorization failed.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/users/{user_id}/sessions",
|
||||||
|
response_model=AdminSessionListResponse,
|
||||||
|
)
|
||||||
|
def list_user_sessions(
|
||||||
|
user_id: str,
|
||||||
|
tenant_id: str | None = Query(default=None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope("admin:users:read", "access:membership:read")
|
||||||
|
),
|
||||||
|
) -> AdminSessionListResponse:
|
||||||
|
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||||
|
user = (
|
||||||
|
session.query(User)
|
||||||
|
.filter(User.id == user_id, User.tenant_id == tenant.id)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found",
|
||||||
|
)
|
||||||
|
items = list_account_sessions(
|
||||||
|
session,
|
||||||
|
account_id=user.account_id,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
current_session_id=principal.session_id,
|
||||||
|
)
|
||||||
|
return AdminSessionListResponse(
|
||||||
|
sessions=[_admin_session_item(item) for item in items]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/users/{user_id}/sessions/{session_id}/revoke",
|
||||||
|
response_model=AdminSessionRevocationResponse,
|
||||||
|
)
|
||||||
|
def revoke_user_session(
|
||||||
|
user_id: str,
|
||||||
|
session_id: str,
|
||||||
|
payload: AdminSessionRevocationRequest,
|
||||||
|
tenant_id: str | None = Query(default=None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope("admin:users:update", "access:membership:update")
|
||||||
|
),
|
||||||
|
) -> AdminSessionRevocationResponse:
|
||||||
|
_require_session_admin_reauthorization(principal, payload.current_password)
|
||||||
|
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||||
|
user = (
|
||||||
|
session.query(User)
|
||||||
|
.filter(User.id == user_id, User.tenant_id == tenant.id)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
item, changed = revoke_account_session(
|
||||||
|
session,
|
||||||
|
account_id=user.account_id,
|
||||||
|
session_id=session_id,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
protected_session_id=principal.session_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Session not found",
|
||||||
|
)
|
||||||
|
if changed:
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="access.session.revoked_by_administrator",
|
||||||
|
scope="tenant",
|
||||||
|
object_type="access_auth_session",
|
||||||
|
object_id=item.id,
|
||||||
|
details={"target_membership_id": user.id},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
return AdminSessionRevocationResponse(
|
||||||
|
session=_admin_session_item(
|
||||||
|
session_summary(item, current_session_id=principal.session_id)
|
||||||
|
),
|
||||||
|
revoked=changed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/access/resource-explanation/subjects",
|
||||||
|
response_model=ResourceAccessExplanationSubjectsResponse,
|
||||||
|
)
|
||||||
|
def get_resource_access_explanation_subjects(
|
||||||
|
tenant_id: str | None = Query(default=None),
|
||||||
|
query: str | None = Query(default=None, max_length=200),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"admin:users:read",
|
||||||
|
"admin:roles:read",
|
||||||
|
"access:membership:read",
|
||||||
|
"access:role:read",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
) -> ResourceAccessExplanationSubjectsResponse:
|
||||||
|
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||||
|
decision = _access_explanation_subject_decision(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
)
|
||||||
|
users_query = session.query(User).filter(
|
||||||
|
User.tenant_id == tenant.id,
|
||||||
|
User.is_active.is_(True),
|
||||||
|
)
|
||||||
|
if not decision.allow_other_users:
|
||||||
|
users_query = users_query.filter(User.id == principal.membership_id)
|
||||||
|
elif query and query.strip():
|
||||||
|
pattern = f"%{query.strip()}%"
|
||||||
|
users_query = users_query.filter(
|
||||||
|
or_(User.display_name.ilike(pattern), User.email.ilike(pattern))
|
||||||
|
)
|
||||||
|
users = users_query.order_by(User.display_name.asc(), User.email.asc()).limit(100).all()
|
||||||
|
if decision.allow_other_users and not query:
|
||||||
|
current_user = (
|
||||||
|
session.query(User)
|
||||||
|
.filter(
|
||||||
|
User.id == principal.membership_id,
|
||||||
|
User.tenant_id == tenant.id,
|
||||||
|
User.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if current_user is not None and all(user.id != current_user.id for user in users):
|
||||||
|
users = [current_user, *users[:99]]
|
||||||
|
return ResourceAccessExplanationSubjectsResponse(
|
||||||
|
mode="cross_user" if decision.allow_other_users else "current_user",
|
||||||
|
can_select_other_users=decision.allow_other_users,
|
||||||
|
reason=decision.reason,
|
||||||
|
source=decision.source,
|
||||||
|
required_scope=decision.required_scope,
|
||||||
|
users=[
|
||||||
|
ResourceAccessExplanationSubjectItem(
|
||||||
|
id=user.id,
|
||||||
|
email=user.email,
|
||||||
|
display_name=user.display_name,
|
||||||
|
)
|
||||||
|
for user in users
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/access/resource-explanation", response_model=ResourceAccessExplanationResponse)
|
@router.get("/access/resource-explanation", response_model=ResourceAccessExplanationResponse)
|
||||||
def get_resource_access_explanation(
|
def get_resource_access_explanation(
|
||||||
user_id: str = Query(...),
|
user_id: str = Query(...),
|
||||||
@@ -2207,6 +2633,16 @@ def get_resource_access_explanation(
|
|||||||
principal: ApiPrincipal = Depends(require_any_scope("admin:users:read", "admin:roles:read", "access:membership:read", "access:role:read")),
|
principal: ApiPrincipal = Depends(require_any_scope("admin:users:read", "admin:roles:read", "access:membership:read", "access:role:read")),
|
||||||
):
|
):
|
||||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||||
|
decision = _access_explanation_subject_decision(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
)
|
||||||
|
if user_id != principal.membership_id and not decision.allow_other_users:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=decision.reason,
|
||||||
|
)
|
||||||
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none()
|
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none()
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
@@ -2226,6 +2662,21 @@ def get_resource_access_explanation(
|
|||||||
resource_id=resource_id,
|
resource_id=resource_id,
|
||||||
action=action,
|
action=action,
|
||||||
)
|
)
|
||||||
|
if user_id != principal.membership_id:
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="access.resource_explanation.selected_user_viewed",
|
||||||
|
scope="tenant",
|
||||||
|
object_type=resource_type,
|
||||||
|
object_id=resource_id,
|
||||||
|
details={
|
||||||
|
"target_membership_id": user.id,
|
||||||
|
"requested_action": action,
|
||||||
|
"policy_source": decision.source,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
return ResourceAccessExplanationResponse(
|
return ResourceAccessExplanationResponse(
|
||||||
user=_user_item_for_response(session, user, idm_directory=idm_directory, organization_directory=organization_directory),
|
user=_user_item_for_response(session, user, idm_directory=idm_directory, organization_directory=organization_directory),
|
||||||
resource_type=resource_type,
|
resource_type=resource_type,
|
||||||
@@ -2981,6 +3432,25 @@ def _system_role_summaries_for_response(session: Session, roles: list[Role]) ->
|
|||||||
return [_role_summary(session, role, system_role_assignment_counts=system_role_counts) for role in roles]
|
return [_role_summary(session, role, system_role_assignment_counts=system_role_counts) for role in roles]
|
||||||
|
|
||||||
|
|
||||||
|
def _system_roles_for_account_catalog(session: Session) -> list[Role]:
|
||||||
|
roles = (
|
||||||
|
session.query(Role)
|
||||||
|
.filter(Role.tenant_id.is_(None))
|
||||||
|
.order_by(Role.name.asc(), Role.id.asc())
|
||||||
|
.limit(1001)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if len(roles) > 1000:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
||||||
|
detail=(
|
||||||
|
"The system role catalog exceeds 1000 entries. "
|
||||||
|
"Use the paginated system roles endpoint."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return roles
|
||||||
|
|
||||||
|
|
||||||
def _full_system_roles_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> RoleListDeltaResponse:
|
def _full_system_roles_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> RoleListDeltaResponse:
|
||||||
ensure_default_roles(session, None)
|
ensure_default_roles(session, None)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -3178,13 +3648,13 @@ _SYSTEM_ACCOUNTS_DELTA_COLLECTIONS = (ACCESS_SYSTEM_ACCOUNTS_COLLECTION, ACCESS_
|
|||||||
def _full_system_accounts_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> SystemAccountListDeltaResponse:
|
def _full_system_accounts_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> SystemAccountListDeltaResponse:
|
||||||
ensure_default_roles(session, None)
|
ensure_default_roles(session, None)
|
||||||
session.commit()
|
session.commit()
|
||||||
system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all()
|
system_roles = _system_roles_for_account_catalog(session)
|
||||||
snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=None, module_id=ACCESS_MODULE_ID, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS)
|
snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=None, module_id=ACCESS_MODULE_ID, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS)
|
||||||
page = cursor[0] if cursor is not None else 1
|
page = cursor[0] if cursor is not None else 1
|
||||||
query = session.query(Account).order_by(Account.email.asc(), Account.id.asc())
|
query = session.query(Account).order_by(Account.email.asc(), Account.id.asc())
|
||||||
accounts, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="system-accounts", snapshot_sequence=snapshot_sequence)
|
accounts, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="system-accounts", snapshot_sequence=snapshot_sequence)
|
||||||
return SystemAccountListDeltaResponse(
|
return SystemAccountListDeltaResponse(
|
||||||
accounts=[_system_account_item(session, account) for account in accounts],
|
accounts=_system_account_items(session, accounts),
|
||||||
roles=_system_role_summaries_for_response(session, system_roles),
|
roles=_system_role_summaries_for_response(session, system_roles),
|
||||||
deleted=[],
|
deleted=[],
|
||||||
watermark=watermark,
|
watermark=watermark,
|
||||||
@@ -3223,7 +3693,7 @@ def _system_accounts_delta_response(session: Session, *, since: str, limit: int)
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
return SystemAccountListDeltaResponse(
|
return SystemAccountListDeltaResponse(
|
||||||
accounts=[_system_account_item(session, account) for account in visible_accounts.values()],
|
accounts=_system_account_items(session, list(visible_accounts.values())),
|
||||||
roles=_system_role_summaries_for_response(session, list(visible_roles.values())),
|
roles=_system_role_summaries_for_response(session, list(visible_roles.values())),
|
||||||
deleted=deleted,
|
deleted=deleted,
|
||||||
watermark=_access_delta_response_watermark(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, entries=entries, has_more=has_more),
|
watermark=_access_delta_response_watermark(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, entries=entries, has_more=has_more),
|
||||||
@@ -3255,11 +3725,11 @@ def list_system_accounts(
|
|||||||
):
|
):
|
||||||
ensure_default_roles(session, None)
|
ensure_default_roles(session, None)
|
||||||
session.commit()
|
session.commit()
|
||||||
system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all()
|
system_roles = _system_roles_for_account_catalog(session)
|
||||||
query = session.query(Account).order_by(Account.email.asc())
|
query = session.query(Account).order_by(Account.email.asc())
|
||||||
accounts, pagination = _page_query(query, page=page, page_size=page_size)
|
accounts, pagination = _page_query(query, page=page, page_size=page_size)
|
||||||
return SystemAccountListResponse(
|
return SystemAccountListResponse(
|
||||||
accounts=[_system_account_item(session, account) for account in accounts],
|
accounts=_system_account_items(session, accounts),
|
||||||
roles=_system_role_summaries_for_response(session, system_roles),
|
roles=_system_role_summaries_for_response(session, system_roles),
|
||||||
**pagination,
|
**pagination,
|
||||||
)
|
)
|
||||||
@@ -3590,7 +4060,14 @@ def _api_key_items_for_response(session: Session, keys: list[ApiKey]) -> list[Ap
|
|||||||
|
|
||||||
|
|
||||||
def _full_api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool, cursor: tuple[int, int] | None = None, limit: int = 500) -> ApiKeyListDeltaResponse:
|
def _full_api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool, cursor: tuple[int, int] | None = None, limit: int = 500) -> ApiKeyListDeltaResponse:
|
||||||
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
|
query = (
|
||||||
|
session.query(ApiKey)
|
||||||
|
.join(User, User.id == ApiKey.user_id)
|
||||||
|
.filter(
|
||||||
|
ApiKey.tenant_id == tenant.id,
|
||||||
|
User.auth_provider != "service_account",
|
||||||
|
)
|
||||||
|
)
|
||||||
if not include_revoked:
|
if not include_revoked:
|
||||||
query = query.filter(ApiKey.revoked_at.is_(None))
|
query = query.filter(ApiKey.revoked_at.is_(None))
|
||||||
snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_API_KEYS_COLLECTION,))
|
snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_API_KEYS_COLLECTION,))
|
||||||
@@ -3617,7 +4094,14 @@ def _api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoke
|
|||||||
if entries is None:
|
if entries is None:
|
||||||
return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked, limit=limit)
|
return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked, limit=limit)
|
||||||
changed_ids = _changed_ids(entries, "access_api_key")
|
changed_ids = _changed_ids(entries, "access_api_key")
|
||||||
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
|
query = (
|
||||||
|
session.query(ApiKey)
|
||||||
|
.join(User, User.id == ApiKey.user_id)
|
||||||
|
.filter(
|
||||||
|
ApiKey.tenant_id == tenant.id,
|
||||||
|
User.auth_provider != "service_account",
|
||||||
|
)
|
||||||
|
)
|
||||||
if not include_revoked:
|
if not include_revoked:
|
||||||
query = query.filter(ApiKey.revoked_at.is_(None))
|
query = query.filter(ApiKey.revoked_at.is_(None))
|
||||||
visible = {
|
visible = {
|
||||||
@@ -3667,7 +4151,14 @@ def list_api_keys(
|
|||||||
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")),
|
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")),
|
||||||
):
|
):
|
||||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||||
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
|
query = (
|
||||||
|
session.query(ApiKey)
|
||||||
|
.join(User, User.id == ApiKey.user_id)
|
||||||
|
.filter(
|
||||||
|
ApiKey.tenant_id == tenant.id,
|
||||||
|
User.auth_provider != "service_account",
|
||||||
|
)
|
||||||
|
)
|
||||||
if not include_revoked:
|
if not include_revoked:
|
||||||
query = query.filter(ApiKey.revoked_at.is_(None))
|
query = query.filter(ApiKey.revoked_at.is_(None))
|
||||||
keys, pagination = _page_query(query.order_by(ApiKey.created_at.desc()), page=page, page_size=page_size)
|
keys, pagination = _page_query(query.order_by(ApiKey.created_at.desc()), page=page, page_size=page_size)
|
||||||
@@ -3690,6 +4181,14 @@ def create_tenant_api_key(
|
|||||||
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id, User.is_active.is_(True)).one_or_none()
|
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id, User.is_active.is_(True)).one_or_none()
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active user not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active user not found")
|
||||||
|
if user.auth_provider == "service_account":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"Use the service-account credential API so revision, scope "
|
||||||
|
"ceiling, rotation, and audit guarantees remain enforced"
|
||||||
|
),
|
||||||
|
)
|
||||||
user_scopes = _user_item_for_response(
|
user_scopes = _user_item_for_response(
|
||||||
session,
|
session,
|
||||||
user,
|
user,
|
||||||
@@ -3740,7 +4239,16 @@ def revoke_api_key(
|
|||||||
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:revoke")),
|
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:revoke")),
|
||||||
):
|
):
|
||||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||||
item = session.query(ApiKey).filter(ApiKey.id == api_key_id, ApiKey.tenant_id == tenant.id).one_or_none()
|
item = (
|
||||||
|
session.query(ApiKey)
|
||||||
|
.join(User, User.id == ApiKey.user_id)
|
||||||
|
.filter(
|
||||||
|
ApiKey.id == api_key_id,
|
||||||
|
ApiKey.tenant_id == tenant.id,
|
||||||
|
User.auth_provider != "service_account",
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
|
||||||
if item.revoked_at is None:
|
if item.revoked_at is None:
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.admin.governance import assert_api_keys_allowed
|
||||||
|
from govoplan_access.backend.api.v1.admin_common import _resolve_tenant
|
||||||
|
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||||
|
ServiceAccountCreateRequest,
|
||||||
|
ServiceAccountCredentialCreateRequest,
|
||||||
|
ServiceAccountCredentialItem,
|
||||||
|
ServiceAccountCredentialListResponse,
|
||||||
|
ServiceAccountCredentialMutationResponse,
|
||||||
|
ServiceAccountCredentialRevokeRequest,
|
||||||
|
ServiceAccountCredentialRotateRequest,
|
||||||
|
ServiceAccountCredentialSecretResponse,
|
||||||
|
ServiceAccountItem,
|
||||||
|
ServiceAccountListResponse,
|
||||||
|
ServiceAccountRetireRequest,
|
||||||
|
ServiceAccountUpdateRequest,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.service_accounts import (
|
||||||
|
ServiceAccountConflictError,
|
||||||
|
ServiceAccountCredentialNotFoundError,
|
||||||
|
ServiceAccountCredentialSummary,
|
||||||
|
ServiceAccountError,
|
||||||
|
ServiceAccountNotFoundError,
|
||||||
|
create_service_account,
|
||||||
|
create_service_account_credential,
|
||||||
|
get_service_account,
|
||||||
|
list_service_accounts,
|
||||||
|
list_service_account_credentials,
|
||||||
|
revoke_service_account_credential,
|
||||||
|
retire_service_account,
|
||||||
|
rotate_service_account_credential,
|
||||||
|
service_account_credential_summaries,
|
||||||
|
update_service_account,
|
||||||
|
)
|
||||||
|
from govoplan_core.admin.common import AdminConflictError
|
||||||
|
from govoplan_core.audit.logging import audit_from_principal
|
||||||
|
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/admin/service-accounts",
|
||||||
|
tags=["admin", "service-accounts"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ServiceAccountListResponse)
|
||||||
|
def list_managed_service_accounts(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_scope("access:service_account:read")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
tenant = _resolve_tenant(session, principal, None)
|
||||||
|
items = list_service_accounts(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
)
|
||||||
|
summaries = service_account_credential_summaries(
|
||||||
|
session,
|
||||||
|
service_accounts=items,
|
||||||
|
)
|
||||||
|
return ServiceAccountListResponse(
|
||||||
|
items=[
|
||||||
|
_service_account_item(item, summaries.get(item.id))
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{service_account_id}",
|
||||||
|
response_model=ServiceAccountItem,
|
||||||
|
)
|
||||||
|
def get_managed_service_account(
|
||||||
|
service_account_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_scope("access:service_account:read")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
tenant = _resolve_tenant(session, principal, None)
|
||||||
|
try:
|
||||||
|
item = get_service_account(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
service_account_id=service_account_id,
|
||||||
|
)
|
||||||
|
except ServiceAccountError as exc:
|
||||||
|
raise _service_account_http_error(exc) from exc
|
||||||
|
summary = service_account_credential_summaries(
|
||||||
|
session,
|
||||||
|
service_accounts=(item,),
|
||||||
|
)[item.id]
|
||||||
|
return _service_account_item(item, summary)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=ServiceAccountItem,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_managed_service_account(
|
||||||
|
payload: ServiceAccountCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_scope("access:service_account:write")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
tenant = _resolve_tenant(session, principal, None)
|
||||||
|
try:
|
||||||
|
item = create_service_account(
|
||||||
|
session,
|
||||||
|
tenant=tenant,
|
||||||
|
principal=principal,
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
scope_ceiling=payload.scope_ceiling,
|
||||||
|
)
|
||||||
|
except (ServiceAccountError, PermissionError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _service_account_http_error(exc) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="service_account.created",
|
||||||
|
scope="tenant",
|
||||||
|
object_type="service_account",
|
||||||
|
object_id=item.id,
|
||||||
|
details={
|
||||||
|
"name": item.name,
|
||||||
|
"scope_ceiling": list(item.scope_ceiling),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _service_account_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{service_account_id}",
|
||||||
|
response_model=ServiceAccountItem,
|
||||||
|
)
|
||||||
|
def update_managed_service_account(
|
||||||
|
service_account_id: str,
|
||||||
|
payload: ServiceAccountUpdateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_scope("access:service_account:write")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
tenant = _resolve_tenant(session, principal, None)
|
||||||
|
changes = {
|
||||||
|
field: getattr(payload, field)
|
||||||
|
for field in payload.model_fields_set
|
||||||
|
if field != "expected_revision"
|
||||||
|
}
|
||||||
|
for field in ("name", "scope_ceiling", "is_active"):
|
||||||
|
if field in changes and changes[field] is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"{field} cannot be null",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
item = update_service_account(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
service_account_id=service_account_id,
|
||||||
|
principal=principal,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
changes=changes,
|
||||||
|
)
|
||||||
|
except (ServiceAccountError, PermissionError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _service_account_http_error(exc) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="service_account.updated",
|
||||||
|
scope="tenant",
|
||||||
|
object_type="service_account",
|
||||||
|
object_id=item.id,
|
||||||
|
details={
|
||||||
|
"changed_fields": sorted(changes),
|
||||||
|
"revision": item.revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _service_account_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{service_account_id}/retire",
|
||||||
|
response_model=ServiceAccountItem,
|
||||||
|
)
|
||||||
|
def retire_managed_service_account(
|
||||||
|
service_account_id: str,
|
||||||
|
payload: ServiceAccountRetireRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_scope("access:service_account:write")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
tenant = _resolve_tenant(session, principal, None)
|
||||||
|
try:
|
||||||
|
item = retire_service_account(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
service_account_id=service_account_id,
|
||||||
|
principal=principal,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
)
|
||||||
|
except (ServiceAccountError, PermissionError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _service_account_http_error(exc) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="service_account.retired",
|
||||||
|
scope="tenant",
|
||||||
|
object_type="service_account",
|
||||||
|
object_id=item.id,
|
||||||
|
details={"revision": item.revision},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return _service_account_item(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{service_account_id}/credentials",
|
||||||
|
response_model=ServiceAccountCredentialListResponse,
|
||||||
|
)
|
||||||
|
def list_managed_service_account_credentials(
|
||||||
|
service_account_id: str,
|
||||||
|
include_revoked: bool = Query(default=True),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_scope("access:service_account:read")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
tenant = _resolve_tenant(session, principal, None)
|
||||||
|
try:
|
||||||
|
item, credentials = list_service_account_credentials(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
service_account_id=service_account_id,
|
||||||
|
include_revoked=include_revoked,
|
||||||
|
)
|
||||||
|
except ServiceAccountError as exc:
|
||||||
|
raise _service_account_http_error(exc) from exc
|
||||||
|
return ServiceAccountCredentialListResponse(
|
||||||
|
service_account_revision=item.revision,
|
||||||
|
items=[_credential_item(credential) for credential in credentials],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{service_account_id}/credentials",
|
||||||
|
response_model=ServiceAccountCredentialSecretResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_managed_service_account_credential(
|
||||||
|
service_account_id: str,
|
||||||
|
payload: ServiceAccountCredentialCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_scope("access:service_account:write")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
tenant = _resolve_tenant(session, principal, None)
|
||||||
|
try:
|
||||||
|
assert_api_keys_allowed(session, tenant)
|
||||||
|
item, created = create_service_account_credential(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
service_account_id=service_account_id,
|
||||||
|
principal=principal,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
name=payload.name,
|
||||||
|
scopes=payload.scopes,
|
||||||
|
expires_at=payload.expires_at,
|
||||||
|
)
|
||||||
|
except (ServiceAccountError, PermissionError, AdminConflictError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _service_account_http_error(exc) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="service_account.credential_created",
|
||||||
|
scope="tenant",
|
||||||
|
object_type="service_account_credential",
|
||||||
|
object_id=created.model.id,
|
||||||
|
details={
|
||||||
|
"service_account_id": item.id,
|
||||||
|
"prefix": created.model.prefix,
|
||||||
|
"scopes": list(created.model.scopes),
|
||||||
|
"service_account_revision": item.revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return ServiceAccountCredentialSecretResponse(
|
||||||
|
service_account_revision=item.revision,
|
||||||
|
credential=_credential_item(created.model),
|
||||||
|
secret=created.secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{service_account_id}/credentials/{credential_id}/rotate",
|
||||||
|
response_model=ServiceAccountCredentialSecretResponse,
|
||||||
|
)
|
||||||
|
def rotate_managed_service_account_credential(
|
||||||
|
service_account_id: str,
|
||||||
|
credential_id: str,
|
||||||
|
payload: ServiceAccountCredentialRotateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_scope("access:service_account:write")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
tenant = _resolve_tenant(session, principal, None)
|
||||||
|
try:
|
||||||
|
assert_api_keys_allowed(session, tenant)
|
||||||
|
item, previous, created = rotate_service_account_credential(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
service_account_id=service_account_id,
|
||||||
|
credential_id=credential_id,
|
||||||
|
principal=principal,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
name=payload.name,
|
||||||
|
scopes=payload.scopes,
|
||||||
|
expires_at=payload.expires_at,
|
||||||
|
)
|
||||||
|
except (ServiceAccountError, PermissionError, AdminConflictError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _service_account_http_error(exc) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="service_account.credential_rotated",
|
||||||
|
scope="tenant",
|
||||||
|
object_type="service_account_credential",
|
||||||
|
object_id=created.model.id,
|
||||||
|
details={
|
||||||
|
"service_account_id": item.id,
|
||||||
|
"previous_credential_id": previous.id,
|
||||||
|
"prefix": created.model.prefix,
|
||||||
|
"scopes": list(created.model.scopes),
|
||||||
|
"service_account_revision": item.revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return ServiceAccountCredentialSecretResponse(
|
||||||
|
service_account_revision=item.revision,
|
||||||
|
credential=_credential_item(created.model),
|
||||||
|
secret=created.secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{service_account_id}/credentials/{credential_id}/revoke",
|
||||||
|
response_model=ServiceAccountCredentialMutationResponse,
|
||||||
|
)
|
||||||
|
def revoke_managed_service_account_credential(
|
||||||
|
service_account_id: str,
|
||||||
|
credential_id: str,
|
||||||
|
payload: ServiceAccountCredentialRevokeRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_scope("access:service_account:write")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
tenant = _resolve_tenant(session, principal, None)
|
||||||
|
try:
|
||||||
|
item, credential = revoke_service_account_credential(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
service_account_id=service_account_id,
|
||||||
|
credential_id=credential_id,
|
||||||
|
principal=principal,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
)
|
||||||
|
except (ServiceAccountError, PermissionError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _service_account_http_error(exc) from exc
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="service_account.credential_revoked",
|
||||||
|
scope="tenant",
|
||||||
|
object_type="service_account_credential",
|
||||||
|
object_id=credential.id,
|
||||||
|
details={
|
||||||
|
"service_account_id": item.id,
|
||||||
|
"prefix": credential.prefix,
|
||||||
|
"service_account_revision": item.revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return ServiceAccountCredentialMutationResponse(
|
||||||
|
service_account_revision=item.revision,
|
||||||
|
credential=_credential_item(credential),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _service_account_item(
|
||||||
|
item: object,
|
||||||
|
summary: ServiceAccountCredentialSummary | None = None,
|
||||||
|
) -> ServiceAccountItem:
|
||||||
|
values = ServiceAccountItem.model_validate(
|
||||||
|
item, from_attributes=True
|
||||||
|
)
|
||||||
|
if summary is None:
|
||||||
|
return values
|
||||||
|
return values.model_copy(
|
||||||
|
update={
|
||||||
|
"credential_count": summary.credential_count,
|
||||||
|
"active_credential_count": summary.active_credential_count,
|
||||||
|
"last_credential_used_at": summary.last_credential_used_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _credential_item(item: object) -> ServiceAccountCredentialItem:
|
||||||
|
return ServiceAccountCredentialItem.model_validate(
|
||||||
|
item,
|
||||||
|
from_attributes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _service_account_http_error(exc: Exception) -> HTTPException:
|
||||||
|
if isinstance(exc, ServiceAccountNotFoundError):
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
if isinstance(exc, ServiceAccountCredentialNotFoundError):
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
if isinstance(exc, ServiceAccountConflictError):
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
if isinstance(exc, PermissionError):
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
if isinstance(exc, AdminConflictError):
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, Request, status
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -15,15 +17,32 @@ from govoplan_core.core.access import (
|
|||||||
PrincipalRef,
|
PrincipalRef,
|
||||||
PrincipalResolver,
|
PrincipalResolver,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.automation import (
|
||||||
|
AutomationPrincipalRequest,
|
||||||
|
AutomationPrincipalResolution,
|
||||||
|
)
|
||||||
from govoplan_core.core.identity import IdentityDirectory
|
from govoplan_core.core.identity import IdentityDirectory
|
||||||
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
|
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
|
||||||
from govoplan_core.core.organizations import OrganizationDirectory
|
from govoplan_core.core.organizations import OrganizationDirectory
|
||||||
|
from govoplan_core.core.principal_cache import (
|
||||||
|
auth_principal_revision,
|
||||||
|
)
|
||||||
from govoplan_core.core.modules import AccessDecision
|
from govoplan_core.core.modules import AccessDecision
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
||||||
from govoplan_core.db.session import get_database, get_session
|
from govoplan_core.db.session import get_database, get_session
|
||||||
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Role, Tenant, User
|
from govoplan_access.backend.db.models import (
|
||||||
|
Account,
|
||||||
|
ApiKey,
|
||||||
|
AuthSession,
|
||||||
|
Role,
|
||||||
|
ServiceAccount,
|
||||||
|
Tenant,
|
||||||
|
User,
|
||||||
|
)
|
||||||
from govoplan_access.backend.semantic import collect_external_function_roles, identity_id_for_account
|
from govoplan_access.backend.semantic import collect_external_function_roles, identity_id_for_account
|
||||||
|
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||||
|
from govoplan_access.backend.auth.tokens import hash_secret
|
||||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||||
from govoplan_access.backend.security.sessions import (
|
from govoplan_access.backend.security.sessions import (
|
||||||
authenticate_session_token,
|
authenticate_session_token,
|
||||||
@@ -32,6 +51,7 @@ from govoplan_access.backend.security.sessions import (
|
|||||||
verify_auth_session_csrf,
|
verify_auth_session_csrf,
|
||||||
)
|
)
|
||||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||||
|
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||||
from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes
|
from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
@@ -90,6 +110,10 @@ def _build_principal_ref(
|
|||||||
role_ids = [role.id for role in authorization_context.tenant_roles]
|
role_ids = [role.id for role in authorization_context.tenant_roles]
|
||||||
if include_system_roles:
|
if include_system_roles:
|
||||||
role_ids.extend(role.id for role in authorization_context.system_roles)
|
role_ids.extend(role.id for role in authorization_context.system_roles)
|
||||||
|
acting_assignment = next(
|
||||||
|
(item for item in idm_assignments if item.source == "acting_for"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
return PrincipalRef(
|
return PrincipalRef(
|
||||||
account_id=account.id,
|
account_id=account.id,
|
||||||
membership_id=user.id,
|
membership_id=user.id,
|
||||||
@@ -103,6 +127,10 @@ def _build_principal_ref(
|
|||||||
auth_method=auth_method, # type: ignore[arg-type]
|
auth_method=auth_method, # type: ignore[arg-type]
|
||||||
api_key_id=api_key.id if api_key else None,
|
api_key_id=api_key.id if api_key else None,
|
||||||
session_id=auth_session.id if auth_session else None,
|
session_id=auth_session.id if auth_session else None,
|
||||||
|
acting_assignment_id=(acting_assignment.id if acting_assignment else None),
|
||||||
|
acting_for_account_id=(
|
||||||
|
acting_assignment.acting_for_account_id if acting_assignment else None
|
||||||
|
),
|
||||||
email=account.email,
|
email=account.email,
|
||||||
display_name=account.display_name or user.display_name,
|
display_name=account.display_name or user.display_name,
|
||||||
)
|
)
|
||||||
@@ -191,6 +219,15 @@ def _resolve_legacy_principal_context(
|
|||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
|
||||||
|
|
||||||
|
cached = _cached_principal_context(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
token=token,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
if source != "cookie":
|
if source != "cookie":
|
||||||
context = _resolve_api_key_principal_context(
|
context = _resolve_api_key_principal_context(
|
||||||
session,
|
session,
|
||||||
@@ -200,7 +237,14 @@ def _resolve_legacy_principal_context(
|
|||||||
organization_directory=organization_directory,
|
organization_directory=organization_directory,
|
||||||
)
|
)
|
||||||
if context is not None:
|
if context is not None:
|
||||||
return context
|
return _cache_resolved_principal_context(
|
||||||
|
session,
|
||||||
|
token=token,
|
||||||
|
context=context,
|
||||||
|
idm_directory=idm_directory,
|
||||||
|
identity_directory=identity_directory,
|
||||||
|
organization_directory=organization_directory,
|
||||||
|
)
|
||||||
|
|
||||||
context = _resolve_session_principal_context(
|
context = _resolve_session_principal_context(
|
||||||
request,
|
request,
|
||||||
@@ -212,11 +256,227 @@ def _resolve_legacy_principal_context(
|
|||||||
organization_directory=organization_directory,
|
organization_directory=organization_directory,
|
||||||
)
|
)
|
||||||
if context is not None:
|
if context is not None:
|
||||||
return context
|
return _cache_resolved_principal_context(
|
||||||
|
session,
|
||||||
|
token=token,
|
||||||
|
context=context,
|
||||||
|
idm_directory=idm_directory,
|
||||||
|
identity_directory=identity_directory,
|
||||||
|
organization_directory=organization_directory,
|
||||||
|
)
|
||||||
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
|
||||||
|
|
||||||
|
|
||||||
|
def _cached_principal_context(
|
||||||
|
request: Request,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
source: str,
|
||||||
|
) -> ResolvedPrincipalContext | None:
|
||||||
|
if not settings.auth_principal_cache_enabled:
|
||||||
|
return None
|
||||||
|
token_digest = hash_secret(token)
|
||||||
|
entry = principal_summary_cache.get(
|
||||||
|
token_digest,
|
||||||
|
session_ttl_seconds=settings.auth_principal_cache_session_ttl_seconds,
|
||||||
|
api_key_ttl_seconds=settings.auth_principal_cache_api_key_ttl_seconds,
|
||||||
|
)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
before = auth_principal_revision(session, tenant_id=entry.principal.tenant_id)
|
||||||
|
if before != entry.revision:
|
||||||
|
principal_summary_cache.discard(token_digest)
|
||||||
|
return None
|
||||||
|
context = _rehydrate_cached_principal(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
token_digest=token_digest,
|
||||||
|
source=source,
|
||||||
|
principal=entry.principal,
|
||||||
|
)
|
||||||
|
after = auth_principal_revision(session, tenant_id=entry.principal.tenant_id)
|
||||||
|
if context is None or after != before:
|
||||||
|
principal_summary_cache.discard(token_digest)
|
||||||
|
return None
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def _rehydrate_cached_principal(
|
||||||
|
request: Request,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
token_digest: str,
|
||||||
|
source: str,
|
||||||
|
principal: PrincipalRef,
|
||||||
|
) -> ResolvedPrincipalContext | None:
|
||||||
|
account = session.get(Account, principal.account_id)
|
||||||
|
user = session.get(User, principal.membership_id) if principal.membership_id else None
|
||||||
|
tenant = session.get(Tenant, principal.tenant_id) if principal.tenant_id else None
|
||||||
|
if (
|
||||||
|
not account
|
||||||
|
or not user
|
||||||
|
or not tenant
|
||||||
|
or not account.is_active
|
||||||
|
or not user.is_active
|
||||||
|
or not tenant.is_active
|
||||||
|
or user.account_id != account.id
|
||||||
|
or user.tenant_id != tenant.id
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if principal.auth_method == "api_key":
|
||||||
|
api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None
|
||||||
|
if (
|
||||||
|
api_key is None
|
||||||
|
or api_key.key_hash != token_digest
|
||||||
|
or api_key.revoked_at is not None
|
||||||
|
or api_key.user_id != user.id
|
||||||
|
or api_key.tenant_id != tenant.id
|
||||||
|
or _is_expired(api_key.expires_at, allow_none=True)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
_touch_auth_activity(
|
||||||
|
session,
|
||||||
|
api_key,
|
||||||
|
field="last_used_at",
|
||||||
|
)
|
||||||
|
return ResolvedPrincipalContext(
|
||||||
|
principal=principal,
|
||||||
|
account=account,
|
||||||
|
user=user,
|
||||||
|
tenant=tenant,
|
||||||
|
api_key=api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_session = session.get(AuthSession, principal.session_id) if principal.session_id else None
|
||||||
|
if (
|
||||||
|
auth_session is None
|
||||||
|
or auth_session.token_hash != token_digest
|
||||||
|
or auth_session.revoked_at is not None
|
||||||
|
or auth_session.user_id != user.id
|
||||||
|
or auth_session.account_id != account.id
|
||||||
|
or auth_session.tenant_id != tenant.id
|
||||||
|
or _is_expired(auth_session.expires_at)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
if source == "cookie":
|
||||||
|
_verify_session_csrf(request, auth_session)
|
||||||
|
_touch_auth_activity(
|
||||||
|
session,
|
||||||
|
auth_session,
|
||||||
|
field="last_seen_at",
|
||||||
|
)
|
||||||
|
return ResolvedPrincipalContext(
|
||||||
|
principal=principal,
|
||||||
|
account=account,
|
||||||
|
user=user,
|
||||||
|
tenant=tenant,
|
||||||
|
auth_session=auth_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_expired(value, *, allow_none: bool = False) -> bool:
|
||||||
|
expires_at = ensure_aware_utc(value)
|
||||||
|
return (not allow_none and expires_at is None) or (
|
||||||
|
expires_at is not None and expires_at < utc_now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _touch_auth_activity(
|
||||||
|
session: Session,
|
||||||
|
model: ApiKey | AuthSession,
|
||||||
|
*,
|
||||||
|
field: str,
|
||||||
|
) -> None:
|
||||||
|
now = utc_now()
|
||||||
|
previous = ensure_aware_utc(getattr(model, field))
|
||||||
|
interval = settings.auth_activity_touch_interval_seconds
|
||||||
|
if interval <= 0 or previous is None or now - previous >= timedelta(seconds=interval):
|
||||||
|
setattr(model, field, now)
|
||||||
|
session.add(model)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_resolved_principal_context(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
context: ResolvedPrincipalContext,
|
||||||
|
idm_directory: IdmDirectory | None,
|
||||||
|
identity_directory: IdentityDirectory | None,
|
||||||
|
organization_directory: OrganizationDirectory | None,
|
||||||
|
) -> ResolvedPrincipalContext:
|
||||||
|
if not settings.auth_principal_cache_enabled:
|
||||||
|
return context
|
||||||
|
before = auth_principal_revision(session, tenant_id=context.principal.tenant_id)
|
||||||
|
refreshed = _refresh_principal_context(
|
||||||
|
session,
|
||||||
|
context=context,
|
||||||
|
idm_directory=idm_directory,
|
||||||
|
identity_directory=identity_directory,
|
||||||
|
organization_directory=organization_directory,
|
||||||
|
)
|
||||||
|
after = auth_principal_revision(session, tenant_id=context.principal.tenant_id)
|
||||||
|
if before == after:
|
||||||
|
principal_summary_cache.put(
|
||||||
|
hash_secret(token),
|
||||||
|
principal=refreshed.principal,
|
||||||
|
revision=after,
|
||||||
|
max_entries=settings.auth_principal_cache_max_entries,
|
||||||
|
)
|
||||||
|
return refreshed
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_principal_context(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
context: ResolvedPrincipalContext,
|
||||||
|
idm_directory: IdmDirectory | None,
|
||||||
|
identity_directory: IdentityDirectory | None,
|
||||||
|
organization_directory: OrganizationDirectory | None,
|
||||||
|
) -> ResolvedPrincipalContext:
|
||||||
|
idm_assignments, idm_roles = _principal_idm_context(
|
||||||
|
session,
|
||||||
|
user=context.user,
|
||||||
|
account=context.account,
|
||||||
|
tenant_id=context.tenant.id,
|
||||||
|
idm_directory=idm_directory,
|
||||||
|
organization_directory=organization_directory,
|
||||||
|
auth_session=context.auth_session,
|
||||||
|
)
|
||||||
|
include_system = context.auth_session is not None
|
||||||
|
authorization_context = collect_user_authorization_context(
|
||||||
|
session,
|
||||||
|
context.user,
|
||||||
|
account=context.account,
|
||||||
|
include_system=include_system,
|
||||||
|
extra_roles=idm_roles,
|
||||||
|
)
|
||||||
|
scopes = authorization_context.scopes
|
||||||
|
auth_method = "session"
|
||||||
|
if context.api_key is not None:
|
||||||
|
scopes = intersect_api_key_scopes(scopes, context.api_key.scopes or [])
|
||||||
|
auth_method = "api_key"
|
||||||
|
principal = _build_principal_ref(
|
||||||
|
session,
|
||||||
|
account=context.account,
|
||||||
|
user=context.user,
|
||||||
|
tenant_id=context.tenant.id,
|
||||||
|
scopes=scopes,
|
||||||
|
auth_method=auth_method,
|
||||||
|
api_key=context.api_key,
|
||||||
|
auth_session=context.auth_session,
|
||||||
|
idm_assignments=idm_assignments,
|
||||||
|
identity_directory=identity_directory,
|
||||||
|
extra_roles=idm_roles,
|
||||||
|
authorization_context=authorization_context,
|
||||||
|
include_system_roles=include_system,
|
||||||
|
)
|
||||||
|
return replace(context, principal=principal)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_api_key_principal_ref(
|
def _resolve_api_key_principal_ref(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -245,11 +505,19 @@ def _resolve_api_key_principal_context(
|
|||||||
) -> ResolvedPrincipalContext | None:
|
) -> ResolvedPrincipalContext | None:
|
||||||
# API keys remain supported for CLI/automation. Their permissions are the
|
# API keys remain supported for CLI/automation. Their permissions are the
|
||||||
# intersection of the key grant and the owner's current tenant roles.
|
# intersection of the key grant and the owner's current tenant roles.
|
||||||
api_key = authenticate_api_key(session, token)
|
api_key = authenticate_api_key(
|
||||||
|
session,
|
||||||
|
token,
|
||||||
|
touch_interval_seconds=settings.auth_activity_touch_interval_seconds,
|
||||||
|
)
|
||||||
if api_key is None:
|
if api_key is None:
|
||||||
return None
|
return None
|
||||||
user = session.get(User, api_key.user_id)
|
activity_touch_pending = session.is_modified(
|
||||||
account = session.get(Account, user.account_id) if user else None
|
api_key,
|
||||||
|
include_collections=False,
|
||||||
|
)
|
||||||
|
user = api_key.user
|
||||||
|
account = user.account if user else None
|
||||||
tenant = session.get(Tenant, api_key.tenant_id)
|
tenant = session.get(Tenant, api_key.tenant_id)
|
||||||
if (
|
if (
|
||||||
not user or not account or not tenant
|
not user or not account or not tenant
|
||||||
@@ -257,6 +525,18 @@ def _resolve_api_key_principal_context(
|
|||||||
or user.tenant_id != api_key.tenant_id
|
or user.tenant_id != api_key.tenant_id
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal")
|
||||||
|
if (
|
||||||
|
account.auth_provider == "service_account"
|
||||||
|
or user.auth_provider == "service_account"
|
||||||
|
):
|
||||||
|
return _resolve_service_account_credential_context(
|
||||||
|
session,
|
||||||
|
api_key=api_key,
|
||||||
|
account=account,
|
||||||
|
user=user,
|
||||||
|
tenant=tenant,
|
||||||
|
activity_touch_pending=activity_touch_pending,
|
||||||
|
)
|
||||||
idm_assignments, idm_roles = _principal_idm_context(
|
idm_assignments, idm_roles = _principal_idm_context(
|
||||||
session,
|
session,
|
||||||
user=user,
|
user=user,
|
||||||
@@ -264,6 +544,7 @@ def _resolve_api_key_principal_context(
|
|||||||
tenant_id=api_key.tenant_id,
|
tenant_id=api_key.tenant_id,
|
||||||
idm_directory=idm_directory,
|
idm_directory=idm_directory,
|
||||||
organization_directory=organization_directory,
|
organization_directory=organization_directory,
|
||||||
|
auth_session=None,
|
||||||
)
|
)
|
||||||
authorization_context = collect_user_authorization_context(
|
authorization_context = collect_user_authorization_context(
|
||||||
session,
|
session,
|
||||||
@@ -273,7 +554,6 @@ def _resolve_api_key_principal_context(
|
|||||||
extra_roles=idm_roles,
|
extra_roles=idm_roles,
|
||||||
)
|
)
|
||||||
effective_scopes = intersect_api_key_scopes(authorization_context.scopes, api_key.scopes or [])
|
effective_scopes = intersect_api_key_scopes(authorization_context.scopes, api_key.scopes or [])
|
||||||
session.commit()
|
|
||||||
principal = _build_principal_ref(
|
principal = _build_principal_ref(
|
||||||
session,
|
session,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
@@ -287,9 +567,66 @@ def _resolve_api_key_principal_context(
|
|||||||
extra_roles=idm_roles,
|
extra_roles=idm_roles,
|
||||||
authorization_context=authorization_context,
|
authorization_context=authorization_context,
|
||||||
)
|
)
|
||||||
|
if activity_touch_pending:
|
||||||
|
session.commit()
|
||||||
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key)
|
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_service_account_credential_context(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
api_key: ApiKey,
|
||||||
|
account: Account,
|
||||||
|
user: User,
|
||||||
|
tenant: Tenant,
|
||||||
|
activity_touch_pending: bool,
|
||||||
|
) -> ResolvedPrincipalContext:
|
||||||
|
item = (
|
||||||
|
session.query(ServiceAccount)
|
||||||
|
.filter(
|
||||||
|
ServiceAccount.tenant_id == tenant.id,
|
||||||
|
ServiceAccount.account_id == account.id,
|
||||||
|
ServiceAccount.membership_id == user.id,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
item is None
|
||||||
|
or account.auth_provider != "service_account"
|
||||||
|
or user.auth_provider != "service_account"
|
||||||
|
or not item.is_active
|
||||||
|
or item.retired_at is not None
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Inactive or inconsistent service-account credential",
|
||||||
|
)
|
||||||
|
effective_scopes = intersect_api_key_scopes(
|
||||||
|
item.scope_ceiling,
|
||||||
|
api_key.scopes or [],
|
||||||
|
)
|
||||||
|
principal = PrincipalRef(
|
||||||
|
account_id=account.id,
|
||||||
|
membership_id=user.id,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
scopes=frozenset(effective_scopes),
|
||||||
|
auth_method="service_account",
|
||||||
|
api_key_id=api_key.id,
|
||||||
|
service_account_id=item.id,
|
||||||
|
email=None,
|
||||||
|
display_name=item.name,
|
||||||
|
)
|
||||||
|
if activity_touch_pending:
|
||||||
|
session.commit()
|
||||||
|
return ResolvedPrincipalContext(
|
||||||
|
principal=principal,
|
||||||
|
account=account,
|
||||||
|
user=user,
|
||||||
|
tenant=tenant,
|
||||||
|
api_key=api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_session_principal_ref(
|
def _resolve_session_principal_ref(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -322,11 +659,19 @@ def _resolve_session_principal_context(
|
|||||||
identity_directory: IdentityDirectory | None,
|
identity_directory: IdentityDirectory | None,
|
||||||
organization_directory: OrganizationDirectory | None,
|
organization_directory: OrganizationDirectory | None,
|
||||||
) -> ResolvedPrincipalContext | None:
|
) -> ResolvedPrincipalContext | None:
|
||||||
auth_session = authenticate_session_token(session, token)
|
auth_session = authenticate_session_token(
|
||||||
|
session,
|
||||||
|
token,
|
||||||
|
touch_interval_seconds=settings.auth_activity_touch_interval_seconds,
|
||||||
|
)
|
||||||
if auth_session is None:
|
if auth_session is None:
|
||||||
return None
|
return None
|
||||||
user = session.get(User, auth_session.user_id)
|
activity_touch_pending = session.is_modified(
|
||||||
account = session.get(Account, auth_session.account_id)
|
auth_session,
|
||||||
|
include_collections=False,
|
||||||
|
)
|
||||||
|
user = auth_session.user
|
||||||
|
account = auth_session.account
|
||||||
tenant = session.get(Tenant, auth_session.tenant_id)
|
tenant = session.get(Tenant, auth_session.tenant_id)
|
||||||
if (
|
if (
|
||||||
not user or not account or not tenant
|
not user or not account or not tenant
|
||||||
@@ -344,6 +689,7 @@ def _resolve_session_principal_context(
|
|||||||
tenant_id=user.tenant_id,
|
tenant_id=user.tenant_id,
|
||||||
idm_directory=idm_directory,
|
idm_directory=idm_directory,
|
||||||
organization_directory=organization_directory,
|
organization_directory=organization_directory,
|
||||||
|
auth_session=auth_session,
|
||||||
)
|
)
|
||||||
authorization_context = collect_user_authorization_context(
|
authorization_context = collect_user_authorization_context(
|
||||||
session,
|
session,
|
||||||
@@ -353,7 +699,6 @@ def _resolve_session_principal_context(
|
|||||||
extra_roles=idm_roles,
|
extra_roles=idm_roles,
|
||||||
)
|
)
|
||||||
scopes = authorization_context.scopes
|
scopes = authorization_context.scopes
|
||||||
session.commit()
|
|
||||||
principal = _build_principal_ref(
|
principal = _build_principal_ref(
|
||||||
session,
|
session,
|
||||||
auth_session=auth_session,
|
auth_session=auth_session,
|
||||||
@@ -368,6 +713,8 @@ def _resolve_session_principal_context(
|
|||||||
authorization_context=authorization_context,
|
authorization_context=authorization_context,
|
||||||
include_system_roles=True,
|
include_system_roles=True,
|
||||||
)
|
)
|
||||||
|
if activity_touch_pending:
|
||||||
|
session.commit()
|
||||||
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, auth_session=auth_session)
|
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, auth_session=auth_session)
|
||||||
|
|
||||||
|
|
||||||
@@ -388,8 +735,26 @@ def _principal_idm_context(
|
|||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
idm_directory: IdmDirectory | None,
|
idm_directory: IdmDirectory | None,
|
||||||
organization_directory: OrganizationDirectory | None,
|
organization_directory: OrganizationDirectory | None,
|
||||||
|
auth_session: AuthSession | None = None,
|
||||||
) -> tuple[tuple[OrganizationFunctionAssignmentRef, ...], tuple[Role, ...]]:
|
) -> tuple[tuple[OrganizationFunctionAssignmentRef, ...], tuple[Role, ...]]:
|
||||||
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=tenant_id)
|
available = _idm_assignments_for_account(
|
||||||
|
idm_directory,
|
||||||
|
account.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
selected_assignment_id = (
|
||||||
|
auth_session.acting_assignment_id if auth_session is not None else None
|
||||||
|
)
|
||||||
|
idm_assignments = tuple(
|
||||||
|
item
|
||||||
|
for item in available
|
||||||
|
if item.source != "acting_for"
|
||||||
|
or (
|
||||||
|
selected_assignment_id == item.id
|
||||||
|
and auth_session is not None
|
||||||
|
and auth_session.acting_for_account_id == item.acting_for_account_id
|
||||||
|
)
|
||||||
|
)
|
||||||
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory))
|
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory))
|
||||||
return idm_assignments, idm_roles
|
return idm_assignments, idm_roles
|
||||||
|
|
||||||
@@ -558,6 +923,369 @@ class AccessApiPrincipalProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccessAutomationPrincipalProvider:
|
||||||
|
"""Rebuild a trigger owner against current tenant authorization."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
idm_directory: IdmDirectory | None = None,
|
||||||
|
identity_directory: IdentityDirectory | None = None,
|
||||||
|
organization_directory: OrganizationDirectory | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._idm_directory = idm_directory
|
||||||
|
self._identity_directory = identity_directory
|
||||||
|
self._organization_directory = organization_directory
|
||||||
|
|
||||||
|
def resolve_automation_principal(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: AutomationPrincipalRequest,
|
||||||
|
) -> AutomationPrincipalResolution:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise TypeError(
|
||||||
|
"Access automation principal resolution requires a "
|
||||||
|
"SQLAlchemy session"
|
||||||
|
)
|
||||||
|
if request.subject_kind == "service_account":
|
||||||
|
return _resolve_service_account_automation(
|
||||||
|
session,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return _resolve_delegated_user_automation(
|
||||||
|
session,
|
||||||
|
request=request,
|
||||||
|
idm_directory=self._idm_directory,
|
||||||
|
identity_directory=self._identity_directory,
|
||||||
|
organization_directory=self._organization_directory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_delegated_user_automation(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
request: AutomationPrincipalRequest,
|
||||||
|
idm_directory: IdmDirectory | None,
|
||||||
|
identity_directory: IdentityDirectory | None,
|
||||||
|
organization_directory: OrganizationDirectory | None,
|
||||||
|
) -> AutomationPrincipalResolution:
|
||||||
|
account = session.get(Account, request.account_id)
|
||||||
|
user = session.get(User, request.membership_id)
|
||||||
|
tenant = session.get(Tenant, request.tenant_id)
|
||||||
|
if (
|
||||||
|
account is None
|
||||||
|
or user is None
|
||||||
|
or tenant is None
|
||||||
|
or not account.is_active
|
||||||
|
or not user.is_active
|
||||||
|
or not tenant.is_active
|
||||||
|
or user.account_id != account.id
|
||||||
|
or user.tenant_id != tenant.id
|
||||||
|
):
|
||||||
|
return _automation_denied(
|
||||||
|
request,
|
||||||
|
status="inactive_or_inconsistent",
|
||||||
|
reason=(
|
||||||
|
"The automation owner is inactive, missing, or no longer "
|
||||||
|
"belongs to the tenant."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
idm_assignments, idm_roles = _principal_idm_context(
|
||||||
|
session,
|
||||||
|
user=user,
|
||||||
|
account=account,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
idm_directory=idm_directory,
|
||||||
|
organization_directory=organization_directory,
|
||||||
|
auth_session=None,
|
||||||
|
)
|
||||||
|
authorization_context = collect_user_authorization_context(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
account=account,
|
||||||
|
include_system=False,
|
||||||
|
extra_roles=idm_roles,
|
||||||
|
)
|
||||||
|
granted_scopes, missing_scopes = _current_trigger_grants(
|
||||||
|
request.grant_scopes,
|
||||||
|
current_scopes=authorization_context.scopes,
|
||||||
|
)
|
||||||
|
if missing_scopes:
|
||||||
|
return _automation_denied(
|
||||||
|
request,
|
||||||
|
status="authorization_reduced",
|
||||||
|
reason=(
|
||||||
|
"The automation owner no longer has every scope granted "
|
||||||
|
"to this trigger."
|
||||||
|
),
|
||||||
|
granted_scopes=granted_scopes,
|
||||||
|
missing_scopes=missing_scopes,
|
||||||
|
)
|
||||||
|
principal_ref = _build_principal_ref(
|
||||||
|
session,
|
||||||
|
account=account,
|
||||||
|
user=user,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
scopes=list(granted_scopes),
|
||||||
|
auth_method="service_account",
|
||||||
|
idm_assignments=idm_assignments,
|
||||||
|
identity_directory=identity_directory,
|
||||||
|
extra_roles=idm_roles,
|
||||||
|
authorization_context=authorization_context,
|
||||||
|
include_system_roles=False,
|
||||||
|
)
|
||||||
|
principal_ref = replace(
|
||||||
|
principal_ref,
|
||||||
|
service_account_id=None,
|
||||||
|
acting_for_account_id=account.id,
|
||||||
|
)
|
||||||
|
return _automation_allowed(
|
||||||
|
session,
|
||||||
|
request=request,
|
||||||
|
principal_ref=principal_ref,
|
||||||
|
granted_scopes=granted_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_service_account_automation(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
request: AutomationPrincipalRequest,
|
||||||
|
) -> AutomationPrincipalResolution:
|
||||||
|
item = session.get(ServiceAccount, request.service_account_id)
|
||||||
|
tenant = session.get(Tenant, request.tenant_id)
|
||||||
|
account = (
|
||||||
|
session.get(Account, item.account_id)
|
||||||
|
if item is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
user = (
|
||||||
|
session.get(User, item.membership_id)
|
||||||
|
if item is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
item is None
|
||||||
|
or tenant is None
|
||||||
|
or account is None
|
||||||
|
or user is None
|
||||||
|
or item.tenant_id != request.tenant_id
|
||||||
|
or not item.is_active
|
||||||
|
or not tenant.is_active
|
||||||
|
or not account.is_active
|
||||||
|
or not user.is_active
|
||||||
|
or item.account_id != account.id
|
||||||
|
or item.membership_id != user.id
|
||||||
|
or user.account_id != account.id
|
||||||
|
or user.tenant_id != tenant.id
|
||||||
|
or account.auth_provider != "service_account"
|
||||||
|
or user.auth_provider != "service_account"
|
||||||
|
):
|
||||||
|
return _automation_denied(
|
||||||
|
request,
|
||||||
|
status="inactive_or_inconsistent",
|
||||||
|
reason=(
|
||||||
|
"The service account is inactive, missing, or no longer "
|
||||||
|
"belongs to the tenant."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
granted_scopes, missing_scopes = _current_trigger_grants(
|
||||||
|
request.grant_scopes,
|
||||||
|
current_scopes=item.scope_ceiling,
|
||||||
|
)
|
||||||
|
if missing_scopes:
|
||||||
|
return _automation_denied(
|
||||||
|
request,
|
||||||
|
status="authorization_reduced",
|
||||||
|
reason=(
|
||||||
|
"The service account no longer has every scope granted "
|
||||||
|
"to this trigger."
|
||||||
|
),
|
||||||
|
granted_scopes=granted_scopes,
|
||||||
|
missing_scopes=missing_scopes,
|
||||||
|
)
|
||||||
|
principal_ref = PrincipalRef(
|
||||||
|
account_id=account.id,
|
||||||
|
membership_id=user.id,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
scopes=frozenset(granted_scopes),
|
||||||
|
auth_method="service_account",
|
||||||
|
service_account_id=item.id,
|
||||||
|
email=None,
|
||||||
|
display_name=item.name,
|
||||||
|
)
|
||||||
|
return _automation_allowed(
|
||||||
|
session,
|
||||||
|
request=request,
|
||||||
|
principal_ref=principal_ref,
|
||||||
|
granted_scopes=granted_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_trigger_grants(
|
||||||
|
trigger_scopes: tuple[str, ...],
|
||||||
|
*,
|
||||||
|
current_scopes: list[str] | tuple[str, ...],
|
||||||
|
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||||
|
granted = tuple(
|
||||||
|
sorted(
|
||||||
|
{
|
||||||
|
required
|
||||||
|
for required in trigger_scopes
|
||||||
|
if scopes_grant_compatible(
|
||||||
|
current_scopes,
|
||||||
|
required,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
missing = tuple(
|
||||||
|
sorted(set(trigger_scopes) - set(granted))
|
||||||
|
)
|
||||||
|
return granted, missing
|
||||||
|
|
||||||
|
|
||||||
|
def _automation_allowed(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
request: AutomationPrincipalRequest,
|
||||||
|
principal_ref: PrincipalRef,
|
||||||
|
granted_scopes: tuple[str, ...],
|
||||||
|
) -> AutomationPrincipalResolution:
|
||||||
|
api_principal = _api_principal_from_ref(
|
||||||
|
session,
|
||||||
|
principal_ref,
|
||||||
|
permission_evaluator=LegacyPermissionEvaluator(),
|
||||||
|
)
|
||||||
|
provenance = _automation_provenance(
|
||||||
|
request,
|
||||||
|
status="current_authorization_resolved",
|
||||||
|
current_principal={
|
||||||
|
"kind": request.subject_kind,
|
||||||
|
"account_id": principal_ref.account_id,
|
||||||
|
"membership_id": principal_ref.membership_id,
|
||||||
|
"service_account_id": principal_ref.service_account_id,
|
||||||
|
"role_ids": sorted(principal_ref.role_ids),
|
||||||
|
"group_ids": sorted(principal_ref.group_ids),
|
||||||
|
"function_assignment_ids": sorted(
|
||||||
|
principal_ref.function_assignment_ids
|
||||||
|
),
|
||||||
|
"delegation_ids": sorted(
|
||||||
|
principal_ref.delegation_ids
|
||||||
|
),
|
||||||
|
"granted_scopes": list(granted_scopes),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return AutomationPrincipalResolution(
|
||||||
|
allowed=True,
|
||||||
|
principal=api_principal,
|
||||||
|
granted_scopes=granted_scopes,
|
||||||
|
provenance=provenance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _automation_denied(
|
||||||
|
request: AutomationPrincipalRequest,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
reason: str,
|
||||||
|
granted_scopes: tuple[str, ...] = (),
|
||||||
|
missing_scopes: tuple[str, ...] | None = None,
|
||||||
|
) -> AutomationPrincipalResolution:
|
||||||
|
missing = (
|
||||||
|
tuple(sorted(set(request.grant_scopes)))
|
||||||
|
if missing_scopes is None
|
||||||
|
else missing_scopes
|
||||||
|
)
|
||||||
|
return AutomationPrincipalResolution(
|
||||||
|
allowed=False,
|
||||||
|
reason=reason,
|
||||||
|
granted_scopes=granted_scopes,
|
||||||
|
missing_scopes=missing,
|
||||||
|
provenance=_automation_provenance(
|
||||||
|
request,
|
||||||
|
status=status,
|
||||||
|
current_principal=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _automation_provenance(
|
||||||
|
request: AutomationPrincipalRequest,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
current_principal: Mapping[str, object] | None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"contract_version": request.contract_version,
|
||||||
|
"authorization_artifact": {
|
||||||
|
"ref": request.authorization_ref,
|
||||||
|
},
|
||||||
|
"trigger_owner": _automation_trigger_owner(request),
|
||||||
|
"current_automation_principal": (
|
||||||
|
dict(current_principal)
|
||||||
|
if current_principal is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"event_actor": _automation_context_actor(
|
||||||
|
request.context.get("event_actor")
|
||||||
|
),
|
||||||
|
"operator_override": _automation_context_actor(
|
||||||
|
request.context.get("operator_override")
|
||||||
|
),
|
||||||
|
"trigger_ref": _optional_context_text(
|
||||||
|
request.context.get("trigger_ref")
|
||||||
|
),
|
||||||
|
"delivery_ref": _optional_context_text(
|
||||||
|
request.context.get("delivery_ref")
|
||||||
|
),
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _automation_trigger_owner(
|
||||||
|
request: AutomationPrincipalRequest,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"kind": request.subject_kind,
|
||||||
|
"tenant_id": request.tenant_id,
|
||||||
|
"account_id": request.account_id,
|
||||||
|
"membership_id": request.membership_id,
|
||||||
|
"service_account_id": request.service_account_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _automation_context_actor(
|
||||||
|
value: object,
|
||||||
|
) -> dict[str, str] | None:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
return None
|
||||||
|
result = {
|
||||||
|
key: str(value[key]).strip()
|
||||||
|
for key in (
|
||||||
|
"kind",
|
||||||
|
"type",
|
||||||
|
"id",
|
||||||
|
"label",
|
||||||
|
"account_id",
|
||||||
|
"membership_id",
|
||||||
|
"service_account_id",
|
||||||
|
"reason",
|
||||||
|
)
|
||||||
|
if value.get(key) is not None
|
||||||
|
and str(value[key]).strip()
|
||||||
|
}
|
||||||
|
return result or None
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_context_text(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
clean = str(value).strip()
|
||||||
|
return clean[:300] or None
|
||||||
|
|
||||||
|
|
||||||
def get_api_principal(
|
def get_api_principal(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from threading import Lock
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.principal_cache import AuthPrincipalRevision
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CachedPrincipal:
|
||||||
|
principal: PrincipalRef
|
||||||
|
revision: AuthPrincipalRevision
|
||||||
|
stored_at: float
|
||||||
|
|
||||||
|
|
||||||
|
class PrincipalSummaryCache:
|
||||||
|
"""A bounded process-local cache containing no ORM or secret objects."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._entries: OrderedDict[str, CachedPrincipal] = OrderedDict()
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
token_digest: str,
|
||||||
|
*,
|
||||||
|
session_ttl_seconds: int,
|
||||||
|
api_key_ttl_seconds: int,
|
||||||
|
) -> CachedPrincipal | None:
|
||||||
|
with self._lock:
|
||||||
|
entry = self._entries.get(token_digest)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
ttl = (
|
||||||
|
api_key_ttl_seconds
|
||||||
|
if entry.principal.auth_method == "api_key"
|
||||||
|
else session_ttl_seconds
|
||||||
|
)
|
||||||
|
if ttl <= 0 or monotonic() - entry.stored_at > ttl:
|
||||||
|
self._entries.pop(token_digest, None)
|
||||||
|
return None
|
||||||
|
self._entries.move_to_end(token_digest)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def put(
|
||||||
|
self,
|
||||||
|
token_digest: str,
|
||||||
|
*,
|
||||||
|
principal: PrincipalRef,
|
||||||
|
revision: AuthPrincipalRevision,
|
||||||
|
max_entries: int,
|
||||||
|
) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._entries[token_digest] = CachedPrincipal(
|
||||||
|
principal=principal,
|
||||||
|
revision=revision,
|
||||||
|
stored_at=monotonic(),
|
||||||
|
)
|
||||||
|
self._entries.move_to_end(token_digest)
|
||||||
|
while len(self._entries) > max(1, max_entries):
|
||||||
|
self._entries.popitem(last=False)
|
||||||
|
|
||||||
|
def discard(self, token_digest: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._entries.pop(token_digest, None)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._entries.clear()
|
||||||
|
|
||||||
|
|
||||||
|
principal_summary_cache = PrincipalSummaryCache()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CachedPrincipal",
|
||||||
|
"PrincipalSummaryCache",
|
||||||
|
"principal_summary_cache",
|
||||||
|
]
|
||||||
@@ -4,7 +4,18 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, JSON, text
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
text,
|
||||||
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from govoplan_access.backend.db.base import AccessBase, TimestampMixin
|
from govoplan_access.backend.db.base import AccessBase, TimestampMixin
|
||||||
@@ -110,6 +121,91 @@ class User(AccessBase, TimestampMixin):
|
|||||||
auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccount(AccessBase, TimestampMixin):
|
||||||
|
"""Managed non-login principal for current-authority automation."""
|
||||||
|
|
||||||
|
__tablename__ = "access_service_accounts"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"normalized_name",
|
||||||
|
name="uq_access_service_accounts_tenant_name",
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"account_id",
|
||||||
|
name="uq_access_service_accounts_account",
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
"membership_id",
|
||||||
|
name="uq_access_service_accounts_membership",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
primary_key=True,
|
||||||
|
default=new_uuid,
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
account_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("access_accounts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
membership_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("access_users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
normalized_name: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
scope_ceiling: Mapped[list[str]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=list,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
is_active: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
default=True,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
revision: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
default=1,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
created_by_account_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("access_accounts.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
updated_by_account_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("access_accounts.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
retired_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
settings: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
default=dict,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Group(AccessBase, TimestampMixin):
|
class Group(AccessBase, TimestampMixin):
|
||||||
__tablename__ = "access_groups"
|
__tablename__ = "access_groups"
|
||||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_groups_tenant_slug"),)
|
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_groups_tenant_slug"),)
|
||||||
@@ -331,6 +427,8 @@ class AuthSession(AccessBase, TimestampMixin):
|
|||||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
acting_assignment_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
acting_for_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||||
token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
|
token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
|
||||||
csrf_token_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
csrf_token_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
@@ -358,6 +456,7 @@ __all__ = [
|
|||||||
"IdentityAccountLink",
|
"IdentityAccountLink",
|
||||||
"OrganizationUnit",
|
"OrganizationUnit",
|
||||||
"Role",
|
"Role",
|
||||||
|
"ServiceAccount",
|
||||||
"SystemRoleAssignment",
|
"SystemRoleAssignment",
|
||||||
"Tenant",
|
"Tenant",
|
||||||
"User",
|
"User",
|
||||||
|
|||||||
@@ -0,0 +1,601 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import (
|
||||||
|
Account,
|
||||||
|
ApiKey,
|
||||||
|
AuthSession,
|
||||||
|
Function,
|
||||||
|
FunctionAssignment,
|
||||||
|
Group,
|
||||||
|
Identity,
|
||||||
|
IdentityAccountLink,
|
||||||
|
OrganizationUnit,
|
||||||
|
Role,
|
||||||
|
SystemRoleAssignment,
|
||||||
|
User,
|
||||||
|
UserGroupMembership,
|
||||||
|
UserRoleAssignment,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ACCESS_DSAR_CAPABILITY = "privacy.dsar.access"
|
||||||
|
|
||||||
|
|
||||||
|
class AccessDsarProvider:
|
||||||
|
provider_id = "access"
|
||||||
|
module_id = "access"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
db = _session(session)
|
||||||
|
users = _subject_users(db, tenant_id=tenant_id, subject=subject)
|
||||||
|
records: list[DsarRecordRef] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
def append(record: DsarRecordRef) -> None:
|
||||||
|
key = (record.resource_type, record.resource_id)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"membership",
|
||||||
|
user.id,
|
||||||
|
"profile",
|
||||||
|
user.display_name or user.email,
|
||||||
|
{
|
||||||
|
"account_id": user.account_id,
|
||||||
|
"email": user.email,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"auth_provider": user.auth_provider,
|
||||||
|
"last_login_at": _iso(user.last_login_at),
|
||||||
|
"created_at": _iso(user.created_at),
|
||||||
|
"updated_at": _iso(user.updated_at),
|
||||||
|
},
|
||||||
|
observed_at=user.updated_at,
|
||||||
|
source_path=f"/admin?section=tenant-users&user={user.id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
account = db.get(Account, user.account_id)
|
||||||
|
if account is not None:
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"account",
|
||||||
|
account.id,
|
||||||
|
"global_identity",
|
||||||
|
account.display_name or account.email,
|
||||||
|
{
|
||||||
|
"email": account.email,
|
||||||
|
"display_name": account.display_name,
|
||||||
|
"is_active": account.is_active,
|
||||||
|
"auth_provider": account.auth_provider,
|
||||||
|
"last_login_at": _iso(account.last_login_at),
|
||||||
|
"created_at": _iso(account.created_at),
|
||||||
|
},
|
||||||
|
observed_at=account.updated_at,
|
||||||
|
source_path=f"/admin?section=system-users&account={account.id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_append_identity_records(db, append, account)
|
||||||
|
_append_system_role_records(db, append, account)
|
||||||
|
_append_api_key_records(db, append, user)
|
||||||
|
_append_session_records(db, append, user)
|
||||||
|
_append_group_records(db, append, user)
|
||||||
|
_append_role_records(db, append, user)
|
||||||
|
_append_function_records(db, append, user)
|
||||||
|
return tuple(records)
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]:
|
||||||
|
del session, subject
|
||||||
|
actions: list[DsarErasureActionRef] = []
|
||||||
|
for record in records:
|
||||||
|
if record.resource_type == "membership":
|
||||||
|
actions.append(
|
||||||
|
_action(
|
||||||
|
f"access:anonymize:membership:{record.resource_id}",
|
||||||
|
"anonymize",
|
||||||
|
record,
|
||||||
|
"Anonymize and deactivate the tenant membership",
|
||||||
|
"Tenant-local profile data can be removed without deleting stable evidence identifiers.",
|
||||||
|
executable=True,
|
||||||
|
irreversible=True,
|
||||||
|
metadata={"tenant_id": tenant_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif record.resource_type == "api_key" and record.data.get("active"):
|
||||||
|
actions.append(
|
||||||
|
_action(
|
||||||
|
f"access:revoke:api-key:{record.resource_id}",
|
||||||
|
"revoke",
|
||||||
|
record,
|
||||||
|
"Revoke API key",
|
||||||
|
"An active credential associated with the data subject must no longer authenticate.",
|
||||||
|
executable=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif record.resource_type == "auth_session" and record.data.get("active"):
|
||||||
|
actions.append(
|
||||||
|
_action(
|
||||||
|
f"access:revoke:session:{record.resource_id}",
|
||||||
|
"revoke",
|
||||||
|
record,
|
||||||
|
"Revoke login session",
|
||||||
|
"An active session associated with the data subject must no longer authenticate.",
|
||||||
|
executable=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif record.resource_type in {"account", "identity"}:
|
||||||
|
actions.append(
|
||||||
|
_action(
|
||||||
|
f"access:review:{record.resource_type}:{record.resource_id}",
|
||||||
|
"manual_review",
|
||||||
|
record,
|
||||||
|
f"Review global {record.resource_type}",
|
||||||
|
"Global identities may serve other tenants or legal obligations and require a system-level decision.",
|
||||||
|
executable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(actions)
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]:
|
||||||
|
del subject
|
||||||
|
db = _session(session)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
results: list[DsarExecutionResultRef] = []
|
||||||
|
for action in actions:
|
||||||
|
if action.action_id.startswith("access:anonymize:membership:"):
|
||||||
|
row = db.get(User, action.resource_id)
|
||||||
|
if row is None or row.tenant_id != tenant_id:
|
||||||
|
results.append(_blocked(action, "Tenant membership is no longer available."))
|
||||||
|
continue
|
||||||
|
replacement = _erased_email(tenant_id, row.id)
|
||||||
|
unchanged = (
|
||||||
|
row.email == replacement
|
||||||
|
and row.display_name == "Erased data subject"
|
||||||
|
and not row.is_active
|
||||||
|
)
|
||||||
|
row.email = replacement
|
||||||
|
row.display_name = "Erased data subject"
|
||||||
|
row.is_active = False
|
||||||
|
row.is_tenant_admin = False
|
||||||
|
row.password_hash = None
|
||||||
|
row.last_login_at = None
|
||||||
|
row.settings = {}
|
||||||
|
row.mail_profile_policy = {}
|
||||||
|
results.append(
|
||||||
|
_result(
|
||||||
|
action,
|
||||||
|
"unchanged" if unchanged else "executed",
|
||||||
|
"Tenant membership was already anonymized."
|
||||||
|
if unchanged
|
||||||
|
else "Tenant membership was anonymized and deactivated.",
|
||||||
|
{"request_id": request_id, "replacement_email": replacement},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif action.action_id.startswith("access:revoke:api-key:"):
|
||||||
|
row = db.get(ApiKey, action.resource_id)
|
||||||
|
if row is None or row.tenant_id != tenant_id:
|
||||||
|
results.append(_blocked(action, "API key is no longer available."))
|
||||||
|
continue
|
||||||
|
unchanged = row.revoked_at is not None
|
||||||
|
if row.revoked_at is None:
|
||||||
|
row.revoked_at = now
|
||||||
|
results.append(
|
||||||
|
_result(
|
||||||
|
action,
|
||||||
|
"unchanged" if unchanged else "executed",
|
||||||
|
"API key was already revoked." if unchanged else "API key was revoked.",
|
||||||
|
{"request_id": request_id, "revoked_at": _iso(row.revoked_at)},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif action.action_id.startswith("access:revoke:session:"):
|
||||||
|
row = db.get(AuthSession, action.resource_id)
|
||||||
|
if row is None or row.tenant_id != tenant_id:
|
||||||
|
results.append(_blocked(action, "Login session is no longer available."))
|
||||||
|
continue
|
||||||
|
unchanged = row.revoked_at is not None and not row.ip_address and not row.user_agent
|
||||||
|
if row.revoked_at is None:
|
||||||
|
row.revoked_at = now
|
||||||
|
row.ip_address = None
|
||||||
|
row.user_agent = None
|
||||||
|
row.csrf_token_hash = None
|
||||||
|
results.append(
|
||||||
|
_result(
|
||||||
|
action,
|
||||||
|
"unchanged" if unchanged else "executed",
|
||||||
|
"Login session was already revoked and redacted."
|
||||||
|
if unchanged
|
||||||
|
else "Login session was revoked and client metadata was redacted.",
|
||||||
|
{"request_id": request_id, "revoked_at": _iso(row.revoked_at)},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
results.append(_blocked(action, "Access does not execute this action kind."))
|
||||||
|
db.flush()
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_users(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> tuple[User, ...]:
|
||||||
|
candidate_sets: list[set[str]] = []
|
||||||
|
if subject.membership_id:
|
||||||
|
candidate_sets.append({
|
||||||
|
row[0]
|
||||||
|
for row in session.query(User.id).filter(
|
||||||
|
User.tenant_id == tenant_id,
|
||||||
|
User.id == subject.membership_id,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if subject.account_id:
|
||||||
|
candidate_sets.append({
|
||||||
|
row[0]
|
||||||
|
for row in session.query(User.id).filter(
|
||||||
|
User.tenant_id == tenant_id,
|
||||||
|
User.account_id == subject.account_id,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if subject.identity_id:
|
||||||
|
account_ids = {
|
||||||
|
row[0]
|
||||||
|
for row in session.query(IdentityAccountLink.account_id).filter(
|
||||||
|
IdentityAccountLink.identity_id == subject.identity_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
candidate_sets.append(
|
||||||
|
{
|
||||||
|
row[0]
|
||||||
|
for row in session.query(User.id).filter(
|
||||||
|
User.tenant_id == tenant_id,
|
||||||
|
User.account_id.in_(account_ids),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if account_ids
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
for key, value in subject.external_references.items():
|
||||||
|
if key in {"access.account", "account_id"}:
|
||||||
|
candidate_sets.append({
|
||||||
|
row[0]
|
||||||
|
for row in session.query(User.id).filter(
|
||||||
|
User.tenant_id == tenant_id,
|
||||||
|
User.account_id == value,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
elif key in {"access.membership", "membership_id"}:
|
||||||
|
candidate_sets.append({
|
||||||
|
row[0]
|
||||||
|
for row in session.query(User.id).filter(
|
||||||
|
User.tenant_id == tenant_id,
|
||||||
|
User.id == value,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if subject.email:
|
||||||
|
normalized = subject.email.strip().casefold()
|
||||||
|
matching_accounts = {
|
||||||
|
row[0]
|
||||||
|
for row in session.query(Account.id).filter(
|
||||||
|
Account.normalized_email == normalized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
email_matches = {
|
||||||
|
row[0]
|
||||||
|
for row in session.query(User.id).filter(
|
||||||
|
User.tenant_id == tenant_id,
|
||||||
|
func.lower(User.email) == normalized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if matching_accounts:
|
||||||
|
email_matches.update(
|
||||||
|
row[0]
|
||||||
|
for row in session.query(User.id).filter(
|
||||||
|
User.tenant_id == tenant_id,
|
||||||
|
User.account_id.in_(matching_accounts),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
candidate_sets.append(email_matches)
|
||||||
|
if not candidate_sets:
|
||||||
|
return ()
|
||||||
|
user_ids = set.intersection(*candidate_sets)
|
||||||
|
if not user_ids:
|
||||||
|
return ()
|
||||||
|
return tuple(
|
||||||
|
session.query(User)
|
||||||
|
.filter(User.tenant_id == tenant_id, User.id.in_(user_ids))
|
||||||
|
.order_by(User.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_identity_records(session: Session, append: object, account: Account) -> None:
|
||||||
|
for link, identity in (
|
||||||
|
session.query(IdentityAccountLink, Identity)
|
||||||
|
.join(Identity, Identity.id == IdentityAccountLink.identity_id)
|
||||||
|
.filter(IdentityAccountLink.account_id == account.id)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
append( # type: ignore[operator]
|
||||||
|
_record(
|
||||||
|
"identity",
|
||||||
|
identity.id,
|
||||||
|
"global_identity",
|
||||||
|
identity.display_name or identity.id,
|
||||||
|
{
|
||||||
|
"display_name": identity.display_name,
|
||||||
|
"external_subject": identity.external_subject,
|
||||||
|
"source": identity.source,
|
||||||
|
"is_active": identity.is_active,
|
||||||
|
"is_primary_link": link.is_primary,
|
||||||
|
},
|
||||||
|
observed_at=identity.updated_at,
|
||||||
|
source_path=f"/admin?section=system-users&identity={identity.id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_system_role_records(session: Session, append: object, account: Account) -> None:
|
||||||
|
for assignment, role in (
|
||||||
|
session.query(SystemRoleAssignment, Role)
|
||||||
|
.join(Role, Role.id == SystemRoleAssignment.role_id)
|
||||||
|
.filter(SystemRoleAssignment.account_id == account.id)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
append( # type: ignore[operator]
|
||||||
|
_record(
|
||||||
|
"system_role_assignment",
|
||||||
|
assignment.id,
|
||||||
|
"governance_evidence",
|
||||||
|
f"System role: {role.name}",
|
||||||
|
{"role_id": role.id, "role_name": role.name},
|
||||||
|
observed_at=assignment.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason="System authorization history is institutional evidence.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_api_key_records(session: Session, append: object, user: User) -> None:
|
||||||
|
for item in session.query(ApiKey).filter(ApiKey.user_id == user.id).all():
|
||||||
|
append( # type: ignore[operator]
|
||||||
|
_record(
|
||||||
|
"api_key",
|
||||||
|
item.id,
|
||||||
|
"credential",
|
||||||
|
item.name,
|
||||||
|
{
|
||||||
|
"prefix": item.prefix,
|
||||||
|
"scopes": list(item.scopes or ()),
|
||||||
|
"active": item.revoked_at is None,
|
||||||
|
"expires_at": _iso(item.expires_at),
|
||||||
|
"last_used_at": _iso(item.last_used_at),
|
||||||
|
"revoked_at": _iso(item.revoked_at),
|
||||||
|
},
|
||||||
|
observed_at=item.updated_at,
|
||||||
|
source_path="/admin?section=tenant-api-keys",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_session_records(session: Session, append: object, user: User) -> None:
|
||||||
|
for item in session.query(AuthSession).filter(AuthSession.user_id == user.id).all():
|
||||||
|
append( # type: ignore[operator]
|
||||||
|
_record(
|
||||||
|
"auth_session",
|
||||||
|
item.id,
|
||||||
|
"authentication",
|
||||||
|
f"Login session {item.id[:8]}",
|
||||||
|
{
|
||||||
|
"active": item.revoked_at is None,
|
||||||
|
"expires_at": _iso(item.expires_at),
|
||||||
|
"last_seen_at": _iso(item.last_seen_at),
|
||||||
|
"revoked_at": _iso(item.revoked_at),
|
||||||
|
},
|
||||||
|
observed_at=item.updated_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_group_records(session: Session, append: object, user: User) -> None:
|
||||||
|
for assignment, group in (
|
||||||
|
session.query(UserGroupMembership, Group)
|
||||||
|
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||||
|
.filter(UserGroupMembership.user_id == user.id)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
append( # type: ignore[operator]
|
||||||
|
_record(
|
||||||
|
"group_membership",
|
||||||
|
assignment.id,
|
||||||
|
"governance_evidence",
|
||||||
|
f"Group: {group.name}",
|
||||||
|
{"group_id": group.id, "group_name": group.name},
|
||||||
|
observed_at=assignment.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason="Group assignment history is institutional access evidence.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_role_records(session: Session, append: object, user: User) -> None:
|
||||||
|
for assignment, role in (
|
||||||
|
session.query(UserRoleAssignment, Role)
|
||||||
|
.join(Role, Role.id == UserRoleAssignment.role_id)
|
||||||
|
.filter(UserRoleAssignment.user_id == user.id)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
append( # type: ignore[operator]
|
||||||
|
_record(
|
||||||
|
"role_assignment",
|
||||||
|
assignment.id,
|
||||||
|
"governance_evidence",
|
||||||
|
f"Role: {role.name}",
|
||||||
|
{"role_id": role.id, "role_name": role.name},
|
||||||
|
observed_at=assignment.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason="Role assignment history is institutional access evidence.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_function_records(session: Session, append: object, user: User) -> None:
|
||||||
|
rows = (
|
||||||
|
session.query(FunctionAssignment, Function, OrganizationUnit)
|
||||||
|
.join(Function, Function.id == FunctionAssignment.function_id)
|
||||||
|
.join(OrganizationUnit, OrganizationUnit.id == FunctionAssignment.organization_unit_id)
|
||||||
|
.filter(
|
||||||
|
FunctionAssignment.tenant_id == user.tenant_id,
|
||||||
|
FunctionAssignment.account_id == user.account_id,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for assignment, function, unit in rows:
|
||||||
|
append( # type: ignore[operator]
|
||||||
|
_record(
|
||||||
|
"function_assignment",
|
||||||
|
assignment.id,
|
||||||
|
"governance_evidence",
|
||||||
|
f"{function.name} in {unit.name}",
|
||||||
|
{
|
||||||
|
"function_id": function.id,
|
||||||
|
"function_name": function.name,
|
||||||
|
"organization_unit_id": unit.id,
|
||||||
|
"organization_unit_name": unit.name,
|
||||||
|
"source": assignment.source,
|
||||||
|
"valid_from": _iso(assignment.valid_from),
|
||||||
|
"valid_until": _iso(assignment.valid_until),
|
||||||
|
"is_active": assignment.is_active,
|
||||||
|
},
|
||||||
|
observed_at=assignment.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason="Function incumbency is effective-dated institutional evidence.",
|
||||||
|
source_path="/admin?section=tenant-function-role-mappings",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
category: str,
|
||||||
|
title: str,
|
||||||
|
data: dict[str, object],
|
||||||
|
*,
|
||||||
|
observed_at: datetime | None = None,
|
||||||
|
immutable: bool = False,
|
||||||
|
retention_reason: str | None = None,
|
||||||
|
source_path: str | None = None,
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="access",
|
||||||
|
module_id="access",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
category=category,
|
||||||
|
title=title,
|
||||||
|
data=data,
|
||||||
|
observed_at=observed_at,
|
||||||
|
immutable_evidence=immutable,
|
||||||
|
retention_reason=retention_reason,
|
||||||
|
source_path=source_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _action(
|
||||||
|
action_id: str,
|
||||||
|
kind: str,
|
||||||
|
record: DsarRecordRef,
|
||||||
|
title: str,
|
||||||
|
rationale: str,
|
||||||
|
*,
|
||||||
|
executable: bool,
|
||||||
|
irreversible: bool = False,
|
||||||
|
metadata: dict[str, object] | None = None,
|
||||||
|
) -> DsarErasureActionRef:
|
||||||
|
return DsarErasureActionRef(
|
||||||
|
action_id=action_id,
|
||||||
|
provider_id="access",
|
||||||
|
module_id="access",
|
||||||
|
kind=kind, # type: ignore[arg-type]
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=title,
|
||||||
|
rationale=rationale,
|
||||||
|
executable=executable,
|
||||||
|
irreversible=irreversible,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _result(
|
||||||
|
action: DsarErasureActionRef,
|
||||||
|
result_status: str,
|
||||||
|
summary: str,
|
||||||
|
evidence: dict[str, object] | None = None,
|
||||||
|
) -> DsarExecutionResultRef:
|
||||||
|
return DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status=result_status, # type: ignore[arg-type]
|
||||||
|
summary=summary,
|
||||||
|
evidence=evidence or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _blocked(action: DsarErasureActionRef, summary: str) -> DsarExecutionResultRef:
|
||||||
|
return _result(action, "blocked", summary)
|
||||||
|
|
||||||
|
|
||||||
|
def _erased_email(tenant_id: str, membership_id: str) -> str:
|
||||||
|
digest = hashlib.sha256(f"{tenant_id}\0{membership_id}".encode()).hexdigest()[:24]
|
||||||
|
return f"erased+{digest}@invalid.govoplan"
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Access DSAR provider requires a SQLAlchemy session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
return value.isoformat() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ACCESS_DSAR_CAPABILITY", "AccessDsarProvider"]
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""German translations for public structured documentation metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'access.reference.admin-access-fields': {'fields': [{'admin_description': 'Auf dem Konto und den '
|
||||||
|
'Mitglieder-Payloads '
|
||||||
|
'gespeichert. Es muss '
|
||||||
|
'normalisiert und '
|
||||||
|
'eindeutig für das '
|
||||||
|
'entsprechende '
|
||||||
|
'Login-Konto sein.',
|
||||||
|
'api_field': 'email',
|
||||||
|
'api_path': '/api/v1/admin/users',
|
||||||
|
'field_id': 'access.user.email',
|
||||||
|
'label': 'E-Mail',
|
||||||
|
'permission_scope': 'access:membership:create',
|
||||||
|
'provenance': 'Mandantenmitgliedschaft oder '
|
||||||
|
'Kontosuche.',
|
||||||
|
'user_description': 'Die Adresse, die '
|
||||||
|
'verwendet wird, um die '
|
||||||
|
'Person zu '
|
||||||
|
'identifizieren, wenn '
|
||||||
|
'sie sich anmelden.',
|
||||||
|
'validation': 'Muss eine gültige '
|
||||||
|
'E-Mail-Adresse sein.'},
|
||||||
|
{'admin_description': 'Wird, sofern verfügbar, '
|
||||||
|
'in Benutzer- und '
|
||||||
|
'Kontoantworten als '
|
||||||
|
'display_name ausgegeben.',
|
||||||
|
'api_field': 'display_name',
|
||||||
|
'api_path': '/api/v1/admin/users',
|
||||||
|
'field_id': 'access.user.display_name',
|
||||||
|
'label': 'Anzeigename',
|
||||||
|
'permission_scope': 'access:membership:update',
|
||||||
|
'provenance': 'Profil der '
|
||||||
|
'Mandantenmitgliedschaft.',
|
||||||
|
'user_description': 'Der lesbare Name, der '
|
||||||
|
'in Benutzerlisten und '
|
||||||
|
'Bewertungsbildschirmen '
|
||||||
|
'angezeigt wird.',
|
||||||
|
'validation': 'Menschenlesbarer Text; Halten '
|
||||||
|
'Sie ihn für Administratoren '
|
||||||
|
'erkennbar.'},
|
||||||
|
{'admin_description': 'Wird beim Aktualisieren '
|
||||||
|
'einer Benutzer- oder '
|
||||||
|
'Gruppenmitgliedschaft '
|
||||||
|
'als group_ids übertragen.',
|
||||||
|
'api_field': 'group_ids',
|
||||||
|
'api_path': '/api/v1/admin/users/{user_id}',
|
||||||
|
'field_id': 'access.user.groups',
|
||||||
|
'label': 'Gruppen',
|
||||||
|
'permission_scope': 'access:group:manage_members',
|
||||||
|
'provenance': 'Benutzergruppenmitgliedschaftszeilen.',
|
||||||
|
'user_description': 'Gemeinsame Zugriffsbündel, '
|
||||||
|
'die Rollen für viele '
|
||||||
|
'Personen gleichzeitig '
|
||||||
|
'hinzufügen können.',
|
||||||
|
'validation': 'Gruppen müssen zum gleichen '
|
||||||
|
'Mandant gehören.'},
|
||||||
|
{'admin_description': 'Wird bei '
|
||||||
|
'Aktualisierungsanforderungen '
|
||||||
|
'für Benutzer- und '
|
||||||
|
'Gruppenrollen als role_ids '
|
||||||
|
'übertragen.',
|
||||||
|
'api_field': 'role_ids',
|
||||||
|
'api_path': '/api/v1/admin/users/{user_id}',
|
||||||
|
'field_id': 'access.user.roles',
|
||||||
|
'label': 'Rollen',
|
||||||
|
'permission_scope': 'access:role:assign',
|
||||||
|
'provenance': 'Direkte Benutzerrollen plus '
|
||||||
|
'Gruppenrollenvererbung.',
|
||||||
|
'user_description': 'Direktzugangszuschüsse, '
|
||||||
|
'die einer Person '
|
||||||
|
'zugewiesen oder von '
|
||||||
|
'Gruppen geerbt wurden.',
|
||||||
|
'validation': 'Rollen müssen zuordenbar sein '
|
||||||
|
'und dürfen das '
|
||||||
|
'Delegationslimit der '
|
||||||
|
'handelnden Person nicht '
|
||||||
|
'überschreiten.'},
|
||||||
|
{'admin_description': 'Bildet Scopes beim '
|
||||||
|
'Erstellen eines '
|
||||||
|
'API-Schlüssel zu und '
|
||||||
|
'wird mit den aktuellen '
|
||||||
|
'Berechtigungen des '
|
||||||
|
'Besitzers geschnitten.',
|
||||||
|
'api_field': 'scopes',
|
||||||
|
'api_path': '/api/v1/admin/api-keys',
|
||||||
|
'field_id': 'access.api_key.scopes',
|
||||||
|
'label': 'Anwendungsbereiche',
|
||||||
|
'permission_scope': 'access:api_key:create',
|
||||||
|
'provenance': 'API-Schlüssel Grant plus '
|
||||||
|
'Eigentümerdelegation.',
|
||||||
|
'user_description': 'Die Aktionen, die ein '
|
||||||
|
'API-Schlüssel ausführen '
|
||||||
|
'kann.',
|
||||||
|
'validation': 'Verwenden Sie möglichst enge '
|
||||||
|
'Berechtigungsbereiche.'}]},
|
||||||
|
'access.reference.personal-navigation': {'outcome': 'Die Seitenschiene des Benutzers spiegelt die '
|
||||||
|
'persönlichen Präferenzen wider, während '
|
||||||
|
'verschlossene und unzugängliche Einträge '
|
||||||
|
'durch übergeordnete Richtlinien geregelt '
|
||||||
|
'bleiben.'},
|
||||||
|
'access.workflow.configuration-packages': {'limitations': ['Die Paketübernahme installiert keine '
|
||||||
|
'fehlenden Module.',
|
||||||
|
'Die anbieterübergreifende Übernahme ist keine atomar '
|
||||||
|
'verteilte Transaktion.',
|
||||||
|
'Generisches Rollback hängt von einem '
|
||||||
|
'beibehaltenen '
|
||||||
|
'vor der Übernahme erstellten Datenbank-Snapshot ab.'],
|
||||||
|
'operational_consequences': ['Ein abgestandener oder '
|
||||||
|
'blockierter Preflight '
|
||||||
|
'muss vor der Anwendung '
|
||||||
|
'erneut durchgeführt '
|
||||||
|
'werden.',
|
||||||
|
'Eine teilweise Anwendung '
|
||||||
|
'erfordert eine '
|
||||||
|
'Wiederherstellung, bevor '
|
||||||
|
'das Paket erneut '
|
||||||
|
'getestet wird.',
|
||||||
|
'Geheimwerte bleiben '
|
||||||
|
'außerhalb tragbarer '
|
||||||
|
'Fragmente und '
|
||||||
|
'Herkunft.']},
|
||||||
|
'access.workflow.data-subject-request': {'limitations': ['Module ohne DSAR-Anbieter werden als '
|
||||||
|
'Deckungslücken gemeldet.',
|
||||||
|
'Globale Konten und Identitäten werden '
|
||||||
|
'nicht automatisch gelöscht.']},
|
||||||
|
'access.workflow.grant-user-access': {'outcome': 'Eine Person kann sich beim Mandant anmelden und '
|
||||||
|
'erhält den beabsichtigten Zugang durch Gruppen '
|
||||||
|
'und Rollen.',
|
||||||
|
'prerequisites': ['Sie können Admin öffnen.',
|
||||||
|
'Sie können Benutzer, Gruppen und Rollen '
|
||||||
|
'lesen.',
|
||||||
|
'Schreib- oder Zuweisungsaktionen '
|
||||||
|
'erfordern übereinstimmende '
|
||||||
|
'Verwaltungsberechtigungen.'],
|
||||||
|
'result': 'Die Mitgliedschaft hat die beabsichtigten '
|
||||||
|
'effektiven Berechtigungen und keine breiteren '
|
||||||
|
'Rollen als nötig.',
|
||||||
|
'steps': ['Öffnen Sie Admin und gehen Sie zu Benutzern.',
|
||||||
|
'Finden Sie die bestehende Person oder erstellen '
|
||||||
|
'Sie eine Mitgliedschaft mit ihrer E-Mail-Adresse '
|
||||||
|
'und dem Anzeigenamen.',
|
||||||
|
'Überprüfen Sie aktuelle Gruppen und direkte '
|
||||||
|
'Rollen, bevor Sie etwas ändern.',
|
||||||
|
'Fügen Sie die Person der kleinsten Gruppe hinzu, '
|
||||||
|
'die den erforderlichen gemeinsamen Zugriff '
|
||||||
|
'gewährt.',
|
||||||
|
'Weisen Sie direkte Rollen nur zu, wenn eine '
|
||||||
|
'Gruppe nicht mit dem Fall übereinstimmt.',
|
||||||
|
'Speichern und überprüfen Sie eine '
|
||||||
|
'Blockernachricht, bevor Sie einen System- oder '
|
||||||
|
'Mandantbesitzer um Hilfe bitten.'],
|
||||||
|
'verification': 'Öffnen Sie den Benutzer erneut und '
|
||||||
|
'vergleichen Sie Gruppen, direkte Rollen '
|
||||||
|
'und effektive Berechtigungen mit der '
|
||||||
|
'Anforderung.'},
|
||||||
|
'access.workflow.manage-api-keys': {'consequences': ['Der Widerruf lehnt nachfolgende Anfragen, '
|
||||||
|
'die mit dem Schlüssel gestellt wurden, '
|
||||||
|
'sofort ab.',
|
||||||
|
'Durch das Entfernen von Berechtigungen vom '
|
||||||
|
'Besitzer wird der effektive '
|
||||||
|
'Schlüsselzugriff sofort eingeschränkt.'],
|
||||||
|
'limitations': ['Ein einmaliges Geheimnis kann nach dem '
|
||||||
|
'Schließen des Erstellungsdialogs nicht '
|
||||||
|
'angezeigt oder wiederhergestellt werden.',
|
||||||
|
'Ändern des Besitzers, Ablauf oder Scopes '
|
||||||
|
'erfordert einen Ersatzschlüssel.',
|
||||||
|
'Der Widerruf aktualisiert keine externen '
|
||||||
|
'Clients; die Betreiber müssen bei Bedarf '
|
||||||
|
'einen Ersatz installieren.'],
|
||||||
|
'outcome': 'Der Automatisierungsclient verfügt über einen '
|
||||||
|
'zeitlich begrenzten Berechtigungsnachweis, dessen '
|
||||||
|
'effektiver Zugriff weder seine gespeicherten '
|
||||||
|
'Berechtigungsbereiche noch die aktuellen '
|
||||||
|
'Berechtigungen seines Besitzers überschreiten '
|
||||||
|
'kann.',
|
||||||
|
'prerequisites': ['Der Mandant erlaubt '
|
||||||
|
'API-Anmeldeinformationen.',
|
||||||
|
'Die handelnde Person kann API-Schlüssel '
|
||||||
|
'erstellen oder widerrufen und jeden '
|
||||||
|
'ausgewählten Bereich delegieren.',
|
||||||
|
'Ein zugelassener externer Geheimmanager '
|
||||||
|
'und rechenschaftspflichtiger Eigentümer '
|
||||||
|
'sind bekannt.'],
|
||||||
|
'steps': ['Wählen Sie den verantwortlichen Eigentümer und die '
|
||||||
|
'engsten erforderlichen Berechtigungsbereiche.',
|
||||||
|
'Legen Sie den kürzesten praktischen Ablauf fest, '
|
||||||
|
'bevor Sie den Schlüssel erstellen.',
|
||||||
|
'Übertragen Sie das einmalige Geheimnis direkt in '
|
||||||
|
'den genehmigten Geheimmanager.',
|
||||||
|
'Widerrufen Sie den Schlüssel, wenn sein Client, '
|
||||||
|
'Eigentümer oder Zweck nicht mehr gültig ist.'],
|
||||||
|
'verification': 'Laden Sie das Schlüsselverzeichnis neu, '
|
||||||
|
'überprüfen Sie Eigentümer, Präfix, '
|
||||||
|
'Berechtigungsumfang, Ablauf und Status und '
|
||||||
|
'testen Sie dann den beabsichtigten Client, '
|
||||||
|
'ohne geheimes Material in Nachweise zu '
|
||||||
|
'kopieren.'},
|
||||||
|
'access.workflow.manage-reusable-credentials': {'limitations': ['GovOPlaN kann ein konfiguriertes '
|
||||||
|
'Geheimnis nicht anzeigen oder '
|
||||||
|
'wiederherstellen.',
|
||||||
|
'Eine leere Modul- oder '
|
||||||
|
'Serverbeschränkung bedeutet '
|
||||||
|
'jeden Wert, der nach '
|
||||||
|
'Berechtigungsumfang zulässig '
|
||||||
|
'ist.',
|
||||||
|
'Das Löschen oder Leeren eines '
|
||||||
|
'Geheimnisses schreibt keine '
|
||||||
|
'abhängigen Verbindungsreferenzen '
|
||||||
|
'neu.'],
|
||||||
|
'outcome': 'Die Zugangsdaten bleiben '
|
||||||
|
'schreibgeschützt und sind nur '
|
||||||
|
'innerhalb seines aktiven '
|
||||||
|
'Berechtigungsumfangs, Moduls, Servers '
|
||||||
|
'und Autorisierungsgrenzen verwendbar.',
|
||||||
|
'prerequisites': ['Der beabsichtigte '
|
||||||
|
'Berechtigungsinhaber wird '
|
||||||
|
'ausgewählt.',
|
||||||
|
'Die handelnde Person kann '
|
||||||
|
'Anmeldeinformationen lesen und '
|
||||||
|
'hat Schreibautorität für '
|
||||||
|
'Mutationen.',
|
||||||
|
'Der externe '
|
||||||
|
'Secret-Manager-Eigentümer und '
|
||||||
|
'abhängige Verbindungen sind '
|
||||||
|
'bekannt.'],
|
||||||
|
'steps': ['Wählen Sie den engsten Besitzumfang '
|
||||||
|
'und Anmeldetyp.',
|
||||||
|
'Beschränken Sie Module und Server '
|
||||||
|
'explizit, wenn eine breite Nutzung '
|
||||||
|
'nicht beabsichtigt ist.',
|
||||||
|
'Speichern Sie ein neues oder '
|
||||||
|
'Ersatzgeheimnis, ohne zu erwarten, '
|
||||||
|
'dass es erneut angezeigt wird.',
|
||||||
|
'Überprüfen Sie abhängige Verbindungen '
|
||||||
|
'vor der Deaktivierung, geheimen '
|
||||||
|
'Löschung oder Löschung.'],
|
||||||
|
'verification': 'Laden Sie die Liste der '
|
||||||
|
'Zugangsdaten neu, bestätigen Sie '
|
||||||
|
'deren Berechtigungsumfang und '
|
||||||
|
'Verfügbarkeit und testen Sie '
|
||||||
|
'dann jede beabsichtigte '
|
||||||
|
'abhängige Verbindung, ohne das '
|
||||||
|
'Geheimnis zu enthüllen.'},
|
||||||
|
'access.workflow.manage-service-account-credentials': {'consequences': ['Rotation widerruft den '
|
||||||
|
'vorherigen Nachweis in '
|
||||||
|
'der gleichen '
|
||||||
|
'Transaktion, die seinen '
|
||||||
|
'Ersatz schafft.',
|
||||||
|
'Der Widerruf, die '
|
||||||
|
'Deaktivierung des Kontos '
|
||||||
|
'und der Ruhestand lehnen '
|
||||||
|
'betroffene '
|
||||||
|
'Kundenanfragen sofort '
|
||||||
|
'ab.',
|
||||||
|
'Eine veraltete Revision '
|
||||||
|
'wird abgelehnt, so dass '
|
||||||
|
'ein gleichzeitiger '
|
||||||
|
'Verwaltungswechsel nicht '
|
||||||
|
'überschrieben wird.'],
|
||||||
|
'limitations': ['Einmalige '
|
||||||
|
'Anmeldegeheimnisse können '
|
||||||
|
'nach dem Schließen des '
|
||||||
|
'Erstellungsdialogs nicht '
|
||||||
|
'angezeigt oder '
|
||||||
|
'wiederhergestellt werden.',
|
||||||
|
'Deaktivierung und eine '
|
||||||
|
'reduzierte '
|
||||||
|
'Berechtigungsumfangsobergrenze '
|
||||||
|
'betreffen Clients sofort, '
|
||||||
|
'schreiben ihre externe '
|
||||||
|
'Konfiguration jedoch '
|
||||||
|
'nicht neu.',
|
||||||
|
'Der Ruhestand widerruft '
|
||||||
|
'alle aktiven '
|
||||||
|
'Anmeldeinformationen und '
|
||||||
|
'erfordert ein neues '
|
||||||
|
'Servicekonto für die '
|
||||||
|
'spätere '
|
||||||
|
'Wiederverwendung.'],
|
||||||
|
'outcome': 'Der Automatisierungsprinzipal '
|
||||||
|
'bleibt nicht interaktiv und '
|
||||||
|
'kann sich nur durch einen '
|
||||||
|
'aktiven Berechtigungsnachweis '
|
||||||
|
'authentifizieren, dessen '
|
||||||
|
'Gewährung innerhalb der '
|
||||||
|
'aktuellen '
|
||||||
|
'Berechtigungsumfangsobergrenze '
|
||||||
|
'des Kontos liegt.',
|
||||||
|
'prerequisites': ['Der Mandant erlaubt '
|
||||||
|
'API-Anmeldeinformationen.',
|
||||||
|
'Sie haben eine '
|
||||||
|
'Service-Account-Schreibberechtigung '
|
||||||
|
'und können jeden '
|
||||||
|
'ausgewählten Bereich '
|
||||||
|
'delegieren.'],
|
||||||
|
'steps': ['Erstellen Sie ein Servicekonto '
|
||||||
|
'und definieren Sie die engste '
|
||||||
|
'Nutzumfangsobergrenze.',
|
||||||
|
'Öffnen Sie das Konto und '
|
||||||
|
'erstellen Sie einen '
|
||||||
|
'Berechtigungsnachweis mit einem '
|
||||||
|
'gleichen oder engeren '
|
||||||
|
'Berechtigungsumfang.',
|
||||||
|
'Notieren Sie das einmalige '
|
||||||
|
'Geheimnis in einem externen '
|
||||||
|
'Geheimmanager.',
|
||||||
|
'Anmeldeinformationen vor Ablauf '
|
||||||
|
'drehen und Anmeldeinformationen '
|
||||||
|
'widerrufen, die nicht mehr '
|
||||||
|
'verwendet werden.'],
|
||||||
|
'verification': 'Die Verwaltungstabelle '
|
||||||
|
'zeigt die erwartete '
|
||||||
|
'Anzahl der aktiven '
|
||||||
|
'Anmeldeinformationen, den '
|
||||||
|
'Zeitstempel für die '
|
||||||
|
'letzte Verwendung, die '
|
||||||
|
'Revision und die '
|
||||||
|
'Audit-Ereignisse, ohne '
|
||||||
|
'geheimes Material '
|
||||||
|
'preiszugeben.'}}
|
||||||
@@ -1,26 +1,186 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_access.backend.db.models import Group, GroupRoleAssignment, Role, UserGroupMembership, UserRoleAssignment
|
from govoplan_access.backend.db.models import (
|
||||||
|
Group,
|
||||||
|
GroupRoleAssignment,
|
||||||
|
Role,
|
||||||
|
UserGroupMembership,
|
||||||
|
UserRoleAssignment,
|
||||||
|
new_uuid,
|
||||||
|
)
|
||||||
from govoplan_core.admin.common import AdminConflictError
|
from govoplan_core.admin.common import AdminConflictError
|
||||||
from govoplan_core.core.access import AccessGovernanceMaterializer, GovernanceTemplateMaterialization
|
from govoplan_core.core.access import (
|
||||||
|
AccessGovernanceMaterializer,
|
||||||
|
AccessGovernanceProjectionV1,
|
||||||
|
GovernanceProjectionBatch,
|
||||||
|
GovernanceProjectionCommand,
|
||||||
|
GovernanceProjectionOutcome,
|
||||||
|
GovernanceProjectionResult,
|
||||||
|
GovernanceTemplateMaterialization,
|
||||||
|
)
|
||||||
from govoplan_core.core.runtime import get_registry
|
from govoplan_core.core.runtime import get_registry
|
||||||
|
|
||||||
|
|
||||||
class SqlAccessGovernanceMaterializer(AccessGovernanceMaterializer):
|
class SqlAccessGovernanceMaterializer(
|
||||||
def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
AccessGovernanceMaterializer,
|
||||||
|
AccessGovernanceProjectionV1,
|
||||||
|
):
|
||||||
|
"""Reconcile governance projections with a constant number of bulk reads."""
|
||||||
|
|
||||||
|
def reconcile(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
batch: GovernanceProjectionBatch,
|
||||||
|
) -> GovernanceProjectionResult:
|
||||||
db = _session(session)
|
db = _session(session)
|
||||||
if template.kind == "group":
|
commands = tuple(batch.commands)
|
||||||
group = (
|
group_commands = tuple(item for item in commands if item.template.kind == "group")
|
||||||
db.query(Group)
|
role_commands = tuple(item for item in commands if item.template.kind == "role")
|
||||||
.filter(Group.tenant_id == template.tenant_id, Group.system_template_id == template.template_id)
|
|
||||||
.first()
|
groups, duplicate_group_keys = _managed_groups(db, group_commands)
|
||||||
|
roles, duplicate_role_keys = _managed_roles(db, role_commands)
|
||||||
|
used_group_slugs = _used_slugs(db, Group, group_commands)
|
||||||
|
used_role_slugs = _used_slugs(db, Role, role_commands)
|
||||||
|
|
||||||
|
group_ids = {item.id for item in groups.values()}
|
||||||
|
role_ids = {item.id for item in roles.values()}
|
||||||
|
group_memberships = _assignment_counts(db, UserGroupMembership, UserGroupMembership.group_id, group_ids)
|
||||||
|
group_role_links = _assignment_counts(db, GroupRoleAssignment, GroupRoleAssignment.group_id, group_ids)
|
||||||
|
role_user_links = _assignment_counts(db, UserRoleAssignment, UserRoleAssignment.role_id, role_ids)
|
||||||
|
role_group_links = _assignment_counts(db, GroupRoleAssignment, GroupRoleAssignment.role_id, role_ids)
|
||||||
|
|
||||||
|
outcomes: list[GovernanceProjectionOutcome] = []
|
||||||
|
for command in commands:
|
||||||
|
key = (command.template.tenant_id, command.template.template_id)
|
||||||
|
if command.template.kind == "group":
|
||||||
|
outcome = self._reconcile_group(
|
||||||
|
db,
|
||||||
|
command,
|
||||||
|
groups,
|
||||||
|
duplicate_group_keys,
|
||||||
|
used_group_slugs,
|
||||||
|
group_memberships,
|
||||||
|
group_role_links,
|
||||||
|
dry_run=batch.dry_run,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
outcome = self._reconcile_role(
|
||||||
|
db,
|
||||||
|
command,
|
||||||
|
roles,
|
||||||
|
duplicate_role_keys,
|
||||||
|
used_role_slugs,
|
||||||
|
role_user_links,
|
||||||
|
role_group_links,
|
||||||
|
dry_run=batch.dry_run,
|
||||||
|
)
|
||||||
|
outcomes.append(outcome)
|
||||||
|
if outcome.status in {"removed", "absent"}:
|
||||||
|
groups.pop(key, None)
|
||||||
|
roles.pop(key, None)
|
||||||
|
|
||||||
|
if not batch.dry_run:
|
||||||
|
db.flush()
|
||||||
|
return GovernanceProjectionResult(
|
||||||
|
operation_id=batch.operation_id,
|
||||||
|
outcomes=tuple(outcomes),
|
||||||
|
dry_run=batch.dry_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
||||||
|
self._legacy_reconcile(session, template, operation="upsert")
|
||||||
|
|
||||||
|
def remove_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
||||||
|
self._legacy_reconcile(session, template, operation="remove")
|
||||||
|
|
||||||
|
def _legacy_reconcile(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
template: GovernanceTemplateMaterialization,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
) -> None:
|
||||||
|
command = GovernanceProjectionCommand(
|
||||||
|
assignment_id=f"legacy:{template.kind}:{template.template_id}:{template.tenant_id}",
|
||||||
|
operation=operation, # type: ignore[arg-type]
|
||||||
|
template=template,
|
||||||
|
provenance={"contract": "access.governanceMaterializer"},
|
||||||
|
)
|
||||||
|
result = self.reconcile(
|
||||||
|
session,
|
||||||
|
GovernanceProjectionBatch(
|
||||||
|
operation_id=command.assignment_id,
|
||||||
|
commands=(command,),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if result.blocked:
|
||||||
|
raise AdminConflictError(result.blocked[0].message or "Governance projection was blocked.")
|
||||||
|
|
||||||
|
def _reconcile_group(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
command: GovernanceProjectionCommand,
|
||||||
|
existing: dict[tuple[str, str], Group],
|
||||||
|
duplicate_keys: set[tuple[str, str]],
|
||||||
|
used_slugs: dict[str, set[str]],
|
||||||
|
membership_counts: dict[str, int],
|
||||||
|
role_counts: dict[str, int],
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> GovernanceProjectionOutcome:
|
||||||
|
template = command.template
|
||||||
|
key = (template.tenant_id, template.template_id)
|
||||||
|
group = existing.get(key)
|
||||||
|
if key in duplicate_keys:
|
||||||
|
return _outcome(
|
||||||
|
command,
|
||||||
|
status="failed",
|
||||||
|
blocker_codes=("duplicate_managed_projection",),
|
||||||
|
message="Multiple managed groups exist for this template and tenant.",
|
||||||
|
)
|
||||||
|
if command.operation == "remove":
|
||||||
if group is None:
|
if group is None:
|
||||||
|
return _outcome(command, status="absent")
|
||||||
|
blockers: list[str] = []
|
||||||
|
if membership_counts.get(group.id, 0):
|
||||||
|
blockers.append("group_has_members")
|
||||||
|
if role_counts.get(group.id, 0):
|
||||||
|
blockers.append("group_has_roles")
|
||||||
|
if blockers:
|
||||||
|
return _outcome(
|
||||||
|
command,
|
||||||
|
status="blocked",
|
||||||
|
resource_id=group.id,
|
||||||
|
blocker_codes=tuple(blockers),
|
||||||
|
message=f"Cannot remove {template.name!r} while its managed group has members or roles.",
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
try:
|
||||||
|
_run_delete_vetoes(db, "group", template.tenant_id, group.id)
|
||||||
|
except AdminConflictError as exc:
|
||||||
|
return _outcome(
|
||||||
|
command,
|
||||||
|
status="blocked",
|
||||||
|
resource_id=group.id,
|
||||||
|
blocker_codes=("module_delete_veto",),
|
||||||
|
message=str(exc),
|
||||||
|
)
|
||||||
|
db.delete(group)
|
||||||
|
return _outcome(command, status="removed", resource_id=group.id)
|
||||||
|
|
||||||
|
if group is None:
|
||||||
|
resource_id = new_uuid()
|
||||||
|
slug = _available_slug(used_slugs[template.tenant_id], template.slug)
|
||||||
|
if not dry_run:
|
||||||
group = Group(
|
group = Group(
|
||||||
|
id=resource_id,
|
||||||
tenant_id=template.tenant_id,
|
tenant_id=template.tenant_id,
|
||||||
slug=_available_slug(db, Group, template.tenant_id, template.slug),
|
slug=slug,
|
||||||
name=template.name,
|
name=template.name,
|
||||||
description=template.description,
|
description=template.description,
|
||||||
is_active=template.is_active,
|
is_active=template.is_active,
|
||||||
@@ -28,24 +188,72 @@ class SqlAccessGovernanceMaterializer(AccessGovernanceMaterializer):
|
|||||||
system_required=template.required,
|
system_required=template.required,
|
||||||
)
|
)
|
||||||
db.add(group)
|
db.add(group)
|
||||||
else:
|
existing[key] = group
|
||||||
group.name = template.name
|
return _outcome(command, status="created", resource_id=resource_id)
|
||||||
group.description = template.description
|
|
||||||
group.system_required = template.required
|
|
||||||
if template.required:
|
|
||||||
group.is_active = template.is_active
|
|
||||||
db.flush()
|
|
||||||
return
|
|
||||||
|
|
||||||
role = (
|
changes = {
|
||||||
db.query(Role)
|
"name": template.name,
|
||||||
.filter(Role.tenant_id == template.tenant_id, Role.system_template_id == template.template_id)
|
"description": template.description,
|
||||||
.first()
|
"system_required": template.required,
|
||||||
|
}
|
||||||
|
if template.required:
|
||||||
|
changes["is_active"] = template.is_active
|
||||||
|
changed = any(getattr(group, field) != value for field, value in changes.items())
|
||||||
|
if changed and not dry_run:
|
||||||
|
for field, value in changes.items():
|
||||||
|
setattr(group, field, value)
|
||||||
|
return _outcome(command, status="updated" if changed else "unchanged", resource_id=group.id)
|
||||||
|
|
||||||
|
def _reconcile_role(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
command: GovernanceProjectionCommand,
|
||||||
|
existing: dict[tuple[str, str], Role],
|
||||||
|
duplicate_keys: set[tuple[str, str]],
|
||||||
|
used_slugs: dict[str, set[str]],
|
||||||
|
user_counts: dict[str, int],
|
||||||
|
group_counts: dict[str, int],
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> GovernanceProjectionOutcome:
|
||||||
|
template = command.template
|
||||||
|
key = (template.tenant_id, template.template_id)
|
||||||
|
role = existing.get(key)
|
||||||
|
if key in duplicate_keys:
|
||||||
|
return _outcome(
|
||||||
|
command,
|
||||||
|
status="failed",
|
||||||
|
blocker_codes=("duplicate_managed_projection",),
|
||||||
|
message="Multiple managed roles exist for this template and tenant.",
|
||||||
)
|
)
|
||||||
|
if command.operation == "remove":
|
||||||
if role is None:
|
if role is None:
|
||||||
|
return _outcome(command, status="absent")
|
||||||
|
blockers: list[str] = []
|
||||||
|
if user_counts.get(role.id, 0):
|
||||||
|
blockers.append("role_has_users")
|
||||||
|
if group_counts.get(role.id, 0):
|
||||||
|
blockers.append("role_has_groups")
|
||||||
|
if blockers:
|
||||||
|
return _outcome(
|
||||||
|
command,
|
||||||
|
status="blocked",
|
||||||
|
resource_id=role.id,
|
||||||
|
blocker_codes=tuple(blockers),
|
||||||
|
message=f"Cannot remove {template.name!r} while its managed role is assigned to users or groups.",
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
db.delete(role)
|
||||||
|
return _outcome(command, status="removed", resource_id=role.id)
|
||||||
|
|
||||||
|
if role is None:
|
||||||
|
resource_id = new_uuid()
|
||||||
|
slug = _available_slug(used_slugs[template.tenant_id], template.slug)
|
||||||
|
if not dry_run:
|
||||||
role = Role(
|
role = Role(
|
||||||
|
id=resource_id,
|
||||||
tenant_id=template.tenant_id,
|
tenant_id=template.tenant_id,
|
||||||
slug=_available_slug(db, Role, template.tenant_id, template.slug),
|
slug=slug,
|
||||||
name=template.name,
|
name=template.name,
|
||||||
description=template.description,
|
description=template.description,
|
||||||
permissions=list(template.permissions),
|
permissions=list(template.permissions),
|
||||||
@@ -55,51 +263,124 @@ class SqlAccessGovernanceMaterializer(AccessGovernanceMaterializer):
|
|||||||
system_required=template.required,
|
system_required=template.required,
|
||||||
)
|
)
|
||||||
db.add(role)
|
db.add(role)
|
||||||
else:
|
existing[key] = role
|
||||||
role.name = template.name
|
return _outcome(command, status="created", resource_id=resource_id)
|
||||||
role.description = template.description
|
|
||||||
role.permissions = list(template.permissions)
|
changes: dict[str, object] = {
|
||||||
role.system_required = template.required
|
"name": template.name,
|
||||||
|
"description": template.description,
|
||||||
|
"permissions": list(template.permissions),
|
||||||
|
"system_required": template.required,
|
||||||
|
}
|
||||||
if template.required:
|
if template.required:
|
||||||
role.is_assignable = template.is_active
|
changes["is_assignable"] = template.is_active
|
||||||
db.flush()
|
changed = any(getattr(role, field) != value for field, value in changes.items())
|
||||||
|
if changed and not dry_run:
|
||||||
|
for field, value in changes.items():
|
||||||
|
setattr(role, field, value)
|
||||||
|
return _outcome(command, status="updated" if changed else "unchanged", resource_id=role.id)
|
||||||
|
|
||||||
def remove_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
|
||||||
db = _session(session)
|
|
||||||
if template.kind == "group":
|
|
||||||
group = (
|
|
||||||
db.query(Group)
|
|
||||||
.filter(Group.tenant_id == template.tenant_id, Group.system_template_id == template.template_id)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if group is None:
|
|
||||||
return
|
|
||||||
membership_count = db.query(UserGroupMembership).filter(UserGroupMembership.group_id == group.id).count()
|
|
||||||
role_count = db.query(GroupRoleAssignment).filter(GroupRoleAssignment.group_id == group.id).count()
|
|
||||||
if membership_count or role_count:
|
|
||||||
raise AdminConflictError(
|
|
||||||
f"Cannot remove {template.name!r} from the tenant while its managed group has members or roles."
|
|
||||||
)
|
|
||||||
_run_delete_vetoes(db, "group", template.tenant_id, group.id)
|
|
||||||
db.delete(group)
|
|
||||||
db.flush()
|
|
||||||
return
|
|
||||||
|
|
||||||
role = (
|
def _managed_groups(
|
||||||
db.query(Role)
|
session: Session,
|
||||||
.filter(Role.tenant_id == template.tenant_id, Role.system_template_id == template.template_id)
|
commands: tuple[GovernanceProjectionCommand, ...],
|
||||||
.first()
|
) -> tuple[dict[tuple[str, str], Group], set[tuple[str, str]]]:
|
||||||
|
if not commands:
|
||||||
|
return {}, set()
|
||||||
|
tenants = {item.template.tenant_id for item in commands}
|
||||||
|
templates = {item.template.template_id for item in commands}
|
||||||
|
rows = session.query(Group).filter(
|
||||||
|
Group.tenant_id.in_(tenants),
|
||||||
|
Group.system_template_id.in_(templates),
|
||||||
|
).all()
|
||||||
|
return _indexed_managed(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _managed_roles(
|
||||||
|
session: Session,
|
||||||
|
commands: tuple[GovernanceProjectionCommand, ...],
|
||||||
|
) -> tuple[dict[tuple[str, str], Role], set[tuple[str, str]]]:
|
||||||
|
if not commands:
|
||||||
|
return {}, set()
|
||||||
|
tenants = {item.template.tenant_id for item in commands}
|
||||||
|
templates = {item.template.template_id for item in commands}
|
||||||
|
rows = session.query(Role).filter(
|
||||||
|
Role.tenant_id.in_(tenants),
|
||||||
|
Role.system_template_id.in_(templates),
|
||||||
|
).all()
|
||||||
|
return _indexed_managed(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _indexed_managed(rows):
|
||||||
|
indexed = {}
|
||||||
|
duplicates = set()
|
||||||
|
for row in rows:
|
||||||
|
key = (row.tenant_id, row.system_template_id)
|
||||||
|
if key in indexed:
|
||||||
|
duplicates.add(key)
|
||||||
|
else:
|
||||||
|
indexed[key] = row
|
||||||
|
return indexed, duplicates
|
||||||
|
|
||||||
|
|
||||||
|
def _used_slugs(
|
||||||
|
session: Session,
|
||||||
|
model: type[Group] | type[Role],
|
||||||
|
commands: tuple[GovernanceProjectionCommand, ...],
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
used: dict[str, set[str]] = defaultdict(set)
|
||||||
|
tenants = {item.template.tenant_id for item in commands}
|
||||||
|
if tenants:
|
||||||
|
for tenant_id, slug in session.query(model.tenant_id, model.slug).filter(model.tenant_id.in_(tenants)).all():
|
||||||
|
used[str(tenant_id)].add(slug)
|
||||||
|
for tenant_id in tenants:
|
||||||
|
used[tenant_id]
|
||||||
|
return used
|
||||||
|
|
||||||
|
|
||||||
|
def _assignment_counts(session: Session, model, column, resource_ids: set[str]) -> dict[str, int]:
|
||||||
|
if not resource_ids:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
resource_id: count
|
||||||
|
for resource_id, count in session.query(column, func.count(model.id))
|
||||||
|
.filter(column.in_(resource_ids))
|
||||||
|
.group_by(column)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _available_slug(used: set[str], base: str) -> str:
|
||||||
|
candidate = base
|
||||||
|
suffix = 2
|
||||||
|
while candidate in used:
|
||||||
|
candidate = f"{base}-{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
used.add(candidate)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _outcome(
|
||||||
|
command: GovernanceProjectionCommand,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
resource_id: str | None = None,
|
||||||
|
blocker_codes: tuple[str, ...] = (),
|
||||||
|
message: str | None = None,
|
||||||
|
) -> GovernanceProjectionOutcome:
|
||||||
|
template = command.template
|
||||||
|
return GovernanceProjectionOutcome(
|
||||||
|
assignment_id=command.assignment_id,
|
||||||
|
template_id=template.template_id,
|
||||||
|
tenant_id=template.tenant_id,
|
||||||
|
kind=template.kind,
|
||||||
|
operation=command.operation,
|
||||||
|
status=status, # type: ignore[arg-type]
|
||||||
|
resource_id=resource_id,
|
||||||
|
blocker_codes=blocker_codes,
|
||||||
|
message=message,
|
||||||
|
provenance=dict(command.provenance),
|
||||||
)
|
)
|
||||||
if role is None:
|
|
||||||
return
|
|
||||||
user_count = db.query(UserRoleAssignment).filter(UserRoleAssignment.role_id == role.id).count()
|
|
||||||
group_count = db.query(GroupRoleAssignment).filter(GroupRoleAssignment.role_id == role.id).count()
|
|
||||||
if user_count or group_count:
|
|
||||||
raise AdminConflictError(
|
|
||||||
f"Cannot remove {template.name!r} from the tenant while its managed role is assigned to users or groups."
|
|
||||||
)
|
|
||||||
db.delete(role)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def _session(session: object) -> Session:
|
def _session(session: object) -> Session:
|
||||||
@@ -119,12 +400,3 @@ def _run_delete_vetoes(session: Session, resource_type: str, tenant_id: str, res
|
|||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise AdminConflictError(str(exc)) from exc
|
raise AdminConflictError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
def _available_slug(session: Session, model: type[Group] | type[Role], tenant_id: str, base: str) -> str:
|
|
||||||
candidate = base
|
|
||||||
suffix = 2
|
|
||||||
while session.query(model).filter(model.tenant_id == tenant_id, model.slug == candidate).first():
|
|
||||||
candidate = f"{base}-{suffix}"
|
|
||||||
suffix += 1
|
|
||||||
return candidate
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
|||||||
|
"""managed automation service accounts
|
||||||
|
|
||||||
|
Revision ID: b6d9f2a5c8e1
|
||||||
|
Revises: 4a5b6c7d8e9f
|
||||||
|
Create Date: 2026-07-29 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "b6d9f2a5c8e1"
|
||||||
|
down_revision = "4a5b6c7d8e9f"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if (
|
||||||
|
"access_service_accounts"
|
||||||
|
in sa.inspect(op.get_bind()).get_table_names()
|
||||||
|
):
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"access_service_accounts",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("account_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("membership_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("normalized_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("scope_ceiling", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_by_account_id",
|
||||||
|
sa.String(length=36),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_by_account_id",
|
||||||
|
sa.String(length=36),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("settings", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["account_id"],
|
||||||
|
["access_accounts.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_access_service_accounts_account_id_access_accounts"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["created_by_account_id"],
|
||||||
|
["access_accounts.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_access_service_accounts_created_by_account_id_"
|
||||||
|
"access_accounts"
|
||||||
|
),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["membership_id"],
|
||||||
|
["access_users.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_access_service_accounts_membership_id_access_users"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tenant_id"],
|
||||||
|
["core_scopes.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_access_service_accounts_tenant_id_core_scopes"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["updated_by_account_id"],
|
||||||
|
["access_accounts.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_access_service_accounts_updated_by_account_id_"
|
||||||
|
"access_accounts"
|
||||||
|
),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_access_service_accounts"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"account_id",
|
||||||
|
name="uq_access_service_accounts_account",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"membership_id",
|
||||||
|
name="uq_access_service_accounts_membership",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"normalized_name",
|
||||||
|
name="uq_access_service_accounts_tenant_name",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for name, columns in (
|
||||||
|
(
|
||||||
|
"ix_access_service_accounts_account_id",
|
||||||
|
["account_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_access_service_accounts_is_active",
|
||||||
|
["is_active"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_access_service_accounts_membership_id",
|
||||||
|
["membership_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_access_service_accounts_retired_at",
|
||||||
|
["retired_at"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_access_service_accounts_tenant_id",
|
||||||
|
["tenant_id"],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
op.create_index(name, "access_service_accounts", columns)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if (
|
||||||
|
"access_service_accounts"
|
||||||
|
in sa.inspect(op.get_bind()).get_table_names()
|
||||||
|
):
|
||||||
|
op.drop_table("access_service_accounts")
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
"""managed automation service accounts
|
||||||
|
|
||||||
|
Revision ID: b6d9f2a5c8e1
|
||||||
|
Revises: 4a5b6c7d8e9f
|
||||||
|
Create Date: 2026-07-29 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "b6d9f2a5c8e1"
|
||||||
|
down_revision = "4a5b6c7d8e9f"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if (
|
||||||
|
"access_service_accounts"
|
||||||
|
in sa.inspect(op.get_bind()).get_table_names()
|
||||||
|
):
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"access_service_accounts",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("account_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("membership_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("normalized_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("scope_ceiling", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_by_account_id",
|
||||||
|
sa.String(length=36),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_by_account_id",
|
||||||
|
sa.String(length=36),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("settings", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["account_id"],
|
||||||
|
["access_accounts.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_access_service_accounts_account_id_access_accounts"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["created_by_account_id"],
|
||||||
|
["access_accounts.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_access_service_accounts_created_by_account_id_"
|
||||||
|
"access_accounts"
|
||||||
|
),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["membership_id"],
|
||||||
|
["access_users.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_access_service_accounts_membership_id_access_users"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tenant_id"],
|
||||||
|
["core_scopes.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_access_service_accounts_tenant_id_core_scopes"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["updated_by_account_id"],
|
||||||
|
["access_accounts.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_access_service_accounts_updated_by_account_id_"
|
||||||
|
"access_accounts"
|
||||||
|
),
|
||||||
|
ondelete="SET NULL",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_access_service_accounts"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"account_id",
|
||||||
|
name="uq_access_service_accounts_account",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"membership_id",
|
||||||
|
name="uq_access_service_accounts_membership",
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"normalized_name",
|
||||||
|
name="uq_access_service_accounts_tenant_name",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for name, columns in (
|
||||||
|
(
|
||||||
|
"ix_access_service_accounts_account_id",
|
||||||
|
["account_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_access_service_accounts_is_active",
|
||||||
|
["is_active"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_access_service_accounts_membership_id",
|
||||||
|
["membership_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_access_service_accounts_retired_at",
|
||||||
|
["retired_at"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_access_service_accounts_tenant_id",
|
||||||
|
["tenant_id"],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
op.create_index(name, "access_service_accounts", columns)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if (
|
||||||
|
"access_service_accounts"
|
||||||
|
in sa.inspect(op.get_bind()).get_table_names()
|
||||||
|
):
|
||||||
|
op.drop_table("access_service_accounts")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Persist explicit interactive acting-in-place context.
|
||||||
|
|
||||||
|
Revision ID: c7e0a3d6f9b2
|
||||||
|
Revises: b6d9f2a5c8e1
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "c7e0a3d6f9b2"
|
||||||
|
down_revision = "b6d9f2a5c8e1"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"access_auth_sessions",
|
||||||
|
sa.Column("acting_assignment_id", sa.String(length=36), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"access_auth_sessions",
|
||||||
|
sa.Column("acting_for_account_id", sa.String(length=36), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_access_auth_sessions_acting_assignment_id"),
|
||||||
|
"access_auth_sessions",
|
||||||
|
["acting_assignment_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_access_auth_sessions_acting_assignment_id"),
|
||||||
|
table_name="access_auth_sessions",
|
||||||
|
)
|
||||||
|
op.drop_column("access_auth_sessions", "acting_for_account_id")
|
||||||
|
op.drop_column("access_auth_sessions", "acting_assignment_id")
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, Group, User
|
||||||
|
from govoplan_core.core.references import (
|
||||||
|
ReferenceOption,
|
||||||
|
ReferenceSearchPage,
|
||||||
|
ReferenceSearchRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SqlAccessReferenceOptionProvider:
|
||||||
|
"""Principal-aware, bounded Access directory search."""
|
||||||
|
|
||||||
|
def search_reference_options(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ReferenceSearchRequest,
|
||||||
|
) -> ReferenceSearchPage:
|
||||||
|
db = _session(session)
|
||||||
|
tenant_id = str(request.tenant_id or "").strip()
|
||||||
|
if not tenant_id:
|
||||||
|
return ReferenceSearchPage()
|
||||||
|
limit = max(1, min(int(request.limit), 200))
|
||||||
|
offset = _cursor_offset(request.cursor)
|
||||||
|
selected = tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
str(value).strip()
|
||||||
|
for value in request.selected_values
|
||||||
|
if str(value).strip()
|
||||||
|
)
|
||||||
|
)[:200]
|
||||||
|
administrative = request.context.get("administrative") is True
|
||||||
|
query = str(request.query or "").strip().casefold()
|
||||||
|
if request.kind in {"user", "membership"}:
|
||||||
|
return _search_users(
|
||||||
|
db,
|
||||||
|
principal,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
kind=request.kind,
|
||||||
|
query=query,
|
||||||
|
selected=selected,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
administrative=administrative,
|
||||||
|
)
|
||||||
|
if request.kind == "group":
|
||||||
|
return _search_groups(
|
||||||
|
db,
|
||||||
|
principal,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
query=query,
|
||||||
|
selected=selected,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
administrative=administrative,
|
||||||
|
)
|
||||||
|
raise ValueError(f"Unsupported Access reference kind: {request.kind}")
|
||||||
|
|
||||||
|
|
||||||
|
def _search_users(
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
kind: str,
|
||||||
|
query: str,
|
||||||
|
selected: Sequence[str],
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
administrative: bool,
|
||||||
|
) -> ReferenceSearchPage:
|
||||||
|
value_column = User.id if kind == "membership" else User.account_id
|
||||||
|
base = (
|
||||||
|
session.query(User, Account)
|
||||||
|
.join(Account, Account.id == User.account_id)
|
||||||
|
.filter(User.tenant_id == tenant_id)
|
||||||
|
)
|
||||||
|
if not administrative:
|
||||||
|
account_id = str(getattr(principal, "account_id", "") or "")
|
||||||
|
if not account_id:
|
||||||
|
return ReferenceSearchPage()
|
||||||
|
base = base.filter(User.account_id == account_id)
|
||||||
|
|
||||||
|
selected_rows = (
|
||||||
|
base.filter(value_column.in_(selected)).all()
|
||||||
|
if selected
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
search_query = base
|
||||||
|
if selected:
|
||||||
|
search_query = search_query.filter(value_column.notin_(selected))
|
||||||
|
if query:
|
||||||
|
search_query = search_query.filter(
|
||||||
|
or_(
|
||||||
|
func.lower(func.coalesce(User.display_name, "")).contains(
|
||||||
|
query,
|
||||||
|
autoescape=True,
|
||||||
|
),
|
||||||
|
func.lower(User.email).contains(query, autoescape=True),
|
||||||
|
func.lower(Account.email).contains(query, autoescape=True),
|
||||||
|
func.lower(value_column).contains(query, autoescape=True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
search_query.order_by(
|
||||||
|
func.lower(func.coalesce(User.display_name, User.email)).asc(),
|
||||||
|
value_column.asc(),
|
||||||
|
)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
has_more = len(rows) > limit
|
||||||
|
options = [
|
||||||
|
_user_option(user, account, kind=kind)
|
||||||
|
for user, account in rows[:limit]
|
||||||
|
]
|
||||||
|
selected_by_value = {
|
||||||
|
_user_value(user, kind=kind): _user_option(user, account, kind=kind)
|
||||||
|
for user, account in selected_rows
|
||||||
|
}
|
||||||
|
options.extend(
|
||||||
|
selected_by_value[value]
|
||||||
|
for value in selected
|
||||||
|
if value in selected_by_value
|
||||||
|
)
|
||||||
|
return ReferenceSearchPage(
|
||||||
|
options=tuple(options),
|
||||||
|
next_cursor=f"offset:{offset + limit}" if has_more else None,
|
||||||
|
has_more=has_more,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _search_groups(
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
query: str,
|
||||||
|
selected: Sequence[str],
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
administrative: bool,
|
||||||
|
) -> ReferenceSearchPage:
|
||||||
|
base = session.query(Group).filter(Group.tenant_id == tenant_id)
|
||||||
|
if not administrative:
|
||||||
|
permitted = tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
str(group_id)
|
||||||
|
for group_id in getattr(principal, "group_ids", ())
|
||||||
|
if str(group_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not permitted:
|
||||||
|
return ReferenceSearchPage()
|
||||||
|
base = base.filter(Group.id.in_(permitted))
|
||||||
|
|
||||||
|
selected_rows = base.filter(Group.id.in_(selected)).all() if selected else []
|
||||||
|
search_query = base
|
||||||
|
if selected:
|
||||||
|
search_query = search_query.filter(Group.id.notin_(selected))
|
||||||
|
if query:
|
||||||
|
search_query = search_query.filter(
|
||||||
|
or_(
|
||||||
|
func.lower(Group.name).contains(query, autoescape=True),
|
||||||
|
func.lower(Group.slug).contains(query, autoescape=True),
|
||||||
|
func.lower(Group.id).contains(query, autoescape=True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
search_query.order_by(func.lower(Group.name).asc(), Group.id.asc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
has_more = len(rows) > limit
|
||||||
|
options = [_group_option(group) for group in rows[:limit]]
|
||||||
|
selected_by_value = {group.id: _group_option(group) for group in selected_rows}
|
||||||
|
options.extend(
|
||||||
|
selected_by_value[value]
|
||||||
|
for value in selected
|
||||||
|
if value in selected_by_value
|
||||||
|
)
|
||||||
|
return ReferenceSearchPage(
|
||||||
|
options=tuple(options),
|
||||||
|
next_cursor=f"offset:{offset + limit}" if has_more else None,
|
||||||
|
has_more=has_more,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _user_value(user: User, *, kind: str) -> str:
|
||||||
|
return user.id if kind == "membership" else user.account_id
|
||||||
|
|
||||||
|
|
||||||
|
def _user_option(user: User, account: Account, *, kind: str) -> ReferenceOption:
|
||||||
|
inactive = not user.is_active or not account.is_active
|
||||||
|
value = _user_value(user, kind=kind)
|
||||||
|
description_parts = [
|
||||||
|
user.email,
|
||||||
|
"Inactive" if inactive else None,
|
||||||
|
]
|
||||||
|
return ReferenceOption(
|
||||||
|
value=value,
|
||||||
|
label=user.display_name or user.email or value,
|
||||||
|
description=" · ".join(
|
||||||
|
part for part in description_parts if part
|
||||||
|
) or None,
|
||||||
|
kind=kind,
|
||||||
|
availability="inactive" if inactive else "available",
|
||||||
|
disabled=inactive,
|
||||||
|
source_module="access",
|
||||||
|
provenance={
|
||||||
|
"tenant_id": user.tenant_id,
|
||||||
|
"membership_id": user.id,
|
||||||
|
"account_id": user.account_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _group_option(group: Group) -> ReferenceOption:
|
||||||
|
inactive = not group.is_active
|
||||||
|
return ReferenceOption(
|
||||||
|
value=group.id,
|
||||||
|
label=group.name or group.id,
|
||||||
|
description="Inactive" if inactive else None,
|
||||||
|
kind="group",
|
||||||
|
availability="inactive" if inactive else "available",
|
||||||
|
disabled=inactive,
|
||||||
|
source_module="access",
|
||||||
|
provenance={"tenant_id": group.tenant_id, "group_id": group.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cursor_offset(cursor: str | None) -> int:
|
||||||
|
if cursor is None:
|
||||||
|
return 0
|
||||||
|
prefix = "offset:"
|
||||||
|
if not cursor.startswith(prefix):
|
||||||
|
raise ValueError("Invalid reference search cursor.")
|
||||||
|
try:
|
||||||
|
offset = int(cursor[len(prefix):])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("Invalid reference search cursor.") from exc
|
||||||
|
if offset < 0:
|
||||||
|
raise ValueError("Invalid reference search cursor.")
|
||||||
|
return offset
|
||||||
|
|
||||||
|
|
||||||
|
def _session(session: object) -> Session:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise TypeError("Access reference search requires a SQLAlchemy Session")
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["SqlAccessReferenceOptionProvider"]
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
|
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
|
||||||
from govoplan_access.backend.db.models import ApiKey, User
|
from govoplan_access.backend.db.models import ApiKey, User
|
||||||
@@ -11,6 +11,7 @@ from govoplan_core.security.time import ensure_aware_utc, utc_now
|
|||||||
|
|
||||||
API_KEY_PREFIX_LENGTH = 12
|
API_KEY_PREFIX_LENGTH = 12
|
||||||
API_KEY_RANDOM_BYTES = 32
|
API_KEY_RANDOM_BYTES = 32
|
||||||
|
API_KEY_SECRET_PREFIX = "gpn_"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -28,7 +29,7 @@ def verify_api_key(secret: str, expected_hash: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def generate_api_key_secret() -> str:
|
def generate_api_key_secret() -> str:
|
||||||
return generate_secret("mm_", random_bytes=API_KEY_RANDOM_BYTES)
|
return generate_secret(API_KEY_SECRET_PREFIX, random_bytes=API_KEY_RANDOM_BYTES)
|
||||||
|
|
||||||
|
|
||||||
def api_key_prefix(secret: str) -> str:
|
def api_key_prefix(secret: str) -> str:
|
||||||
@@ -60,15 +61,34 @@ def create_api_key(
|
|||||||
return CreatedApiKey(model=model, secret=secret)
|
return CreatedApiKey(model=model, secret=secret)
|
||||||
|
|
||||||
|
|
||||||
def authenticate_api_key(session: Session, secret: str) -> ApiKey | None:
|
def authenticate_api_key(
|
||||||
|
session: Session,
|
||||||
|
secret: str,
|
||||||
|
*,
|
||||||
|
touch_interval_seconds: int = 5 * 60,
|
||||||
|
) -> ApiKey | None:
|
||||||
prefix = api_key_prefix(secret)
|
prefix = api_key_prefix(secret)
|
||||||
candidates = session.query(ApiKey).filter(ApiKey.prefix == prefix, ApiKey.revoked_at.is_(None)).all()
|
candidates = (
|
||||||
|
session.query(ApiKey)
|
||||||
|
.options(joinedload(ApiKey.user).joinedload(User.account))
|
||||||
|
.filter(
|
||||||
|
ApiKey.prefix == prefix,
|
||||||
|
ApiKey.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
now = utc_now()
|
now = utc_now()
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
expires_at = ensure_aware_utc(candidate.expires_at)
|
expires_at = ensure_aware_utc(candidate.expires_at)
|
||||||
if expires_at and expires_at < now:
|
if expires_at and expires_at < now:
|
||||||
continue
|
continue
|
||||||
if verify_api_key(secret, candidate.key_hash):
|
if verify_api_key(secret, candidate.key_hash):
|
||||||
|
last_used_at = ensure_aware_utc(candidate.last_used_at)
|
||||||
|
if (
|
||||||
|
touch_interval_seconds <= 0
|
||||||
|
or last_used_at is None
|
||||||
|
or now - last_used_at >= timedelta(seconds=touch_interval_seconds)
|
||||||
|
):
|
||||||
candidate.last_used_at = now
|
candidate.last_used_at = now
|
||||||
session.add(candidate)
|
session.add(candidate)
|
||||||
return candidate
|
return candidate
|
||||||
@@ -78,4 +98,3 @@ def authenticate_api_key(session: Session, secret: str) -> ApiKey | None:
|
|||||||
def has_scope(api_key: ApiKey, required_scope: str) -> bool:
|
def has_scope(api_key: ApiKey, required_scope: str) -> bool:
|
||||||
scopes = set(api_key.scopes or [])
|
scopes = set(api_key.scopes or [])
|
||||||
return "*" in scopes or required_scope in scopes
|
return "*" in scopes or required_scope in scopes
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from dataclasses import dataclass
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
|
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
|
||||||
from govoplan_access.backend.db.models import (
|
from govoplan_access.backend.db.models import (
|
||||||
@@ -104,15 +104,37 @@ def create_auth_session(
|
|||||||
return CreatedSession(model=model, token=token, csrf_token=csrf_token)
|
return CreatedSession(model=model, token=token, csrf_token=csrf_token)
|
||||||
|
|
||||||
|
|
||||||
def authenticate_session_token(session: Session, token: str) -> AuthSession | None:
|
def authenticate_session_token(
|
||||||
|
session: Session,
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
touch_interval_seconds: int = 5 * 60,
|
||||||
|
) -> AuthSession | None:
|
||||||
token_hash = hash_session_token(token)
|
token_hash = hash_session_token(token)
|
||||||
model = session.query(AuthSession).filter(AuthSession.token_hash == token_hash, AuthSession.revoked_at.is_(None)).one_or_none()
|
model = (
|
||||||
|
session.query(AuthSession)
|
||||||
|
.options(
|
||||||
|
joinedload(AuthSession.user).joinedload(User.account),
|
||||||
|
joinedload(AuthSession.account),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
AuthSession.token_hash == token_hash,
|
||||||
|
AuthSession.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
if not model:
|
if not model:
|
||||||
return None
|
return None
|
||||||
now = utc_now()
|
now = utc_now()
|
||||||
expires_at = ensure_aware_utc(model.expires_at)
|
expires_at = ensure_aware_utc(model.expires_at)
|
||||||
if expires_at is None or expires_at < now:
|
if expires_at is None or expires_at < now:
|
||||||
return None
|
return None
|
||||||
|
last_seen_at = ensure_aware_utc(model.last_seen_at)
|
||||||
|
if (
|
||||||
|
touch_interval_seconds <= 0
|
||||||
|
or last_seen_at is None
|
||||||
|
or now - last_seen_at >= timedelta(seconds=touch_interval_seconds)
|
||||||
|
):
|
||||||
model.last_seen_at = now
|
model.last_seen_at = now
|
||||||
session.add(model)
|
session.add(model)
|
||||||
return model
|
return model
|
||||||
@@ -143,6 +165,8 @@ def switch_auth_session_tenant(session: Session, auth_session: AuthSession, tena
|
|||||||
raise LookupError("The account does not have an active membership in this tenant.")
|
raise LookupError("The account does not have an active membership in this tenant.")
|
||||||
auth_session.tenant_id = membership.tenant_id
|
auth_session.tenant_id = membership.tenant_id
|
||||||
auth_session.user_id = membership.id
|
auth_session.user_id = membership.id
|
||||||
|
auth_session.acting_assignment_id = None
|
||||||
|
auth_session.acting_for_account_id = None
|
||||||
auth_session.last_seen_at = utc_now()
|
auth_session.last_seen_at = utc_now()
|
||||||
session.add(auth_session)
|
session.add(auth_session)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|||||||
@@ -0,0 +1,642 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.base import utcnow
|
||||||
|
from govoplan_access.backend.db.models import (
|
||||||
|
Account,
|
||||||
|
ApiKey,
|
||||||
|
ServiceAccount,
|
||||||
|
Tenant,
|
||||||
|
User,
|
||||||
|
new_uuid,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.permissions.catalog import scopes_grant
|
||||||
|
from govoplan_access.backend.security.api_keys import (
|
||||||
|
CreatedApiKey,
|
||||||
|
create_api_key,
|
||||||
|
)
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountNotFoundError(ServiceAccountError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountConflictError(ServiceAccountError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountCredentialNotFoundError(ServiceAccountError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ServiceAccountCredentialSummary:
|
||||||
|
credential_count: int = 0
|
||||||
|
active_credential_count: int = 0
|
||||||
|
last_credential_used_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def list_service_accounts(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> list[ServiceAccount]:
|
||||||
|
return list(
|
||||||
|
session.scalars(
|
||||||
|
select(ServiceAccount)
|
||||||
|
.where(ServiceAccount.tenant_id == tenant_id)
|
||||||
|
.order_by(
|
||||||
|
ServiceAccount.normalized_name,
|
||||||
|
ServiceAccount.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def service_account_credential_summaries(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
service_accounts: Iterable[ServiceAccount],
|
||||||
|
) -> dict[str, ServiceAccountCredentialSummary]:
|
||||||
|
items = tuple(service_accounts)
|
||||||
|
by_membership = {item.membership_id: item.id for item in items}
|
||||||
|
usable_accounts = {
|
||||||
|
item.id
|
||||||
|
for item in items
|
||||||
|
if item.is_active and item.retired_at is None
|
||||||
|
}
|
||||||
|
summaries = {
|
||||||
|
item.id: ServiceAccountCredentialSummary()
|
||||||
|
for item in items
|
||||||
|
}
|
||||||
|
if not by_membership:
|
||||||
|
return summaries
|
||||||
|
now = utc_now()
|
||||||
|
totals: dict[str, int] = {}
|
||||||
|
active: dict[str, int] = {}
|
||||||
|
last_used: dict[str, datetime | None] = {}
|
||||||
|
credentials = session.scalars(
|
||||||
|
select(ApiKey).where(ApiKey.user_id.in_(by_membership))
|
||||||
|
)
|
||||||
|
for credential in credentials:
|
||||||
|
service_account_id = by_membership[credential.user_id]
|
||||||
|
totals[service_account_id] = totals.get(service_account_id, 0) + 1
|
||||||
|
expires_at = ensure_aware_utc(credential.expires_at)
|
||||||
|
if (
|
||||||
|
service_account_id in usable_accounts
|
||||||
|
and
|
||||||
|
credential.revoked_at is None
|
||||||
|
and (expires_at is None or expires_at > now)
|
||||||
|
):
|
||||||
|
active[service_account_id] = (
|
||||||
|
active.get(service_account_id, 0) + 1
|
||||||
|
)
|
||||||
|
used_at = ensure_aware_utc(credential.last_used_at)
|
||||||
|
if used_at is not None and (
|
||||||
|
last_used.get(service_account_id) is None
|
||||||
|
or used_at > last_used[service_account_id]
|
||||||
|
):
|
||||||
|
last_used[service_account_id] = used_at
|
||||||
|
return {
|
||||||
|
item.id: ServiceAccountCredentialSummary(
|
||||||
|
credential_count=totals.get(item.id, 0),
|
||||||
|
active_credential_count=active.get(item.id, 0),
|
||||||
|
last_credential_used_at=last_used.get(item.id),
|
||||||
|
)
|
||||||
|
for item in items
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_service_account_credentials(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
service_account_id: str,
|
||||||
|
include_revoked: bool = True,
|
||||||
|
) -> tuple[ServiceAccount, list[ApiKey]]:
|
||||||
|
item = get_service_account(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
service_account_id=service_account_id,
|
||||||
|
)
|
||||||
|
query = select(ApiKey).where(
|
||||||
|
ApiKey.tenant_id == tenant_id,
|
||||||
|
ApiKey.user_id == item.membership_id,
|
||||||
|
)
|
||||||
|
if not include_revoked:
|
||||||
|
query = query.where(ApiKey.revoked_at.is_(None))
|
||||||
|
credentials = list(
|
||||||
|
session.scalars(
|
||||||
|
query.order_by(ApiKey.created_at.desc(), ApiKey.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return item, credentials
|
||||||
|
|
||||||
|
|
||||||
|
def create_service_account_credential(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
service_account_id: str,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
expected_revision: int,
|
||||||
|
name: str,
|
||||||
|
scopes: Iterable[str],
|
||||||
|
expires_at: datetime | None,
|
||||||
|
) -> tuple[ServiceAccount, CreatedApiKey]:
|
||||||
|
item = _locked_service_account_for_credential_change(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
service_account_id=service_account_id,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
)
|
||||||
|
user = _active_service_account_membership(session, item)
|
||||||
|
credential_scopes = _service_account_credential_scopes(
|
||||||
|
principal,
|
||||||
|
item,
|
||||||
|
scopes,
|
||||||
|
)
|
||||||
|
created = create_api_key(
|
||||||
|
session,
|
||||||
|
user=user,
|
||||||
|
name=_credential_name(name),
|
||||||
|
scopes=list(credential_scopes),
|
||||||
|
expires_at=_future_expiry(expires_at),
|
||||||
|
)
|
||||||
|
_touch_service_account(item, principal)
|
||||||
|
session.flush()
|
||||||
|
return item, created
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_service_account_credential(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
service_account_id: str,
|
||||||
|
credential_id: str,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
expected_revision: int,
|
||||||
|
name: str | None,
|
||||||
|
scopes: Iterable[str] | None,
|
||||||
|
expires_at: datetime | None,
|
||||||
|
) -> tuple[ServiceAccount, ApiKey, CreatedApiKey]:
|
||||||
|
item = _locked_service_account_for_credential_change(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
service_account_id=service_account_id,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
)
|
||||||
|
user = _active_service_account_membership(session, item)
|
||||||
|
previous = _locked_service_account_credential(
|
||||||
|
session,
|
||||||
|
item=item,
|
||||||
|
credential_id=credential_id,
|
||||||
|
)
|
||||||
|
if previous.revoked_at is not None:
|
||||||
|
raise ServiceAccountConflictError(
|
||||||
|
"The credential is already revoked; reload before rotating"
|
||||||
|
)
|
||||||
|
requested_scopes = previous.scopes if scopes is None else scopes
|
||||||
|
credential_scopes = _service_account_credential_scopes(
|
||||||
|
principal,
|
||||||
|
item,
|
||||||
|
requested_scopes,
|
||||||
|
)
|
||||||
|
created = create_api_key(
|
||||||
|
session,
|
||||||
|
user=user,
|
||||||
|
name=_credential_name(name or previous.name),
|
||||||
|
scopes=list(credential_scopes),
|
||||||
|
expires_at=_future_expiry(expires_at),
|
||||||
|
)
|
||||||
|
previous.revoked_at = utc_now()
|
||||||
|
_touch_service_account(item, principal)
|
||||||
|
session.flush()
|
||||||
|
return item, previous, created
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_service_account_credential(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
service_account_id: str,
|
||||||
|
credential_id: str,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
expected_revision: int,
|
||||||
|
) -> tuple[ServiceAccount, ApiKey]:
|
||||||
|
item = _locked_service_account_for_credential_change(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
service_account_id=service_account_id,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
)
|
||||||
|
credential = _locked_service_account_credential(
|
||||||
|
session,
|
||||||
|
item=item,
|
||||||
|
credential_id=credential_id,
|
||||||
|
)
|
||||||
|
if credential.revoked_at is None:
|
||||||
|
credential.revoked_at = utc_now()
|
||||||
|
_touch_service_account(item, principal)
|
||||||
|
session.flush()
|
||||||
|
return item, credential
|
||||||
|
|
||||||
|
|
||||||
|
def get_service_account(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
service_account_id: str,
|
||||||
|
lock: bool = False,
|
||||||
|
) -> ServiceAccount:
|
||||||
|
query = select(ServiceAccount).where(
|
||||||
|
ServiceAccount.id == service_account_id,
|
||||||
|
ServiceAccount.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
if lock:
|
||||||
|
query = query.with_for_update()
|
||||||
|
item = session.scalar(query)
|
||||||
|
if item is None:
|
||||||
|
raise ServiceAccountNotFoundError(
|
||||||
|
"Service account was not found"
|
||||||
|
)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def create_service_account(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant: Tenant,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
name: str,
|
||||||
|
description: str | None,
|
||||||
|
scope_ceiling: Iterable[str],
|
||||||
|
) -> ServiceAccount:
|
||||||
|
clean_name = _service_account_name(name)
|
||||||
|
scopes = _service_account_scopes(
|
||||||
|
principal,
|
||||||
|
scope_ceiling,
|
||||||
|
)
|
||||||
|
service_account_id = new_uuid()
|
||||||
|
internal_email = (
|
||||||
|
f"service-account-{service_account_id}@govoplan.invalid"
|
||||||
|
)
|
||||||
|
account = Account(
|
||||||
|
id=new_uuid(),
|
||||||
|
email=internal_email,
|
||||||
|
normalized_email=internal_email,
|
||||||
|
display_name=clean_name,
|
||||||
|
is_active=True,
|
||||||
|
auth_provider="service_account",
|
||||||
|
password_hash=None,
|
||||||
|
password_reset_required=False,
|
||||||
|
)
|
||||||
|
membership = User(
|
||||||
|
id=new_uuid(),
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
account=account,
|
||||||
|
email=internal_email,
|
||||||
|
display_name=clean_name,
|
||||||
|
is_active=True,
|
||||||
|
is_tenant_admin=False,
|
||||||
|
auth_provider="service_account",
|
||||||
|
password_hash=None,
|
||||||
|
settings={"managed_service_account": service_account_id},
|
||||||
|
)
|
||||||
|
item = ServiceAccount(
|
||||||
|
id=service_account_id,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
account_id=account.id,
|
||||||
|
membership_id=membership.id,
|
||||||
|
name=clean_name,
|
||||||
|
normalized_name=_normalized_name(clean_name),
|
||||||
|
description=_optional_text(description),
|
||||||
|
scope_ceiling=list(scopes),
|
||||||
|
is_active=True,
|
||||||
|
revision=1,
|
||||||
|
created_by_account_id=principal.account_id,
|
||||||
|
updated_by_account_id=principal.account_id,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
session.add_all((account, membership, item))
|
||||||
|
try:
|
||||||
|
session.flush()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise ServiceAccountConflictError(
|
||||||
|
"A service account with this name already exists"
|
||||||
|
) from exc
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def update_service_account(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
service_account_id: str,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
expected_revision: int,
|
||||||
|
changes: Mapping[str, object],
|
||||||
|
) -> ServiceAccount:
|
||||||
|
item = get_service_account(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
service_account_id=service_account_id,
|
||||||
|
lock=True,
|
||||||
|
)
|
||||||
|
if item.revision != expected_revision:
|
||||||
|
raise ServiceAccountConflictError(
|
||||||
|
"Service account changed on the server; reload before saving"
|
||||||
|
)
|
||||||
|
account = session.get(Account, item.account_id)
|
||||||
|
membership = session.get(User, item.membership_id)
|
||||||
|
if account is None or membership is None:
|
||||||
|
raise ServiceAccountConflictError(
|
||||||
|
"Service account backing identity is missing"
|
||||||
|
)
|
||||||
|
if "name" in changes:
|
||||||
|
clean_name = _service_account_name(str(changes["name"]))
|
||||||
|
item.name = clean_name
|
||||||
|
item.normalized_name = _normalized_name(clean_name)
|
||||||
|
account.display_name = clean_name
|
||||||
|
membership.display_name = clean_name
|
||||||
|
if "description" in changes:
|
||||||
|
value = changes["description"]
|
||||||
|
item.description = _optional_text(
|
||||||
|
str(value) if value is not None else None
|
||||||
|
)
|
||||||
|
if "scope_ceiling" in changes:
|
||||||
|
raw_scopes = changes["scope_ceiling"]
|
||||||
|
if not isinstance(raw_scopes, Iterable) or isinstance(
|
||||||
|
raw_scopes,
|
||||||
|
(str, bytes),
|
||||||
|
):
|
||||||
|
raise ServiceAccountError(
|
||||||
|
"Service account scope ceiling is invalid"
|
||||||
|
)
|
||||||
|
item.scope_ceiling = list(
|
||||||
|
_service_account_scopes(
|
||||||
|
principal,
|
||||||
|
(str(scope) for scope in raw_scopes),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if "is_active" in changes:
|
||||||
|
active = bool(changes["is_active"])
|
||||||
|
item.is_active = active
|
||||||
|
account.is_active = active
|
||||||
|
membership.is_active = active
|
||||||
|
item.retired_at = None if active else utcnow()
|
||||||
|
item.revision += 1
|
||||||
|
item.updated_by_account_id = principal.account_id
|
||||||
|
try:
|
||||||
|
session.flush()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise ServiceAccountConflictError(
|
||||||
|
"A service account with this name already exists"
|
||||||
|
) from exc
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def retire_service_account(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
service_account_id: str,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
expected_revision: int,
|
||||||
|
) -> ServiceAccount:
|
||||||
|
item = update_service_account(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
service_account_id=service_account_id,
|
||||||
|
principal=principal,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
changes={"is_active": False},
|
||||||
|
)
|
||||||
|
now = utc_now()
|
||||||
|
credentials = session.scalars(
|
||||||
|
select(ApiKey).where(
|
||||||
|
ApiKey.tenant_id == tenant_id,
|
||||||
|
ApiKey.user_id == item.membership_id,
|
||||||
|
ApiKey.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for credential in credentials:
|
||||||
|
credential.revoked_at = now
|
||||||
|
session.flush()
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _locked_service_account_for_credential_change(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
service_account_id: str,
|
||||||
|
expected_revision: int,
|
||||||
|
) -> ServiceAccount:
|
||||||
|
item = get_service_account(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
service_account_id=service_account_id,
|
||||||
|
lock=True,
|
||||||
|
)
|
||||||
|
if item.revision != expected_revision:
|
||||||
|
raise ServiceAccountConflictError(
|
||||||
|
"Service account changed on the server; reload before changing credentials"
|
||||||
|
)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _locked_service_account_credential(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
item: ServiceAccount,
|
||||||
|
credential_id: str,
|
||||||
|
) -> ApiKey:
|
||||||
|
credential = session.scalar(
|
||||||
|
select(ApiKey)
|
||||||
|
.where(
|
||||||
|
ApiKey.id == credential_id,
|
||||||
|
ApiKey.tenant_id == item.tenant_id,
|
||||||
|
ApiKey.user_id == item.membership_id,
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
if credential is None:
|
||||||
|
raise ServiceAccountCredentialNotFoundError(
|
||||||
|
"Service-account credential was not found"
|
||||||
|
)
|
||||||
|
return credential
|
||||||
|
|
||||||
|
|
||||||
|
def _active_service_account_membership(
|
||||||
|
session: Session,
|
||||||
|
item: ServiceAccount,
|
||||||
|
) -> User:
|
||||||
|
user = session.get(User, item.membership_id)
|
||||||
|
account = session.get(Account, item.account_id)
|
||||||
|
if (
|
||||||
|
not item.is_active
|
||||||
|
or item.retired_at is not None
|
||||||
|
or user is None
|
||||||
|
or account is None
|
||||||
|
or not user.is_active
|
||||||
|
or not account.is_active
|
||||||
|
):
|
||||||
|
raise ServiceAccountConflictError(
|
||||||
|
"Activate the service account before creating or rotating credentials"
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _service_account_credential_scopes(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
item: ServiceAccount,
|
||||||
|
values: Iterable[str],
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
scopes = tuple(
|
||||||
|
sorted(
|
||||||
|
{
|
||||||
|
str(value).strip()
|
||||||
|
for value in values
|
||||||
|
if str(value).strip()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not scopes:
|
||||||
|
raise ServiceAccountError(
|
||||||
|
"A service-account credential requires at least one scope"
|
||||||
|
)
|
||||||
|
if len(scopes) > 200:
|
||||||
|
raise ServiceAccountError(
|
||||||
|
"Service-account credentials support at most 200 scopes"
|
||||||
|
)
|
||||||
|
denied_by_ceiling = tuple(
|
||||||
|
scope
|
||||||
|
for scope in scopes
|
||||||
|
if not scopes_grant(item.scope_ceiling, scope)
|
||||||
|
)
|
||||||
|
if denied_by_ceiling:
|
||||||
|
raise PermissionError(
|
||||||
|
"Credential scopes exceed the service-account scope ceiling: "
|
||||||
|
+ ", ".join(denied_by_ceiling)
|
||||||
|
)
|
||||||
|
denied_by_actor = tuple(
|
||||||
|
scope for scope in scopes if not principal.has(scope)
|
||||||
|
)
|
||||||
|
if denied_by_actor:
|
||||||
|
raise PermissionError(
|
||||||
|
"Credential scopes exceed the current administrator authority: "
|
||||||
|
+ ", ".join(denied_by_actor)
|
||||||
|
)
|
||||||
|
return scopes
|
||||||
|
|
||||||
|
|
||||||
|
def _credential_name(value: str) -> str:
|
||||||
|
clean = " ".join(value.split())
|
||||||
|
if not 1 <= len(clean) <= 255:
|
||||||
|
raise ServiceAccountError(
|
||||||
|
"Credential name must contain between 1 and 255 characters"
|
||||||
|
)
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def _future_expiry(value: datetime | None) -> datetime | None:
|
||||||
|
expires_at = ensure_aware_utc(value)
|
||||||
|
if expires_at is not None and expires_at <= utc_now():
|
||||||
|
raise ServiceAccountError(
|
||||||
|
"Credential expiry must be in the future"
|
||||||
|
)
|
||||||
|
return expires_at
|
||||||
|
|
||||||
|
|
||||||
|
def _touch_service_account(
|
||||||
|
item: ServiceAccount,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
) -> None:
|
||||||
|
item.revision += 1
|
||||||
|
item.updated_by_account_id = principal.account_id
|
||||||
|
|
||||||
|
|
||||||
|
def _service_account_name(value: str) -> str:
|
||||||
|
clean = " ".join(value.split())
|
||||||
|
if not 1 <= len(clean) <= 255:
|
||||||
|
raise ServiceAccountError(
|
||||||
|
"Service account name must contain between 1 and 255 characters"
|
||||||
|
)
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_name(value: str) -> str:
|
||||||
|
return value.casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_text(value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
clean = value.strip()
|
||||||
|
if len(clean) > 4000:
|
||||||
|
raise ServiceAccountError(
|
||||||
|
"Service account description is too long"
|
||||||
|
)
|
||||||
|
return clean or None
|
||||||
|
|
||||||
|
|
||||||
|
def _service_account_scopes(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
values: Iterable[str],
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
scopes = tuple(
|
||||||
|
sorted(
|
||||||
|
{
|
||||||
|
str(value).strip()
|
||||||
|
for value in values
|
||||||
|
if str(value).strip()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(scopes) > 200:
|
||||||
|
raise ServiceAccountError(
|
||||||
|
"Service accounts support at most 200 scope grants"
|
||||||
|
)
|
||||||
|
denied = tuple(
|
||||||
|
scope for scope in scopes
|
||||||
|
if not principal.has(scope)
|
||||||
|
)
|
||||||
|
if denied:
|
||||||
|
raise PermissionError(
|
||||||
|
"Cannot grant service-account scopes outside the current "
|
||||||
|
f"administrator authority: {', '.join(denied)}"
|
||||||
|
)
|
||||||
|
return scopes
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ServiceAccountConflictError",
|
||||||
|
"ServiceAccountCredentialNotFoundError",
|
||||||
|
"ServiceAccountCredentialSummary",
|
||||||
|
"ServiceAccountError",
|
||||||
|
"ServiceAccountNotFoundError",
|
||||||
|
"create_service_account",
|
||||||
|
"create_service_account_credential",
|
||||||
|
"get_service_account",
|
||||||
|
"list_service_accounts",
|
||||||
|
"list_service_account_credentials",
|
||||||
|
"revoke_service_account_credential",
|
||||||
|
"retire_service_account",
|
||||||
|
"rotate_service_account_credential",
|
||||||
|
"service_account_credential_summaries",
|
||||||
|
"update_service_account",
|
||||||
|
]
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import AuthSession
|
||||||
|
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||||
|
|
||||||
|
|
||||||
|
MAX_SESSION_LIST_ITEMS = 100
|
||||||
|
MAX_CLIENT_LABEL_LENGTH = 160
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SessionSummary:
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
current: bool
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
last_seen_at: datetime | None
|
||||||
|
expires_at: datetime
|
||||||
|
revoked_at: datetime | None
|
||||||
|
client: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def session_summary(
|
||||||
|
item: AuthSession,
|
||||||
|
*,
|
||||||
|
current_session_id: str | None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> SessionSummary:
|
||||||
|
effective_at = now or utc_now()
|
||||||
|
expires_at = ensure_aware_utc(item.expires_at)
|
||||||
|
revoked_at = ensure_aware_utc(item.revoked_at)
|
||||||
|
if revoked_at is not None:
|
||||||
|
status = "revoked"
|
||||||
|
elif expires_at is None or expires_at <= effective_at:
|
||||||
|
status = "expired"
|
||||||
|
else:
|
||||||
|
status = "active"
|
||||||
|
return SessionSummary(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
current=item.id == current_session_id,
|
||||||
|
status=status,
|
||||||
|
created_at=item.created_at,
|
||||||
|
last_seen_at=item.last_seen_at,
|
||||||
|
expires_at=item.expires_at,
|
||||||
|
revoked_at=item.revoked_at,
|
||||||
|
client=_bounded_client(item.user_agent),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_account_sessions(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
account_id: str,
|
||||||
|
current_session_id: str | None,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
include_inactive: bool = False,
|
||||||
|
limit: int = MAX_SESSION_LIST_ITEMS,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> tuple[SessionSummary, ...]:
|
||||||
|
effective_at = now or utc_now()
|
||||||
|
query = session.query(AuthSession).filter(AuthSession.account_id == account_id)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.filter(AuthSession.tenant_id == tenant_id)
|
||||||
|
rows = query.order_by(AuthSession.created_at.desc(), AuthSession.id.asc()).all()
|
||||||
|
summaries = tuple(
|
||||||
|
session_summary(
|
||||||
|
item,
|
||||||
|
current_session_id=current_session_id,
|
||||||
|
now=effective_at,
|
||||||
|
)
|
||||||
|
for item in rows
|
||||||
|
)
|
||||||
|
if not include_inactive:
|
||||||
|
summaries = tuple(item for item in summaries if item.status == "active")
|
||||||
|
return summaries[: max(1, min(limit, MAX_SESSION_LIST_ITEMS))]
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_account_session(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
account_id: str,
|
||||||
|
session_id: str,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
protected_session_id: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> tuple[AuthSession | None, bool]:
|
||||||
|
query = session.query(AuthSession).filter(
|
||||||
|
AuthSession.id == session_id,
|
||||||
|
AuthSession.account_id == account_id,
|
||||||
|
)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.filter(AuthSession.tenant_id == tenant_id)
|
||||||
|
item = query.one_or_none()
|
||||||
|
if item is None:
|
||||||
|
return None, False
|
||||||
|
if protected_session_id is not None and item.id == protected_session_id:
|
||||||
|
raise ValueError("The current session cannot be revoked through session management.")
|
||||||
|
if item.revoked_at is not None:
|
||||||
|
return item, False
|
||||||
|
item.revoked_at = now or utc_now()
|
||||||
|
session.add(item)
|
||||||
|
return item, True
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_other_account_sessions(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
account_id: str,
|
||||||
|
current_session_id: str,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
effective_at = now or utc_now()
|
||||||
|
rows = (
|
||||||
|
session.query(AuthSession)
|
||||||
|
.filter(
|
||||||
|
AuthSession.account_id == account_id,
|
||||||
|
AuthSession.id != current_session_id,
|
||||||
|
AuthSession.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
revoked: list[str] = []
|
||||||
|
for item in rows:
|
||||||
|
expires_at = ensure_aware_utc(item.expires_at)
|
||||||
|
if expires_at is None or expires_at <= effective_at:
|
||||||
|
continue
|
||||||
|
item.revoked_at = effective_at
|
||||||
|
session.add(item)
|
||||||
|
revoked.append(item.id)
|
||||||
|
return tuple(sorted(revoked))
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_client(value: str | None) -> str | None:
|
||||||
|
normalized = " ".join(str(value or "").split())
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
return normalized[:MAX_CLIENT_LABEL_LENGTH]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MAX_CLIENT_LABEL_LENGTH",
|
||||||
|
"MAX_SESSION_LIST_ITEMS",
|
||||||
|
"SessionSummary",
|
||||||
|
"list_account_sessions",
|
||||||
|
"revoke_account_session",
|
||||||
|
"revoke_other_account_sessions",
|
||||||
|
"session_summary",
|
||||||
|
]
|
||||||
@@ -5,11 +5,21 @@ from collections.abc import Mapping, Sequence
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_access.backend.admin.service import ensure_default_roles, get_or_create_account
|
from govoplan_access.backend.admin.service import ensure_default_roles, get_or_create_account
|
||||||
from govoplan_access.backend.db.models import Account, SystemRoleAssignment, User, UserRoleAssignment
|
from govoplan_access.backend.db.models import Account, Role, SystemRoleAssignment, User, UserRoleAssignment
|
||||||
|
from govoplan_access.backend.permissions.catalog import normalize_email, scopes_grant
|
||||||
from govoplan_access.backend.security.api_keys import create_api_key
|
from govoplan_access.backend.security.api_keys import create_api_key
|
||||||
from govoplan_access.backend.security.passwords import hash_password
|
from govoplan_access.backend.security.passwords import hash_password
|
||||||
from govoplan_core.admin.common import AdminValidationError
|
from govoplan_core.admin.common import AdminValidationError
|
||||||
from govoplan_core.core.access import CreatedApiKeyRef, DevelopmentBootstrapRef, TenantAccessProvisioner, TenantOwnerCandidateRef, UserRef
|
from govoplan_core.core.access import (
|
||||||
|
CreatedApiKeyRef,
|
||||||
|
DevelopmentBootstrapRef,
|
||||||
|
FirstAdminProvisioner,
|
||||||
|
FirstAdminProvisioningError,
|
||||||
|
FirstSystemAdministratorRef,
|
||||||
|
TenantAccessProvisioner,
|
||||||
|
TenantOwnerCandidateRef,
|
||||||
|
UserRef,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LegacyTenantAccessProvisioner(TenantAccessProvisioner):
|
class LegacyTenantAccessProvisioner(TenantAccessProvisioner):
|
||||||
@@ -169,6 +179,108 @@ class LegacyTenantAccessProvisioner(TenantAccessProvisioner):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyFirstAdminProvisioner(FirstAdminProvisioner):
|
||||||
|
def has_durable_system_administrator(self, session: object) -> bool:
|
||||||
|
db = _session(session)
|
||||||
|
roles = (
|
||||||
|
db.query(Role)
|
||||||
|
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
|
||||||
|
.join(Account, Account.id == SystemRoleAssignment.account_id)
|
||||||
|
.filter(Role.tenant_id.is_(None), Account.is_active.is_(True))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return any(
|
||||||
|
role.slug in {"system_owner", "system_admin"}
|
||||||
|
or scopes_grant(role.permissions or (), "access:system_setting:write")
|
||||||
|
for role in roles
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_first_system_administrator(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant: object,
|
||||||
|
email: str,
|
||||||
|
display_name: str | None,
|
||||||
|
password: str,
|
||||||
|
) -> FirstSystemAdministratorRef:
|
||||||
|
db = _session(session)
|
||||||
|
tenant_id = getattr(tenant, "id", None)
|
||||||
|
if not tenant_id:
|
||||||
|
raise FirstAdminProvisioningError(
|
||||||
|
"First-administrator enrollment requires a persisted initial tenant."
|
||||||
|
)
|
||||||
|
if len(password) < 12:
|
||||||
|
raise FirstAdminProvisioningError(
|
||||||
|
"The administrator password must contain at least 12 characters."
|
||||||
|
)
|
||||||
|
if self.has_durable_system_administrator(db):
|
||||||
|
raise FirstAdminProvisioningError(
|
||||||
|
"A durable system administrator already exists."
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_email = normalize_email(email)
|
||||||
|
if not normalized_email or "@" not in normalized_email:
|
||||||
|
raise FirstAdminProvisioningError("Enter a valid administrator email address.")
|
||||||
|
existing = (
|
||||||
|
db.query(Account)
|
||||||
|
.filter(Account.normalized_email == normalized_email)
|
||||||
|
.with_for_update()
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
raise FirstAdminProvisioningError(
|
||||||
|
"The enrollment email already belongs to an account. Use a new address for the first system owner."
|
||||||
|
)
|
||||||
|
|
||||||
|
tenant_roles = ensure_default_roles(db, tenant) # type: ignore[arg-type]
|
||||||
|
system_roles = ensure_default_roles(db, None)
|
||||||
|
account, created, _temporary_password = get_or_create_account(
|
||||||
|
db,
|
||||||
|
email=email,
|
||||||
|
display_name=display_name,
|
||||||
|
password=password,
|
||||||
|
password_reset_required=False,
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
raise FirstAdminProvisioningError(
|
||||||
|
"The enrollment email already belongs to an account."
|
||||||
|
)
|
||||||
|
membership = User(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
account_id=account.id,
|
||||||
|
email=account.email,
|
||||||
|
display_name=display_name or account.display_name,
|
||||||
|
is_active=True,
|
||||||
|
is_tenant_admin=True,
|
||||||
|
auth_provider=account.auth_provider,
|
||||||
|
password_hash=account.password_hash,
|
||||||
|
)
|
||||||
|
db.add(membership)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
UserRoleAssignment(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=membership.id,
|
||||||
|
role_id=tenant_roles["owner"].id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
SystemRoleAssignment(
|
||||||
|
account_id=account.id,
|
||||||
|
role_id=system_roles["system_owner"].id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
return FirstSystemAdministratorRef(
|
||||||
|
account_id=account.id,
|
||||||
|
email=account.email,
|
||||||
|
display_name=account.display_name,
|
||||||
|
membership_id=membership.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _session(session: object) -> Session:
|
def _session(session: object) -> Session:
|
||||||
if not isinstance(session, Session):
|
if not isinstance(session, Session):
|
||||||
raise TypeError("Tenant access provisioner requires a SQLAlchemy Session")
|
raise TypeError("Tenant access provisioner requires a SQLAlchemy Session")
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import (
|
||||||
|
ApiKey,
|
||||||
|
AuthSession,
|
||||||
|
ExternalFunctionRoleAssignment,
|
||||||
|
Function,
|
||||||
|
FunctionAssignment,
|
||||||
|
FunctionDelegation,
|
||||||
|
FunctionRoleAssignment,
|
||||||
|
Group,
|
||||||
|
GroupRoleAssignment,
|
||||||
|
OrganizationUnit,
|
||||||
|
Role,
|
||||||
|
ServiceAccount,
|
||||||
|
User,
|
||||||
|
UserGroupMembership,
|
||||||
|
UserRoleAssignment,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.tenant_erasure import (
|
||||||
|
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX,
|
||||||
|
TenantErasurePreview,
|
||||||
|
TenantErasureResource,
|
||||||
|
TenantErasureStep,
|
||||||
|
TenantErasureStepResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ACCESS_TENANT_ERASURE_CAPABILITY = (
|
||||||
|
f"{TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX}access"
|
||||||
|
)
|
||||||
|
|
||||||
|
_CREDENTIAL_MODELS = (AuthSession, ApiKey)
|
||||||
|
_TENANT_ACCESS_MODELS = (
|
||||||
|
ServiceAccount,
|
||||||
|
FunctionDelegation,
|
||||||
|
ExternalFunctionRoleAssignment,
|
||||||
|
FunctionRoleAssignment,
|
||||||
|
UserGroupMembership,
|
||||||
|
UserRoleAssignment,
|
||||||
|
GroupRoleAssignment,
|
||||||
|
FunctionAssignment,
|
||||||
|
Function,
|
||||||
|
OrganizationUnit,
|
||||||
|
User,
|
||||||
|
Group,
|
||||||
|
Role,
|
||||||
|
)
|
||||||
|
_ALL_MODELS = _CREDENTIAL_MODELS + _TENANT_ACCESS_MODELS
|
||||||
|
|
||||||
|
|
||||||
|
def _counts(session: Session, tenant_id: str) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
model.__tablename__: session.query(model)
|
||||||
|
.filter(model.tenant_id == tenant_id)
|
||||||
|
.count()
|
||||||
|
for model in _ALL_MODELS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_models(
|
||||||
|
session: Session,
|
||||||
|
tenant_id: str,
|
||||||
|
models: tuple[type, ...],
|
||||||
|
) -> int:
|
||||||
|
deleted = 0
|
||||||
|
for model in models:
|
||||||
|
deleted += (
|
||||||
|
session.query(model)
|
||||||
|
.filter(model.tenant_id == tenant_id)
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
class AccessTenantErasureProvider:
|
||||||
|
module_id = "access"
|
||||||
|
|
||||||
|
def preview_tenant_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> TenantErasurePreview:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise TypeError("Access tenant erasure requires a database session.")
|
||||||
|
counts = _counts(session, tenant_id)
|
||||||
|
credential_count = sum(
|
||||||
|
counts[model.__tablename__] for model in _CREDENTIAL_MODELS
|
||||||
|
)
|
||||||
|
access_count = sum(
|
||||||
|
counts[model.__tablename__] for model in _TENANT_ACCESS_MODELS
|
||||||
|
)
|
||||||
|
resources = tuple(
|
||||||
|
TenantErasureResource(
|
||||||
|
resource_type=table_name,
|
||||||
|
count=count,
|
||||||
|
disposition="erase",
|
||||||
|
summary=f"{count} tenant-scoped Access records will be erased.",
|
||||||
|
)
|
||||||
|
for table_name, count in sorted(counts.items())
|
||||||
|
)
|
||||||
|
steps: list[TenantErasureStep] = []
|
||||||
|
if credential_count:
|
||||||
|
steps.append(
|
||||||
|
TenantErasureStep(
|
||||||
|
step_id="revoke-tenant-credentials",
|
||||||
|
kind="erase",
|
||||||
|
summary="Revoke tenant sessions and erase tenant API keys.",
|
||||||
|
destructive=True,
|
||||||
|
irreversible=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if access_count:
|
||||||
|
steps.append(
|
||||||
|
TenantErasureStep(
|
||||||
|
step_id="erase-tenant-access",
|
||||||
|
kind="erase",
|
||||||
|
summary=(
|
||||||
|
"Erase tenant memberships, service accounts, groups, roles, "
|
||||||
|
"organization units, functions, assignments, and delegations."
|
||||||
|
),
|
||||||
|
destructive=True,
|
||||||
|
irreversible=True,
|
||||||
|
depends_on=(
|
||||||
|
("revoke-tenant-credentials",) if credential_count else ()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return TenantErasurePreview(
|
||||||
|
module_id=self.module_id,
|
||||||
|
complete=True,
|
||||||
|
resources=resources,
|
||||||
|
steps=tuple(steps),
|
||||||
|
warnings=(
|
||||||
|
"Global accounts and identity links are retained because they may belong to other tenants.",
|
||||||
|
),
|
||||||
|
provider_revision="access-tenant-erasure-v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute_tenant_erasure_step(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
tenant_id: str,
|
||||||
|
step_id: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> TenantErasureStepResult:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise TypeError("Access tenant erasure requires a database session.")
|
||||||
|
if not idempotency_key.strip():
|
||||||
|
raise ValueError("Access tenant erasure requires an idempotency key.")
|
||||||
|
if step_id == "revoke-tenant-credentials":
|
||||||
|
deleted = _delete_models(session, tenant_id, _CREDENTIAL_MODELS)
|
||||||
|
summary = "Tenant sessions and API keys were erased."
|
||||||
|
elif step_id == "erase-tenant-access":
|
||||||
|
deleted = _delete_models(session, tenant_id, _TENANT_ACCESS_MODELS)
|
||||||
|
summary = "Tenant-scoped Access records were erased."
|
||||||
|
else:
|
||||||
|
return TenantErasureStepResult(
|
||||||
|
state="blocked",
|
||||||
|
summary="Access tenant erasure step is unknown.",
|
||||||
|
)
|
||||||
|
return TenantErasureStepResult(
|
||||||
|
state="completed",
|
||||||
|
summary=summary,
|
||||||
|
receipt_ref=f"access:tenant-erasure:{tenant_id}:{step_id}",
|
||||||
|
metrics={"deleted": deleted},
|
||||||
|
)
|
||||||
|
|
||||||
|
def reconcile_tenant_erasure_step(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
tenant_id: str,
|
||||||
|
step_id: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
) -> TenantErasureStepResult:
|
||||||
|
return self.execute_tenant_erasure_step(
|
||||||
|
session,
|
||||||
|
tenant_id,
|
||||||
|
step_id,
|
||||||
|
idempotency_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ACCESS_TENANT_ERASURE_CAPABILITY",
|
||||||
|
"AccessTenantErasureProvider",
|
||||||
|
]
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, event
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.administration import SqlAccessAdministration
|
||||||
from govoplan_access.backend.api.v1.admin_common import (
|
from govoplan_access.backend.api.v1.admin_common import (
|
||||||
_accounts_by_user_id,
|
_accounts_by_user_id,
|
||||||
_group_member_ids_by_group_id,
|
_group_member_ids_by_group_id,
|
||||||
@@ -13,7 +15,7 @@ from govoplan_access.backend.api.v1.admin_common import (
|
|||||||
_roles_by_user_id,
|
_roles_by_user_id,
|
||||||
_tenant_role_assignment_counts,
|
_tenant_role_assignment_counts,
|
||||||
)
|
)
|
||||||
from govoplan_access.backend.db.models import Account, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment
|
from govoplan_access.backend.db.models import Account, ApiKey, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -56,6 +58,92 @@ class AdminBatchHelperTests(unittest.TestCase):
|
|||||||
self.assertEqual([item.id for item in roles_by_user[user.id]], [role.id])
|
self.assertEqual([item.id for item in roles_by_user[user.id]], [role.id])
|
||||||
self.assertEqual(role_counts, {role.id: (1, 1)})
|
self.assertEqual(role_counts, {role.id: (1, 1)})
|
||||||
|
|
||||||
|
def test_tenant_counts_many_uses_three_grouped_queries(self) -> None:
|
||||||
|
accounts = [
|
||||||
|
Account(
|
||||||
|
id=f"account-{index}",
|
||||||
|
email=f"user-{index}@example.test",
|
||||||
|
normalized_email=f"user-{index}@example.test",
|
||||||
|
)
|
||||||
|
for index in range(3)
|
||||||
|
]
|
||||||
|
users = [
|
||||||
|
User(
|
||||||
|
id="user-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=accounts[0].id,
|
||||||
|
email=accounts[0].email,
|
||||||
|
),
|
||||||
|
User(
|
||||||
|
id="user-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=accounts[1].id,
|
||||||
|
email=accounts[1].email,
|
||||||
|
is_active=False,
|
||||||
|
),
|
||||||
|
User(
|
||||||
|
id="user-3",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
account_id=accounts[2].id,
|
||||||
|
email=accounts[2].email,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
self.session.add_all(
|
||||||
|
[
|
||||||
|
*accounts,
|
||||||
|
*users,
|
||||||
|
Group(id="group-1", tenant_id="tenant-1", slug="one", name="One"),
|
||||||
|
Group(id="group-2", tenant_id="tenant-2", slug="two", name="Two"),
|
||||||
|
ApiKey(
|
||||||
|
id="key-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
name="Active",
|
||||||
|
prefix="active",
|
||||||
|
key_hash="hash-1",
|
||||||
|
),
|
||||||
|
ApiKey(
|
||||||
|
id="key-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-2",
|
||||||
|
name="Revoked",
|
||||||
|
prefix="revoked",
|
||||||
|
key_hash="hash-2",
|
||||||
|
revoked_at=datetime.now(UTC),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
query_count = 0
|
||||||
|
|
||||||
|
def count_query(*_args: object) -> None:
|
||||||
|
nonlocal query_count
|
||||||
|
query_count += 1
|
||||||
|
|
||||||
|
event.listen(self.engine, "before_cursor_execute", count_query)
|
||||||
|
try:
|
||||||
|
counts = SqlAccessAdministration().tenant_counts_many(
|
||||||
|
self.session,
|
||||||
|
["tenant-1", "tenant-2", "tenant-empty"],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
event.remove(self.engine, "before_cursor_execute", count_query)
|
||||||
|
|
||||||
|
self.assertEqual(3, query_count)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"users": 2,
|
||||||
|
"active_users": 1,
|
||||||
|
"groups": 1,
|
||||||
|
"api_keys": 2,
|
||||||
|
"active_api_keys": 1,
|
||||||
|
},
|
||||||
|
counts["tenant-1"],
|
||||||
|
)
|
||||||
|
self.assertEqual(1, counts["tenant-2"]["users"])
|
||||||
|
self.assertEqual(0, counts["tenant-empty"]["users"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest import TestCase
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||||
|
from govoplan_access.backend.security.sessions import authenticate_session_token
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationActivityTouchTests(TestCase):
|
||||||
|
def test_session_activity_is_touched_only_after_the_interval(self) -> None:
|
||||||
|
now = datetime(2026, 7, 29, 10, 0, tzinfo=timezone.utc)
|
||||||
|
for age_seconds, should_touch in ((60, False), (301, True)):
|
||||||
|
with self.subTest(age_seconds=age_seconds):
|
||||||
|
model = SimpleNamespace(
|
||||||
|
expires_at=now + timedelta(hours=1),
|
||||||
|
last_seen_at=now - timedelta(seconds=age_seconds),
|
||||||
|
)
|
||||||
|
session = MagicMock()
|
||||||
|
(
|
||||||
|
session.query.return_value.options.return_value
|
||||||
|
.filter.return_value.one_or_none
|
||||||
|
).return_value = model
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_access.backend.security.sessions.hash_session_token",
|
||||||
|
return_value="hashed",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_access.backend.security.sessions.utc_now",
|
||||||
|
return_value=now,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = authenticate_session_token(
|
||||||
|
session,
|
||||||
|
"token",
|
||||||
|
touch_interval_seconds=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(model, result)
|
||||||
|
if should_touch:
|
||||||
|
self.assertEqual(now, model.last_seen_at)
|
||||||
|
session.add.assert_called_once_with(model)
|
||||||
|
else:
|
||||||
|
self.assertEqual(
|
||||||
|
now - timedelta(seconds=age_seconds),
|
||||||
|
model.last_seen_at,
|
||||||
|
)
|
||||||
|
session.add.assert_not_called()
|
||||||
|
|
||||||
|
def test_api_key_activity_is_touched_only_after_the_interval(self) -> None:
|
||||||
|
now = datetime(2026, 7, 29, 10, 0, tzinfo=timezone.utc)
|
||||||
|
for age_seconds, should_touch in ((60, False), (301, True)):
|
||||||
|
with self.subTest(age_seconds=age_seconds):
|
||||||
|
model = SimpleNamespace(
|
||||||
|
expires_at=None,
|
||||||
|
last_used_at=now - timedelta(seconds=age_seconds),
|
||||||
|
key_hash="hashed",
|
||||||
|
)
|
||||||
|
session = MagicMock()
|
||||||
|
(
|
||||||
|
session.query.return_value.options.return_value
|
||||||
|
.filter.return_value.all
|
||||||
|
).return_value = [model]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_access.backend.security.api_keys.verify_api_key",
|
||||||
|
return_value=True,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_access.backend.security.api_keys.utc_now",
|
||||||
|
return_value=now,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = authenticate_api_key(
|
||||||
|
session,
|
||||||
|
"mm_test-token",
|
||||||
|
touch_interval_seconds=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(model, result)
|
||||||
|
if should_touch:
|
||||||
|
self.assertEqual(now, model.last_used_at)
|
||||||
|
session.add.assert_called_once_with(model)
|
||||||
|
else:
|
||||||
|
self.assertEqual(
|
||||||
|
now - timedelta(seconds=age_seconds),
|
||||||
|
model.last_used_at,
|
||||||
|
)
|
||||||
|
session.add.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
unittest.main()
|
||||||
@@ -1,13 +1,32 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from govoplan_access.backend.auth.dependencies import _extract_token, _requires_csrf, _resolve_legacy_principal_ref
|
from govoplan_access.backend.auth.dependencies import (
|
||||||
|
_extract_token,
|
||||||
|
_principal_idm_context,
|
||||||
|
_requires_csrf,
|
||||||
|
_resolve_legacy_principal_context,
|
||||||
|
_resolve_legacy_principal_ref,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||||
|
from govoplan_access.backend.auth.tokens import hash_secret
|
||||||
|
from govoplan_access.backend.db.base import AccessBase
|
||||||
|
from govoplan_access.backend.db.models import Account, AuthSession, Role, User, UserRoleAssignment
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||||
|
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||||
|
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.security.time import utc_now
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
|
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||||
|
|
||||||
|
|
||||||
def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ()) -> Request:
|
def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ()) -> Request:
|
||||||
@@ -22,6 +41,9 @@ def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ())
|
|||||||
|
|
||||||
|
|
||||||
class AuthDependencyTests(unittest.TestCase):
|
class AuthDependencyTests(unittest.TestCase):
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
|
||||||
def test_extract_token_prefers_explicit_api_key(self) -> None:
|
def test_extract_token_prefers_explicit_api_key(self) -> None:
|
||||||
request = request_for(headers=[("authorization", "Bearer session-token")])
|
request = request_for(headers=[("authorization", "Bearer session-token")])
|
||||||
|
|
||||||
@@ -44,6 +66,200 @@ class AuthDependencyTests(unittest.TestCase):
|
|||||||
self.assertEqual(raised.exception.status_code, 401)
|
self.assertEqual(raised.exception.status_code, 401)
|
||||||
self.assertEqual(raised.exception.detail, "Missing API key or session token")
|
self.assertEqual(raised.exception.detail, "Missing API key or session token")
|
||||||
|
|
||||||
|
def test_permission_revision_invalidates_cached_principal(self) -> None:
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
create_scope_tables(engine)
|
||||||
|
AccessBase.metadata.create_all(bind=engine)
|
||||||
|
Base.metadata.create_all(
|
||||||
|
bind=engine,
|
||||||
|
tables=[
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
ChangeSequenceRetentionFloor.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(bind=engine)
|
||||||
|
try:
|
||||||
|
with SessionLocal() as session:
|
||||||
|
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||||
|
account = Account(
|
||||||
|
id="account-1",
|
||||||
|
email="owner@example.test",
|
||||||
|
normalized_email="owner@example.test",
|
||||||
|
)
|
||||||
|
user = User(
|
||||||
|
id="user-1",
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
account_id=account.id,
|
||||||
|
email=account.email,
|
||||||
|
)
|
||||||
|
role = Role(
|
||||||
|
id="role-1",
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
slug="reader",
|
||||||
|
name="Reader",
|
||||||
|
permissions=["files:file:read"],
|
||||||
|
)
|
||||||
|
assignment = UserRoleAssignment(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
user_id=user.id,
|
||||||
|
role_id=role.id,
|
||||||
|
)
|
||||||
|
token = "ms_test-session-token"
|
||||||
|
auth_session = AuthSession(
|
||||||
|
id="session-1",
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
user_id=user.id,
|
||||||
|
account_id=account.id,
|
||||||
|
token_hash=hash_secret(token),
|
||||||
|
expires_at=utc_now() + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
session.add_all(
|
||||||
|
[tenant, account, user, role, assignment, auth_session]
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
request = request_for()
|
||||||
|
first = _resolve_legacy_principal_context(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
authorization=f"Bearer {token}",
|
||||||
|
x_api_key=None,
|
||||||
|
)
|
||||||
|
self.assertIn("files:file:read", first.principal.scopes)
|
||||||
|
|
||||||
|
role.permissions = ["files:file:write"]
|
||||||
|
session.add(role)
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
source_module="access",
|
||||||
|
resource_type="role",
|
||||||
|
resource_id=role.id,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
second = _resolve_legacy_principal_context(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
authorization=f"Bearer {token}",
|
||||||
|
x_api_key=None,
|
||||||
|
)
|
||||||
|
self.assertNotIn("files:file:read", second.principal.scopes)
|
||||||
|
self.assertIn("files:file:write", second.principal.scopes)
|
||||||
|
finally:
|
||||||
|
AccessBase.metadata.drop_all(bind=engine)
|
||||||
|
scope_registry.metadata.drop_all(bind=engine)
|
||||||
|
Base.metadata.drop_all(
|
||||||
|
bind=engine,
|
||||||
|
tables=[
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
ChangeSequenceRetentionFloor.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
def test_acting_assignment_requires_exact_session_selection(self) -> None:
|
||||||
|
class Directory:
|
||||||
|
def organization_function_assignments_for_account(
|
||||||
|
self,
|
||||||
|
account_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at=None,
|
||||||
|
):
|
||||||
|
del account_id, effective_at
|
||||||
|
return (
|
||||||
|
OrganizationFunctionAssignmentRef(
|
||||||
|
id="direct-1",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
identity_id="identity-1",
|
||||||
|
account_id="account-1",
|
||||||
|
function_id="function-direct",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
),
|
||||||
|
OrganizationFunctionAssignmentRef(
|
||||||
|
id="acting-1",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
identity_id="identity-1",
|
||||||
|
account_id="account-1",
|
||||||
|
function_id="function-acting",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
source="acting_for",
|
||||||
|
acting_for_account_id="represented-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
create_scope_tables(engine)
|
||||||
|
AccessBase.metadata.create_all(bind=engine)
|
||||||
|
SessionLocal = sessionmaker(bind=engine)
|
||||||
|
try:
|
||||||
|
with SessionLocal() as session:
|
||||||
|
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||||
|
account = Account(
|
||||||
|
id="account-1",
|
||||||
|
email="actor@example.test",
|
||||||
|
normalized_email="actor@example.test",
|
||||||
|
)
|
||||||
|
user = User(
|
||||||
|
id="user-1",
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
account_id=account.id,
|
||||||
|
email=account.email,
|
||||||
|
)
|
||||||
|
auth_session = AuthSession(
|
||||||
|
id="session-1",
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
user_id=user.id,
|
||||||
|
account_id=account.id,
|
||||||
|
token_hash="token-hash",
|
||||||
|
expires_at=utc_now() + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
session.add_all((tenant, account, user, auth_session))
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
ordinary, _ = _principal_idm_context(
|
||||||
|
session,
|
||||||
|
user=user,
|
||||||
|
account=account,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
idm_directory=Directory(), # type: ignore[arg-type]
|
||||||
|
organization_directory=None,
|
||||||
|
)
|
||||||
|
self.assertEqual([item.id for item in ordinary], ["direct-1"])
|
||||||
|
|
||||||
|
auth_session.acting_assignment_id = "acting-1"
|
||||||
|
auth_session.acting_for_account_id = "represented-1"
|
||||||
|
selected, _ = _principal_idm_context(
|
||||||
|
session,
|
||||||
|
user=user,
|
||||||
|
account=account,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
idm_directory=Directory(), # type: ignore[arg-type]
|
||||||
|
organization_directory=None,
|
||||||
|
auth_session=auth_session,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[item.id for item in selected],
|
||||||
|
["direct-1", "acting-1"],
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_session.acting_for_account_id = "wrong-account"
|
||||||
|
mismatched, _ = _principal_idm_context(
|
||||||
|
session,
|
||||||
|
user=user,
|
||||||
|
account=account,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
idm_directory=Directory(), # type: ignore[arg-type]
|
||||||
|
organization_directory=None,
|
||||||
|
auth_session=auth_session,
|
||||||
|
)
|
||||||
|
self.assertEqual([item.id for item in mismatched], ["direct-1"])
|
||||||
|
finally:
|
||||||
|
AccessBase.metadata.drop_all(bind=engine)
|
||||||
|
scope_registry.metadata.drop_all(bind=engine)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.auth.dependencies import (
|
||||||
|
AccessAutomationPrincipalProvider,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.db.base import AccessBase
|
||||||
|
from govoplan_access.backend.db.models import (
|
||||||
|
Account,
|
||||||
|
ServiceAccount,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.manifest import manifest
|
||||||
|
from govoplan_access.backend.security.sessions import (
|
||||||
|
UserAuthorizationContext,
|
||||||
|
)
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.automation import AutomationPrincipalRequest
|
||||||
|
from govoplan_core.tenancy.scope import (
|
||||||
|
Tenant,
|
||||||
|
create_scope_tables,
|
||||||
|
scope_registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationPrincipalTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
create_scope_tables(self.engine)
|
||||||
|
AccessBase.metadata.create_all(bind=self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session = self.Session()
|
||||||
|
self.account = Account(
|
||||||
|
id="account-1",
|
||||||
|
email="owner@example.test",
|
||||||
|
normalized_email="owner@example.test",
|
||||||
|
)
|
||||||
|
self.tenant = Tenant(
|
||||||
|
id="tenant-1",
|
||||||
|
slug="tenant-1",
|
||||||
|
name="Tenant 1",
|
||||||
|
)
|
||||||
|
self.user = User(
|
||||||
|
id="user-1",
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
account_id=self.account.id,
|
||||||
|
email=self.account.email,
|
||||||
|
)
|
||||||
|
self.session.add_all([self.tenant, self.account, self.user])
|
||||||
|
self.session.commit()
|
||||||
|
self.provider = AccessAutomationPrincipalProvider()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
AccessBase.metadata.drop_all(bind=self.engine)
|
||||||
|
scope_registry.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _request(self) -> AutomationPrincipalRequest:
|
||||||
|
return AutomationPrincipalRequest(
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
account_id=self.account.id,
|
||||||
|
membership_id=self.user.id,
|
||||||
|
authorization_ref="dataflow-trigger:1",
|
||||||
|
grant_scopes=(
|
||||||
|
"dataflow:pipeline:run",
|
||||||
|
"datasources:catalogue:read",
|
||||||
|
),
|
||||||
|
context={
|
||||||
|
"trigger_ref": "dataflow-trigger:1",
|
||||||
|
"delivery_ref": "dataflow-delivery:1",
|
||||||
|
"event_actor": {
|
||||||
|
"type": "user",
|
||||||
|
"id": "event-user-1",
|
||||||
|
},
|
||||||
|
"operator_override": {
|
||||||
|
"type": "user",
|
||||||
|
"id": "operator-1",
|
||||||
|
"reason": "approved replay",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolution_intersects_trigger_grant_with_current_scopes(self) -> None:
|
||||||
|
context = UserAuthorizationContext(
|
||||||
|
tenant_roles=[],
|
||||||
|
system_roles=[],
|
||||||
|
groups=[],
|
||||||
|
function_assignment_ids=(),
|
||||||
|
function_delegation_ids=(),
|
||||||
|
scopes=[
|
||||||
|
"dataflow:pipeline:run",
|
||||||
|
"datasources:catalogue:read",
|
||||||
|
"system:settings:write",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"govoplan_access.backend.auth.dependencies."
|
||||||
|
"collect_user_authorization_context",
|
||||||
|
return_value=context,
|
||||||
|
):
|
||||||
|
result = self.provider.resolve_automation_principal(
|
||||||
|
self.session,
|
||||||
|
request=self._request(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result.allowed)
|
||||||
|
self.assertIsInstance(result.principal, ApiPrincipal)
|
||||||
|
self.assertEqual(
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
"dataflow:pipeline:run",
|
||||||
|
"datasources:catalogue:read",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
result.principal.scopes,
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
result.principal.principal.service_account_id
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.account.id,
|
||||||
|
result.principal.principal.acting_for_account_id,
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"system:settings:write",
|
||||||
|
result.principal.scopes,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"delegated_user",
|
||||||
|
result.provenance["trigger_owner"]["kind"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"event-user-1",
|
||||||
|
result.provenance["event_actor"]["id"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"operator-1",
|
||||||
|
result.provenance["operator_override"]["id"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.account.id,
|
||||||
|
result.provenance[
|
||||||
|
"current_automation_principal"
|
||||||
|
]["account_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_revoked_scope_and_suspended_owner_fail_closed(self) -> None:
|
||||||
|
context = UserAuthorizationContext(
|
||||||
|
tenant_roles=[],
|
||||||
|
system_roles=[],
|
||||||
|
groups=[],
|
||||||
|
function_assignment_ids=(),
|
||||||
|
function_delegation_ids=(),
|
||||||
|
scopes=["dataflow:pipeline:run"],
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"govoplan_access.backend.auth.dependencies."
|
||||||
|
"collect_user_authorization_context",
|
||||||
|
return_value=context,
|
||||||
|
):
|
||||||
|
result = self.provider.resolve_automation_principal(
|
||||||
|
self.session,
|
||||||
|
request=self._request(),
|
||||||
|
)
|
||||||
|
self.assertFalse(result.allowed)
|
||||||
|
self.assertEqual(
|
||||||
|
("datasources:catalogue:read",),
|
||||||
|
result.missing_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.account.is_active = False
|
||||||
|
self.session.flush()
|
||||||
|
suspended = self.provider.resolve_automation_principal(
|
||||||
|
self.session,
|
||||||
|
request=self._request(),
|
||||||
|
)
|
||||||
|
self.assertFalse(suspended.allowed)
|
||||||
|
self.assertEqual(
|
||||||
|
"inactive_or_inconsistent",
|
||||||
|
suspended.provenance["status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_service_account_resolution_uses_current_scope_ceiling(self) -> None:
|
||||||
|
account = Account(
|
||||||
|
id="service-account-backing",
|
||||||
|
email="service@example.invalid",
|
||||||
|
normalized_email="service@example.invalid",
|
||||||
|
display_name="Import worker",
|
||||||
|
auth_provider="service_account",
|
||||||
|
)
|
||||||
|
membership = User(
|
||||||
|
id="service-membership",
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
account_id=account.id,
|
||||||
|
email=account.email,
|
||||||
|
display_name=account.display_name,
|
||||||
|
auth_provider="service_account",
|
||||||
|
)
|
||||||
|
service_account = ServiceAccount(
|
||||||
|
id="service-1",
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
account_id=account.id,
|
||||||
|
membership_id=membership.id,
|
||||||
|
name="Import worker",
|
||||||
|
normalized_name="import worker",
|
||||||
|
scope_ceiling=[
|
||||||
|
"dataflow:pipeline:run",
|
||||||
|
"datasources:catalogue:read",
|
||||||
|
"system:settings:write",
|
||||||
|
],
|
||||||
|
is_active=True,
|
||||||
|
revision=1,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
(account, membership, service_account)
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
request = AutomationPrincipalRequest.service_account(
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
service_account_id=service_account.id,
|
||||||
|
authorization_ref="dataflow-trigger:service",
|
||||||
|
grant_scopes=(
|
||||||
|
"dataflow:pipeline:run",
|
||||||
|
"datasources:catalogue:read",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.provider.resolve_automation_principal(
|
||||||
|
self.session,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result.allowed)
|
||||||
|
self.assertEqual(
|
||||||
|
service_account.id,
|
||||||
|
result.principal.principal.service_account_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
frozenset(request.grant_scopes),
|
||||||
|
result.principal.scopes,
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"system:settings:write",
|
||||||
|
result.principal.scopes,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"service_account",
|
||||||
|
result.provenance["trigger_owner"]["kind"],
|
||||||
|
)
|
||||||
|
|
||||||
|
service_account.scope_ceiling = [
|
||||||
|
"dataflow:pipeline:run"
|
||||||
|
]
|
||||||
|
self.session.flush()
|
||||||
|
reduced = self.provider.resolve_automation_principal(
|
||||||
|
self.session,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
self.assertFalse(reduced.allowed)
|
||||||
|
self.assertEqual(
|
||||||
|
("datasources:catalogue:read",),
|
||||||
|
reduced.missing_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
service_account.is_active = False
|
||||||
|
self.session.flush()
|
||||||
|
inactive = self.provider.resolve_automation_principal(
|
||||||
|
self.session,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
self.assertFalse(inactive.allowed)
|
||||||
|
self.assertEqual(
|
||||||
|
"inactive_or_inconsistent",
|
||||||
|
inactive.provenance["status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_manifest_registers_automation_resolution(self) -> None:
|
||||||
|
self.assertIn(
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
|
manifest.capability_factories,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||||
|
ConfigurationPackageApplyResponse,
|
||||||
|
ConfigurationPackageExportResponse,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.api.v1.routes import _configuration_context
|
||||||
|
from govoplan_core.core.infrastructure_capabilities import (
|
||||||
|
InfrastructureCapabilityReceiptError,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.provider_governance import (
|
||||||
|
ExternalProviderRuntimeState,
|
||||||
|
ExternalProviderStateProviderRegistration,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigurationPackageContextTests(unittest.TestCase):
|
||||||
|
def test_api_responses_preserve_rollback_and_redacted_export_provenance(self) -> None:
|
||||||
|
applied = ConfigurationPackageApplyResponse(
|
||||||
|
rollback={
|
||||||
|
"status": "database_restore_required",
|
||||||
|
"summary": "Snapshot is the generic rollback boundary.",
|
||||||
|
"recovery_action": "Retain the snapshot.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
exported = ConfigurationPackageExportResponse(
|
||||||
|
provenance={
|
||||||
|
"exported_at": "2026-08-22T12:00:00+00:00",
|
||||||
|
"source_core_version": "0.1.35",
|
||||||
|
"module_versions": {"forms": "0.1.20"},
|
||||||
|
"tenant_id": "tenant-1",
|
||||||
|
"exporter_id": "user-1",
|
||||||
|
"selection": {
|
||||||
|
"scopes": ["tenant"],
|
||||||
|
"module_ids": ["forms"],
|
||||||
|
"object_refs": [],
|
||||||
|
},
|
||||||
|
"redacted_secret_keys": ["credential_ref"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
"database_restore_required",
|
||||||
|
applied.model_dump()["rollback"]["status"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["credential_ref"],
|
||||||
|
exported.model_dump()["provenance"]["redacted_secret_keys"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_context_carries_operator_scopes_and_validated_infrastructure_receipt(self) -> None:
|
||||||
|
receipt = SimpleNamespace(installation_id="deployment-1")
|
||||||
|
principal = SimpleNamespace(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user=SimpleNamespace(id="user-1"),
|
||||||
|
scopes=frozenset({"system:settings:write"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_access.backend.api.v1.routes.load_infrastructure_capability_receipt",
|
||||||
|
return_value=receipt,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
context = _configuration_context(principal)
|
||||||
|
|
||||||
|
self.assertIs(receipt, context.infrastructure_receipt)
|
||||||
|
self.assertEqual(
|
||||||
|
frozenset({"system:settings:write"}),
|
||||||
|
context.operator_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_context_preserves_invalid_receipt_as_fail_closed_provider_state(self) -> None:
|
||||||
|
principal = SimpleNamespace(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user=SimpleNamespace(id="user-1"),
|
||||||
|
scopes=frozenset(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_access.backend.api.v1.routes.load_infrastructure_capability_receipt",
|
||||||
|
side_effect=InfrastructureCapabilityReceiptError("invalid receipt"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
context = _configuration_context(principal)
|
||||||
|
|
||||||
|
self.assertIsNone(context.infrastructure_receipt)
|
||||||
|
self.assertEqual("invalid receipt", context.infrastructure_receipt_error)
|
||||||
|
|
||||||
|
def test_context_projects_installed_external_provider_declarations(self) -> None:
|
||||||
|
declaration = SimpleNamespace(
|
||||||
|
id="connectors.example",
|
||||||
|
to_dict=lambda: {
|
||||||
|
"id": "connectors.example",
|
||||||
|
"maturity": "read",
|
||||||
|
"authority_modes": ["external_mirror"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
registry = SimpleNamespace(
|
||||||
|
manifests=lambda: (
|
||||||
|
SimpleNamespace(id="access", version="0.1.14"),
|
||||||
|
SimpleNamespace(id="connectors", version="0.1.14"),
|
||||||
|
),
|
||||||
|
capability_names=lambda: ("connectors.profiles",),
|
||||||
|
external_provider_declarations=lambda: (declaration,),
|
||||||
|
)
|
||||||
|
principal = SimpleNamespace(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user=SimpleNamespace(id="user-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||||
|
return_value=registry,
|
||||||
|
):
|
||||||
|
context = _configuration_context(principal)
|
||||||
|
|
||||||
|
self.assertEqual("0.1.14", context.installed_modules["connectors"])
|
||||||
|
self.assertIn("connectors.profiles", context.capabilities)
|
||||||
|
self.assertEqual(
|
||||||
|
"external_mirror",
|
||||||
|
context.external_provider_declarations["connectors.example"][
|
||||||
|
"authority_modes"
|
||||||
|
][0],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_context_projects_tenant_runtime_provider_state(self) -> None:
|
||||||
|
declaration = SimpleNamespace(
|
||||||
|
id="calendar.caldav_sync",
|
||||||
|
to_dict=lambda: {
|
||||||
|
"id": "calendar.caldav_sync",
|
||||||
|
"maturity": "synchronize",
|
||||||
|
"authority_modes": ["governed_sync"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
registration = ExternalProviderStateProviderRegistration(
|
||||||
|
module_id="calendar",
|
||||||
|
provider_id="calendar.caldav_sync",
|
||||||
|
provider=lambda context: (
|
||||||
|
ExternalProviderRuntimeState(
|
||||||
|
provider_id="calendar.caldav_sync",
|
||||||
|
binding_ref="calendar:sync-source:one",
|
||||||
|
authority_mode="governed_sync",
|
||||||
|
observed_at=datetime(2026, 8, 1, 12, 0, tzinfo=UTC),
|
||||||
|
configured=True,
|
||||||
|
active=True,
|
||||||
|
health="healthy",
|
||||||
|
freshness="current",
|
||||||
|
conflict="clear",
|
||||||
|
recovery="ready",
|
||||||
|
metrics={"tenant_matches": context.tenant_id == "tenant-1"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
registry = SimpleNamespace(
|
||||||
|
manifests=lambda: (SimpleNamespace(id="calendar", version="0.1.8"),),
|
||||||
|
capability_names=lambda: (),
|
||||||
|
external_provider_declarations=lambda: (declaration,),
|
||||||
|
external_provider_state_providers=lambda: (registration,),
|
||||||
|
)
|
||||||
|
principal = SimpleNamespace(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user=SimpleNamespace(id="user-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||||
|
return_value=registry,
|
||||||
|
):
|
||||||
|
context = _configuration_context(principal, session=object())
|
||||||
|
|
||||||
|
state = context.external_provider_states["calendar.caldav_sync"]
|
||||||
|
self.assertEqual("healthy", state["health"])
|
||||||
|
self.assertEqual("calendar:sync-source:one", state["binding_ref"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.base import AccessBase
|
||||||
|
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, User
|
||||||
|
from govoplan_access.backend.dsar_provider import AccessDsarProvider
|
||||||
|
from govoplan_core.core.dsar import DsarSubjectRef
|
||||||
|
|
||||||
|
|
||||||
|
class AccessDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine(
|
||||||
|
"sqlite+pysqlite://",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
AccessBase.metadata.create_all(bind=self.engine)
|
||||||
|
self.session = sessionmaker(bind=self.engine, expire_on_commit=False)()
|
||||||
|
self.account = Account(
|
||||||
|
id="account-1",
|
||||||
|
email="ada@example.test",
|
||||||
|
normalized_email="ada@example.test",
|
||||||
|
display_name="Ada Example",
|
||||||
|
password_hash="secret-hash",
|
||||||
|
)
|
||||||
|
self.user = User(
|
||||||
|
id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=self.account.id,
|
||||||
|
email="ada@example.test",
|
||||||
|
display_name="Ada Example",
|
||||||
|
password_hash="tenant-secret-hash",
|
||||||
|
settings={"locale": "de"},
|
||||||
|
mail_profile_policy={"profile": "one"},
|
||||||
|
)
|
||||||
|
self.key = ApiKey(
|
||||||
|
id="key-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=self.user.id,
|
||||||
|
name="Automation",
|
||||||
|
prefix="gpn_example",
|
||||||
|
key_hash="do-not-export",
|
||||||
|
scopes=["files:read"],
|
||||||
|
)
|
||||||
|
self.auth_session = AuthSession(
|
||||||
|
id="session-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=self.user.id,
|
||||||
|
account_id=self.account.id,
|
||||||
|
token_hash="do-not-export",
|
||||||
|
csrf_token_hash="do-not-export",
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
||||||
|
user_agent="Browser fingerprint",
|
||||||
|
ip_address="192.0.2.10",
|
||||||
|
)
|
||||||
|
self.session.add_all([self.account, self.user, self.key, self.auth_session])
|
||||||
|
self.session.commit()
|
||||||
|
self.provider = AccessDsarProvider()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
AccessBase.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_search_omits_secret_and_client_fingerprint_material(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(email="ADA@example.test"),
|
||||||
|
)
|
||||||
|
serialized = repr([record.to_dict() for record in records])
|
||||||
|
self.assertIn("membership-1", serialized)
|
||||||
|
self.assertNotIn("do-not-export", serialized)
|
||||||
|
self.assertNotIn("Browser fingerprint", serialized)
|
||||||
|
self.assertNotIn("192.0.2.10", serialized)
|
||||||
|
|
||||||
|
def test_multiple_subject_selectors_must_identify_the_same_membership(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
membership_id=self.user.id,
|
||||||
|
email="different@example.test",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual((), records)
|
||||||
|
|
||||||
|
def test_plan_and_execution_anonymize_membership_and_revoke_credentials(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id=self.account.id),
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id=self.account.id),
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
executable = tuple(action for action in actions if action.executable)
|
||||||
|
self.assertEqual(3, len(executable))
|
||||||
|
self.assertTrue(any(action.kind == "manual_review" for action in actions))
|
||||||
|
|
||||||
|
results = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id=self.account.id),
|
||||||
|
actions=executable,
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
self.assertEqual({"executed"}, {result.status for result in results})
|
||||||
|
self.assertTrue(self.user.email.endswith("@invalid.govoplan"))
|
||||||
|
self.assertFalse(self.user.is_active)
|
||||||
|
self.assertEqual({}, self.user.settings)
|
||||||
|
self.assertIsNotNone(self.key.revoked_at)
|
||||||
|
self.assertIsNotNone(self.auth_session.revoked_at)
|
||||||
|
self.assertIsNone(self.auth_session.user_agent)
|
||||||
|
self.assertIsNone(self.auth_session.ip_address)
|
||||||
|
self.assertEqual("ada@example.test", self.account.email)
|
||||||
|
|
||||||
|
repeated = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(account_id=self.account.id),
|
||||||
|
actions=executable,
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
self.assertEqual({"unchanged"}, {result.status for result in repeated})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.base import AccessBase
|
||||||
|
from govoplan_access.backend.db.models import (
|
||||||
|
Account,
|
||||||
|
Role,
|
||||||
|
SystemRoleAssignment,
|
||||||
|
User,
|
||||||
|
UserRoleAssignment,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.security.passwords import verify_password
|
||||||
|
from govoplan_access.backend.tenancy.provisioning import LegacyFirstAdminProvisioner
|
||||||
|
from govoplan_core.core.access import FirstAdminProvisioningError
|
||||||
|
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||||
|
|
||||||
|
|
||||||
|
class FirstAdminProvisioningTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
create_scope_tables(self.engine)
|
||||||
|
AccessBase.metadata.create_all(bind=self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine, expire_on_commit=False)
|
||||||
|
self.session = self.Session()
|
||||||
|
self.tenant = Tenant(id="tenant-1", slug="default", name="Default Tenant")
|
||||||
|
self.session.add(self.tenant)
|
||||||
|
self.session.flush()
|
||||||
|
self.provisioner = LegacyFirstAdminProvisioner()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
AccessBase.metadata.drop_all(bind=self.engine)
|
||||||
|
scope_registry.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_creates_one_system_owner_with_a_login_membership(self) -> None:
|
||||||
|
created = self.provisioner.create_first_system_administrator(
|
||||||
|
self.session,
|
||||||
|
tenant=self.tenant,
|
||||||
|
email="Owner@Example.test",
|
||||||
|
display_name="System Owner",
|
||||||
|
password="a-production-password",
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
account = self.session.get(Account, created.account_id)
|
||||||
|
membership = self.session.get(User, created.membership_id)
|
||||||
|
self.assertIsNotNone(account)
|
||||||
|
self.assertIsNotNone(membership)
|
||||||
|
assert account is not None
|
||||||
|
assert membership is not None
|
||||||
|
self.assertTrue(verify_password("a-production-password", account.password_hash))
|
||||||
|
self.assertEqual(membership.tenant_id, self.tenant.id)
|
||||||
|
self.assertTrue(membership.is_tenant_admin)
|
||||||
|
system_role = (
|
||||||
|
self.session.query(Role)
|
||||||
|
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
|
||||||
|
.filter(SystemRoleAssignment.account_id == account.id)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
tenant_role = (
|
||||||
|
self.session.query(Role)
|
||||||
|
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
|
||||||
|
.filter(UserRoleAssignment.user_id == membership.id)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
self.assertEqual(system_role.slug, "system_owner")
|
||||||
|
self.assertEqual(tenant_role.slug, "owner")
|
||||||
|
self.assertTrue(
|
||||||
|
self.provisioner.has_durable_system_administrator(self.session)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(FirstAdminProvisioningError, "already exists"):
|
||||||
|
self.provisioner.create_first_system_administrator(
|
||||||
|
self.session,
|
||||||
|
tenant=self.tenant,
|
||||||
|
email="second@example.test",
|
||||||
|
display_name=None,
|
||||||
|
password="another-production-password",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_refuses_to_promote_or_reset_an_existing_account(self) -> None:
|
||||||
|
self.session.add(
|
||||||
|
Account(
|
||||||
|
email="existing@example.test",
|
||||||
|
normalized_email="existing@example.test",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(FirstAdminProvisioningError, "already belongs"):
|
||||||
|
self.provisioner.create_first_system_administrator(
|
||||||
|
self.session,
|
||||||
|
tenant=self.tenant,
|
||||||
|
email="existing@example.test",
|
||||||
|
display_name=None,
|
||||||
|
password="a-production-password",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, Group, Role, User, UserRoleAssignment
|
||||||
|
from govoplan_access.backend.governance_materializer import SqlAccessGovernanceMaterializer
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
GovernanceProjectionBatch,
|
||||||
|
GovernanceProjectionCommand,
|
||||||
|
GovernanceTemplateMaterialization,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _command(index: int, *, kind: str = "role", operation: str = "upsert") -> GovernanceProjectionCommand:
|
||||||
|
return GovernanceProjectionCommand(
|
||||||
|
assignment_id=f"assignment-{kind}-{index}",
|
||||||
|
operation=operation, # type: ignore[arg-type]
|
||||||
|
template=GovernanceTemplateMaterialization(
|
||||||
|
template_id=f"template-{kind}",
|
||||||
|
kind=kind, # type: ignore[arg-type]
|
||||||
|
tenant_id=f"tenant-{index}",
|
||||||
|
slug=f"managed-{kind}",
|
||||||
|
name=f"Managed {kind}",
|
||||||
|
permissions=("access:role:read",) if kind == "role" else (),
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
provenance={"source": "test", "assignment_mode": "required"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GovernanceProjectionTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session = self.Session()
|
||||||
|
self.materializer = SqlAccessGovernanceMaterializer()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_bulk_projection_is_idempotent_and_returns_per_assignment_outcomes(self) -> None:
|
||||||
|
commands = tuple(_command(index) for index in range(5))
|
||||||
|
first = self.materializer.reconcile(
|
||||||
|
self.session,
|
||||||
|
GovernanceProjectionBatch(operation_id="first", commands=commands),
|
||||||
|
)
|
||||||
|
second = self.materializer.reconcile(
|
||||||
|
self.session,
|
||||||
|
GovernanceProjectionBatch(operation_id="second", commands=commands),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["created"] * 5, [item.status for item in first.outcomes])
|
||||||
|
self.assertEqual(["unchanged"] * 5, [item.status for item in second.outcomes])
|
||||||
|
self.assertEqual(5, self.session.query(Role).count())
|
||||||
|
self.assertEqual(
|
||||||
|
{item.assignment_id for item in commands},
|
||||||
|
{item.assignment_id for item in second.outcomes},
|
||||||
|
)
|
||||||
|
self.assertTrue(all(item.provenance["source"] == "test" for item in second.outcomes))
|
||||||
|
|
||||||
|
def test_removal_isolated_blocker_preserves_other_batch_outcomes(self) -> None:
|
||||||
|
first, second = _command(1), _command(2)
|
||||||
|
created = self.materializer.reconcile(
|
||||||
|
self.session,
|
||||||
|
GovernanceProjectionBatch(operation_id="create", commands=(first, second)),
|
||||||
|
)
|
||||||
|
roles = {item.tenant_id: item.resource_id for item in created.outcomes}
|
||||||
|
account = Account(
|
||||||
|
id="account-1",
|
||||||
|
email="assigned@example.test",
|
||||||
|
normalized_email="assigned@example.test",
|
||||||
|
)
|
||||||
|
user = User(
|
||||||
|
id="user-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=account.id,
|
||||||
|
email=account.email,
|
||||||
|
)
|
||||||
|
self.session.add_all([account, user])
|
||||||
|
self.session.flush()
|
||||||
|
self.session.add(
|
||||||
|
UserRoleAssignment(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=user.id,
|
||||||
|
role_id=roles["tenant-1"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
removals = tuple(
|
||||||
|
GovernanceProjectionCommand(
|
||||||
|
assignment_id=item.assignment_id,
|
||||||
|
operation="remove",
|
||||||
|
template=item.template,
|
||||||
|
provenance=item.provenance,
|
||||||
|
)
|
||||||
|
for item in (first, second)
|
||||||
|
)
|
||||||
|
result = self.materializer.reconcile(
|
||||||
|
self.session,
|
||||||
|
GovernanceProjectionBatch(operation_id="remove", commands=removals),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["blocked", "removed"], [item.status for item in result.outcomes])
|
||||||
|
self.assertEqual(("role_has_users",), result.outcomes[0].blocker_codes)
|
||||||
|
self.assertIsNotNone(self.session.get(Role, roles["tenant-1"]))
|
||||||
|
self.assertIsNone(self.session.get(Role, roles["tenant-2"]))
|
||||||
|
|
||||||
|
def test_dry_run_does_not_mutate(self) -> None:
|
||||||
|
result = self.materializer.reconcile(
|
||||||
|
self.session,
|
||||||
|
GovernanceProjectionBatch(
|
||||||
|
operation_id="preview",
|
||||||
|
commands=(_command(1, kind="group"),),
|
||||||
|
dry_run=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual("created", result.outcomes[0].status)
|
||||||
|
self.assertEqual(0, self.session.query(Group).count())
|
||||||
|
|
||||||
|
def test_bulk_read_query_count_does_not_grow_per_assignment(self) -> None:
|
||||||
|
def select_count(size: int) -> int:
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
def record_select(_conn, _cursor, statement, _parameters, _context, _executemany):
|
||||||
|
nonlocal count
|
||||||
|
if statement.lstrip().upper().startswith("SELECT"):
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
event.listen(self.engine, "before_cursor_execute", record_select)
|
||||||
|
try:
|
||||||
|
self.materializer.reconcile(
|
||||||
|
self.session,
|
||||||
|
GovernanceProjectionBatch(
|
||||||
|
operation_id=f"preview-{size}",
|
||||||
|
commands=tuple(
|
||||||
|
_command(index, kind="group" if index % 2 else "role")
|
||||||
|
for index in range(size)
|
||||||
|
),
|
||||||
|
dry_run=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
event.remove(self.engine, "before_cursor_execute", record_select)
|
||||||
|
return count
|
||||||
|
|
||||||
|
small = select_count(2)
|
||||||
|
large = select_count(200)
|
||||||
|
self.assertEqual(small, large)
|
||||||
|
self.assertLessEqual(large, 4)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_access.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class InterfaceDocumentationContractTests(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_access_admin_topics_publish_stable_help_contexts(self) -> None:
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
|
||||||
|
expected_contexts = {
|
||||||
|
"access.workflow.grant-user-access": {
|
||||||
|
"access.admin.users",
|
||||||
|
"access.admin.groups",
|
||||||
|
"access.admin.roles",
|
||||||
|
"access.admin.blocked",
|
||||||
|
},
|
||||||
|
"access.reference.admin-access-fields": {
|
||||||
|
"access.admin.system-users",
|
||||||
|
"access.admin.system-roles",
|
||||||
|
"access.admin.tenant-users",
|
||||||
|
"access.admin.tenant-groups",
|
||||||
|
"access.admin.tenant-roles",
|
||||||
|
},
|
||||||
|
"access.workflow.manage-api-keys": {
|
||||||
|
"access.admin.api-keys",
|
||||||
|
"access.api-keys.action.create",
|
||||||
|
"access.api-keys.action.revoke",
|
||||||
|
"access.api-keys.field.owner",
|
||||||
|
"access.api-keys.field.expiry",
|
||||||
|
"access.api-keys.field.scopes",
|
||||||
|
"access.api-keys.secret",
|
||||||
|
"access.api-keys.confirm-revoke",
|
||||||
|
},
|
||||||
|
"access.workflow.manage-reusable-credentials": {
|
||||||
|
"access.admin.system-credentials",
|
||||||
|
"access.admin.tenant-credentials",
|
||||||
|
"access.admin.group-credentials",
|
||||||
|
"access.admin.user-credentials",
|
||||||
|
"access.settings.credentials",
|
||||||
|
"access.credentials",
|
||||||
|
"access.credentials.field.secret",
|
||||||
|
"access.credentials.field.clear-secret",
|
||||||
|
"access.credentials.field.inherit-to-lower-scopes",
|
||||||
|
"access.credentials.action.delete",
|
||||||
|
"access.credentials.confirm-delete",
|
||||||
|
},
|
||||||
|
"access.reference.external-function-role-mappings": {
|
||||||
|
"access.admin.function-mappings",
|
||||||
|
"access.explanation",
|
||||||
|
},
|
||||||
|
"access.workflow.manage-service-account-credentials": {
|
||||||
|
"access.admin.service-accounts",
|
||||||
|
"access.service-accounts.action.create",
|
||||||
|
"access.service-accounts.action.activation",
|
||||||
|
"access.service-accounts.action.retire",
|
||||||
|
"access.service-accounts.field.scope-ceiling",
|
||||||
|
"access.service-accounts.action.rotate-credential",
|
||||||
|
"access.service-accounts.action.revoke-credential",
|
||||||
|
"access.service-accounts.field.credential-expiry",
|
||||||
|
"access.service-accounts.field.credential-scopes",
|
||||||
|
"access.service-accounts.secret",
|
||||||
|
"access.service-accounts.confirm-retire",
|
||||||
|
},
|
||||||
|
"access.workflow.manage-sessions": {
|
||||||
|
"access.settings.sessions",
|
||||||
|
"access.sessions.action.revoke",
|
||||||
|
"access.sessions.action.revoke-others",
|
||||||
|
"access.admin.user-sessions",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for topic_id, expected in expected_contexts.items():
|
||||||
|
self.assertIn(topic_id, topics)
|
||||||
|
metadata = topics[topic_id].metadata or {}
|
||||||
|
self.assertTrue(
|
||||||
|
expected.issubset(set(metadata.get("help_contexts", ()))),
|
||||||
|
topic_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
credential_topic = topics["access.workflow.manage-reusable-credentials"]
|
||||||
|
self.assertEqual(
|
||||||
|
{"title", "summary", "body"},
|
||||||
|
set(credential_topic.translations["de"]),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"nicht rückgängig gemacht",
|
||||||
|
credential_topic.translations["de"]["body"],
|
||||||
|
)
|
||||||
|
|
||||||
|
api_key_topic = topics["access.workflow.manage-api-keys"]
|
||||||
|
self.assertEqual(
|
||||||
|
{"title", "summary", "body"},
|
||||||
|
set(api_key_topic.translations["de"]),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"sofort und kann für diesen Schlüssel nicht rückgängig gemacht werden",
|
||||||
|
api_key_topic.translations["de"]["body"],
|
||||||
|
)
|
||||||
|
|
||||||
|
service_account_topic = topics[
|
||||||
|
"access.workflow.manage-service-account-credentials"
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
{"title", "summary", "body"},
|
||||||
|
set(service_account_topic.translations["de"]),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"widerruft sämtliche aktiven Zugangsdaten",
|
||||||
|
service_account_topic.translations["de"]["body"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_access_admin_surfaces_remain_declared(self) -> None:
|
||||||
|
surface_ids = {surface.id for surface in manifest.frontend.view_surfaces}
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"access.admin.system-roles",
|
||||||
|
"access.admin.system-users",
|
||||||
|
"access.admin.system-credentials",
|
||||||
|
"access.admin.tenant-roles",
|
||||||
|
"access.admin.tenant-function-mappings",
|
||||||
|
"access.admin.tenant-groups",
|
||||||
|
"access.admin.tenant-users",
|
||||||
|
"access.admin.tenant-credentials",
|
||||||
|
"access.admin.tenant-api-keys",
|
||||||
|
"access.admin.tenant-service-accounts",
|
||||||
|
"access.admin.group-credentials",
|
||||||
|
"access.admin.user-credentials",
|
||||||
|
"access.settings.credentials",
|
||||||
|
"access.settings.sessions",
|
||||||
|
}.issubset(surface_ids)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -5,17 +5,30 @@ import pathlib
|
|||||||
import tomllib
|
import tomllib
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||||
|
SystemSettingsItem,
|
||||||
|
TenantCreateRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
class OptionalTenancyContractTests(unittest.TestCase):
|
class OptionalTenancyContractTests(unittest.TestCase):
|
||||||
|
def test_compatibility_admin_schemas_use_german_reference_default(self) -> None:
|
||||||
|
tenant = TenantCreateRequest(slug="example", name="Example")
|
||||||
|
|
||||||
|
self.assertEqual("de", tenant.default_locale)
|
||||||
|
self.assertEqual("de", SystemSettingsItem().default_locale)
|
||||||
|
|
||||||
def test_access_package_does_not_require_tenancy_to_install(self) -> None:
|
def test_access_package_does_not_require_tenancy_to_install(self) -> None:
|
||||||
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
|
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
|
||||||
dependencies = tuple(project["dependencies"])
|
dependencies = tuple(project["dependencies"])
|
||||||
|
|
||||||
self.assertIn("govoplan-core>=0.1.11", dependencies)
|
self.assertTrue(
|
||||||
|
any(item.startswith("govoplan-core>=") for item in dependencies)
|
||||||
|
)
|
||||||
self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies)
|
self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies)
|
||||||
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))
|
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, Group, User
|
||||||
|
from govoplan_access.backend.reference_options import (
|
||||||
|
SqlAccessReferenceOptionProvider,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.references import ReferenceSearchRequest
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AccessReferenceOptionProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[Account.__table__, User.__table__, Group.__table__],
|
||||||
|
)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
for index in range(120):
|
||||||
|
account = Account(
|
||||||
|
id=f"account-{index:03}",
|
||||||
|
email=f"person-{index:03}@example.test",
|
||||||
|
normalized_email=f"person-{index:03}@example.test",
|
||||||
|
)
|
||||||
|
self.session.add(account)
|
||||||
|
self.session.add(
|
||||||
|
User(
|
||||||
|
id=f"membership-{index:03}",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=account.id,
|
||||||
|
email=account.email,
|
||||||
|
display_name=f"Person {index:03}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for index in range(75):
|
||||||
|
self.session.add(
|
||||||
|
Group(
|
||||||
|
id=f"group-{index:03}",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug=f"group-{index:03}",
|
||||||
|
name=f"Group {index:03}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.provider = SqlAccessReferenceOptionProvider()
|
||||||
|
self.admin = SimpleNamespace(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-000",
|
||||||
|
group_ids=frozenset(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_large_directory_search_is_bounded_and_paged(self) -> None:
|
||||||
|
first = self.provider.search_reference_options(
|
||||||
|
self.session,
|
||||||
|
self.admin,
|
||||||
|
request=ReferenceSearchRequest(
|
||||||
|
kind="membership",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
limit=25,
|
||||||
|
context={"administrative": True},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
second = self.provider.search_reference_options(
|
||||||
|
self.session,
|
||||||
|
self.admin,
|
||||||
|
request=ReferenceSearchRequest(
|
||||||
|
kind="membership",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
limit=25,
|
||||||
|
cursor=first.next_cursor,
|
||||||
|
context={"administrative": True},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(25, len(first.options))
|
||||||
|
self.assertTrue(first.has_more)
|
||||||
|
self.assertEqual("offset:25", first.next_cursor)
|
||||||
|
self.assertEqual("membership-000", first.options[0].value)
|
||||||
|
self.assertEqual("membership-025", second.options[0].value)
|
||||||
|
|
||||||
|
def test_search_and_selected_values_do_not_materialize_the_directory(self) -> None:
|
||||||
|
page = self.provider.search_reference_options(
|
||||||
|
self.session,
|
||||||
|
self.admin,
|
||||||
|
request=ReferenceSearchRequest(
|
||||||
|
kind="membership",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
query="PERSON 119",
|
||||||
|
selected_values=("membership-005", "removed-membership"),
|
||||||
|
limit=10,
|
||||||
|
context={"administrative": True},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["membership-119", "membership-005"],
|
||||||
|
[option.value for option in page.options],
|
||||||
|
)
|
||||||
|
self.assertFalse(page.has_more)
|
||||||
|
|
||||||
|
def test_non_administrators_only_search_their_permitted_references(self) -> None:
|
||||||
|
principal = SimpleNamespace(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-004",
|
||||||
|
group_ids=frozenset({"group-007"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
users = self.provider.search_reference_options(
|
||||||
|
self.session,
|
||||||
|
principal,
|
||||||
|
request=ReferenceSearchRequest(
|
||||||
|
kind="user",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
groups = self.provider.search_reference_options(
|
||||||
|
self.session,
|
||||||
|
principal,
|
||||||
|
request=ReferenceSearchRequest(
|
||||||
|
kind="group",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["account-004"], [option.value for option in users.options])
|
||||||
|
self.assertEqual(["group-007"], [option.value for option in groups.options])
|
||||||
|
|
||||||
|
def test_invalid_cursor_is_rejected(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "Invalid reference search cursor"):
|
||||||
|
self.provider.search_reference_options(
|
||||||
|
self.session,
|
||||||
|
self.admin,
|
||||||
|
request=ReferenceSearchRequest(
|
||||||
|
kind="group",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
cursor="page:2",
|
||||||
|
context={"administrative": True},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS,
|
||||||
|
AccessExplanationSubjectDecision,
|
||||||
|
PrincipalRef,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.api.v1.routes import (
|
||||||
|
_access_explanation_subject_decision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _principal() -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="user-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id="account-1"),
|
||||||
|
user=SimpleNamespace(id="user-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _SubjectPolicy:
|
||||||
|
def decide_subject_selection(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: PrincipalRef,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> AccessExplanationSubjectDecision:
|
||||||
|
del session, principal, tenant_id
|
||||||
|
return AccessExplanationSubjectDecision(
|
||||||
|
allow_other_users=True,
|
||||||
|
reason="Permitted by test policy.",
|
||||||
|
source="test.policy",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider: object | None = None) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return (
|
||||||
|
name == CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS
|
||||||
|
and self.provider is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def require_capability(self, name: str) -> object:
|
||||||
|
if not self.has_capability(name):
|
||||||
|
raise KeyError(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceAccessExplanationSubjectTests(unittest.TestCase):
|
||||||
|
def test_missing_policy_defaults_to_current_user(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
decision = _access_explanation_subject_decision(
|
||||||
|
object(), # type: ignore[arg-type]
|
||||||
|
_principal(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(decision.allow_other_users)
|
||||||
|
self.assertEqual("access.safe_default", decision.source)
|
||||||
|
|
||||||
|
def test_policy_capability_controls_cross_user_selection(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||||
|
return_value=_Registry(_SubjectPolicy()),
|
||||||
|
):
|
||||||
|
decision = _access_explanation_subject_decision(
|
||||||
|
object(), # type: ignore[arg-type]
|
||||||
|
_principal(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(decision.allow_other_users)
|
||||||
|
self.assertEqual("test.policy", decision.source)
|
||||||
|
|
||||||
|
def test_route_contract_is_tenant_bounded_and_audited(self) -> None:
|
||||||
|
source = (
|
||||||
|
ROOT / "src/govoplan_access/backend/api/v1/routes.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn('User.tenant_id == tenant.id', source)
|
||||||
|
self.assertIn('User.id == principal.membership_id', source)
|
||||||
|
self.assertIn(
|
||||||
|
'action="access.resource_explanation.selected_user_viewed"',
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.base import AccessBase
|
||||||
|
from govoplan_access.backend.db.models import (
|
||||||
|
Account,
|
||||||
|
ApiKey,
|
||||||
|
AuthSession,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.service_accounts import (
|
||||||
|
ServiceAccountConflictError,
|
||||||
|
create_service_account,
|
||||||
|
create_service_account_credential,
|
||||||
|
revoke_service_account_credential,
|
||||||
|
retire_service_account,
|
||||||
|
rotate_service_account_credential,
|
||||||
|
service_account_credential_summaries,
|
||||||
|
update_service_account,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.auth.dependencies import (
|
||||||
|
_resolve_api_key_principal_context,
|
||||||
|
)
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.tenancy.scope import (
|
||||||
|
Tenant,
|
||||||
|
create_scope_tables,
|
||||||
|
scope_registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
create_scope_tables(self.engine)
|
||||||
|
AccessBase.metadata.create_all(bind=self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session = self.Session()
|
||||||
|
self.tenant = Tenant(
|
||||||
|
id="tenant-1",
|
||||||
|
slug="tenant-1",
|
||||||
|
name="Tenant 1",
|
||||||
|
)
|
||||||
|
self.account = Account(
|
||||||
|
id="account-1",
|
||||||
|
email="admin@example.test",
|
||||||
|
normalized_email="admin@example.test",
|
||||||
|
)
|
||||||
|
self.user = User(
|
||||||
|
id="user-1",
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
account_id=self.account.id,
|
||||||
|
email=self.account.email,
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
(self.tenant, self.account, self.user)
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
AccessBase.metadata.drop_all(bind=self.engine)
|
||||||
|
scope_registry.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _principal(
|
||||||
|
self,
|
||||||
|
scopes: frozenset[str] = frozenset({"tenant:*"}),
|
||||||
|
) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id=self.account.id,
|
||||||
|
membership_id=self.user.id,
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
scopes=scopes,
|
||||||
|
),
|
||||||
|
account=self.account,
|
||||||
|
user=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_builds_a_non_login_identity_without_secrets(self) -> None:
|
||||||
|
item = create_service_account(
|
||||||
|
self.session,
|
||||||
|
tenant=self.tenant,
|
||||||
|
principal=self._principal(),
|
||||||
|
name=" Monthly import ",
|
||||||
|
description="Runs the governed monthly import.",
|
||||||
|
scope_ceiling=(
|
||||||
|
"dataflow:pipeline:run",
|
||||||
|
"datasources:catalogue:read",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
backing_account = self.session.get(
|
||||||
|
Account,
|
||||||
|
item.account_id,
|
||||||
|
)
|
||||||
|
membership = self.session.get(
|
||||||
|
User,
|
||||||
|
item.membership_id,
|
||||||
|
)
|
||||||
|
self.assertEqual("Monthly import", item.name)
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"dataflow:pipeline:run",
|
||||||
|
"datasources:catalogue:read",
|
||||||
|
],
|
||||||
|
item.scope_ceiling,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"service_account",
|
||||||
|
backing_account.auth_provider,
|
||||||
|
)
|
||||||
|
self.assertIsNone(backing_account.password_hash)
|
||||||
|
self.assertEqual(
|
||||||
|
"service_account",
|
||||||
|
membership.auth_provider,
|
||||||
|
)
|
||||||
|
self.assertIsNone(membership.password_hash)
|
||||||
|
self.assertEqual(
|
||||||
|
0,
|
||||||
|
self.session.query(ApiKey)
|
||||||
|
.filter(ApiKey.user_id == membership.id)
|
||||||
|
.count(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
0,
|
||||||
|
self.session.query(AuthSession)
|
||||||
|
.filter(AuthSession.user_id == membership.id)
|
||||||
|
.count(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_scope_escalation_and_stale_updates_fail_closed(self) -> None:
|
||||||
|
principal = self._principal(
|
||||||
|
frozenset({"dataflow:pipeline:run"})
|
||||||
|
)
|
||||||
|
with self.assertRaises(PermissionError):
|
||||||
|
create_service_account(
|
||||||
|
self.session,
|
||||||
|
tenant=self.tenant,
|
||||||
|
principal=principal,
|
||||||
|
name="Escalating worker",
|
||||||
|
description=None,
|
||||||
|
scope_ceiling=("system:settings:write",),
|
||||||
|
)
|
||||||
|
|
||||||
|
item = create_service_account(
|
||||||
|
self.session,
|
||||||
|
tenant=self.tenant,
|
||||||
|
principal=principal,
|
||||||
|
name="Bounded worker",
|
||||||
|
description=None,
|
||||||
|
scope_ceiling=("dataflow:pipeline:run",),
|
||||||
|
)
|
||||||
|
updated = update_service_account(
|
||||||
|
self.session,
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
service_account_id=item.id,
|
||||||
|
principal=principal,
|
||||||
|
expected_revision=1,
|
||||||
|
changes={"description": "Updated"},
|
||||||
|
)
|
||||||
|
self.assertEqual(2, updated.revision)
|
||||||
|
with self.assertRaises(ServiceAccountConflictError):
|
||||||
|
update_service_account(
|
||||||
|
self.session,
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
service_account_id=item.id,
|
||||||
|
principal=principal,
|
||||||
|
expected_revision=1,
|
||||||
|
changes={"description": "Stale"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_retirement_revokes_the_backing_principal(self) -> None:
|
||||||
|
principal = self._principal()
|
||||||
|
item = create_service_account(
|
||||||
|
self.session,
|
||||||
|
tenant=self.tenant,
|
||||||
|
principal=principal,
|
||||||
|
name="Retired worker",
|
||||||
|
description=None,
|
||||||
|
scope_ceiling=("dataflow:pipeline:run",),
|
||||||
|
)
|
||||||
|
|
||||||
|
retired = retire_service_account(
|
||||||
|
self.session,
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
service_account_id=item.id,
|
||||||
|
principal=principal,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(retired.is_active)
|
||||||
|
self.assertIsNotNone(retired.retired_at)
|
||||||
|
self.assertFalse(
|
||||||
|
self.session.get(Account, retired.account_id).is_active
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
self.session.get(User, retired.membership_id).is_active
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_credentials_are_one_time_scope_bounded_and_rotatable(self) -> None:
|
||||||
|
principal = self._principal()
|
||||||
|
item = create_service_account(
|
||||||
|
self.session,
|
||||||
|
tenant=self.tenant,
|
||||||
|
principal=principal,
|
||||||
|
name="Monthly worker",
|
||||||
|
description=None,
|
||||||
|
scope_ceiling=("dataflow:pipeline:run",),
|
||||||
|
)
|
||||||
|
item, first = create_service_account_credential(
|
||||||
|
self.session,
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
service_account_id=item.id,
|
||||||
|
principal=principal,
|
||||||
|
expected_revision=1,
|
||||||
|
name="Worker credential",
|
||||||
|
scopes=("dataflow:pipeline:run",),
|
||||||
|
expires_at=None,
|
||||||
|
)
|
||||||
|
self.assertEqual(2, item.revision)
|
||||||
|
self.assertTrue(first.secret.startswith("gpn_"))
|
||||||
|
self.assertNotEqual(first.secret, first.model.key_hash)
|
||||||
|
|
||||||
|
item, previous, replacement = rotate_service_account_credential(
|
||||||
|
self.session,
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
service_account_id=item.id,
|
||||||
|
credential_id=first.model.id,
|
||||||
|
principal=principal,
|
||||||
|
expected_revision=2,
|
||||||
|
name=None,
|
||||||
|
scopes=None,
|
||||||
|
expires_at=None,
|
||||||
|
)
|
||||||
|
self.assertEqual(3, item.revision)
|
||||||
|
self.assertIsNotNone(previous.revoked_at)
|
||||||
|
self.assertIsNone(replacement.model.revoked_at)
|
||||||
|
self.assertNotEqual(first.secret, replacement.secret)
|
||||||
|
|
||||||
|
with self.assertRaises(ServiceAccountConflictError):
|
||||||
|
revoke_service_account_credential(
|
||||||
|
self.session,
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
service_account_id=item.id,
|
||||||
|
credential_id=replacement.model.id,
|
||||||
|
principal=principal,
|
||||||
|
expected_revision=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
item, revoked = revoke_service_account_credential(
|
||||||
|
self.session,
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
service_account_id=item.id,
|
||||||
|
credential_id=replacement.model.id,
|
||||||
|
principal=principal,
|
||||||
|
expected_revision=3,
|
||||||
|
)
|
||||||
|
self.assertEqual(4, item.revision)
|
||||||
|
self.assertIsNotNone(revoked.revoked_at)
|
||||||
|
summary = service_account_credential_summaries(
|
||||||
|
self.session,
|
||||||
|
service_accounts=(item,),
|
||||||
|
)[item.id]
|
||||||
|
self.assertEqual(2, summary.credential_count)
|
||||||
|
self.assertEqual(0, summary.active_credential_count)
|
||||||
|
|
||||||
|
def test_service_account_credential_uses_current_ceiling(self) -> None:
|
||||||
|
principal = self._principal()
|
||||||
|
item = create_service_account(
|
||||||
|
self.session,
|
||||||
|
tenant=self.tenant,
|
||||||
|
principal=principal,
|
||||||
|
name="Bounded API worker",
|
||||||
|
description=None,
|
||||||
|
scope_ceiling=("dataflow:pipeline:run",),
|
||||||
|
)
|
||||||
|
item, created = create_service_account_credential(
|
||||||
|
self.session,
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
service_account_id=item.id,
|
||||||
|
principal=principal,
|
||||||
|
expected_revision=1,
|
||||||
|
name="Runtime",
|
||||||
|
scopes=("dataflow:pipeline:run",),
|
||||||
|
expires_at=None,
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
context = _resolve_api_key_principal_context(
|
||||||
|
self.session,
|
||||||
|
token=created.secret,
|
||||||
|
idm_directory=None,
|
||||||
|
identity_directory=None,
|
||||||
|
organization_directory=None,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(context)
|
||||||
|
self.assertEqual("service_account", context.principal.auth_method)
|
||||||
|
self.assertEqual(item.id, context.principal.service_account_id)
|
||||||
|
self.assertEqual(created.model.id, context.principal.api_key_id)
|
||||||
|
self.assertEqual(
|
||||||
|
frozenset({"dataflow:pipeline:run"}),
|
||||||
|
context.principal.scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
update_service_account(
|
||||||
|
self.session,
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
service_account_id=item.id,
|
||||||
|
principal=principal,
|
||||||
|
expected_revision=2,
|
||||||
|
changes={"scope_ceiling": []},
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
narrowed = _resolve_api_key_principal_context(
|
||||||
|
self.session,
|
||||||
|
token=created.secret,
|
||||||
|
idm_directory=None,
|
||||||
|
identity_directory=None,
|
||||||
|
organization_directory=None,
|
||||||
|
)
|
||||||
|
self.assertEqual(frozenset(), narrowed.principal.scopes)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.base import AccessBase
|
||||||
|
from govoplan_access.backend.db.models import Account, AuthSession, User
|
||||||
|
from govoplan_access.backend.security.sessions import authenticate_session_token, hash_session_token
|
||||||
|
from govoplan_access.backend.security.passwords import hash_password
|
||||||
|
from govoplan_access.backend.api.v1.admin_schemas import AdminSessionItem
|
||||||
|
from govoplan_access.backend.api.v1.auth import AccountSessionInfo
|
||||||
|
from govoplan_access.backend.api.v1.routes import _require_session_admin_reauthorization
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from govoplan_access.backend.session_management import (
|
||||||
|
MAX_CLIENT_LABEL_LENGTH,
|
||||||
|
list_account_sessions,
|
||||||
|
revoke_account_session,
|
||||||
|
revoke_other_account_sessions,
|
||||||
|
)
|
||||||
|
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManagementTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
create_scope_tables(self.engine)
|
||||||
|
AccessBase.metadata.create_all(bind=self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session = self.Session()
|
||||||
|
self.now = datetime(2026, 8, 19, 20, 0, tzinfo=timezone.utc)
|
||||||
|
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||||
|
other_tenant = Tenant(id="tenant-2", slug="tenant-2", name="Tenant 2")
|
||||||
|
self.account = Account(
|
||||||
|
id="account-1",
|
||||||
|
email="person@example.test",
|
||||||
|
normalized_email="person@example.test",
|
||||||
|
)
|
||||||
|
other_account = Account(
|
||||||
|
id="account-2",
|
||||||
|
email="other@example.test",
|
||||||
|
normalized_email="other@example.test",
|
||||||
|
)
|
||||||
|
user = User(
|
||||||
|
id="user-1",
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
account_id=self.account.id,
|
||||||
|
email=self.account.email,
|
||||||
|
)
|
||||||
|
other_user = User(
|
||||||
|
id="user-2",
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
account_id=other_account.id,
|
||||||
|
email=other_account.email,
|
||||||
|
)
|
||||||
|
self.session.add_all((tenant, other_tenant, self.account, other_account, user, other_user))
|
||||||
|
self.session.flush()
|
||||||
|
self.tokens = {
|
||||||
|
"current": "ms_current",
|
||||||
|
"other": "ms_other",
|
||||||
|
"expired": "ms_expired",
|
||||||
|
"revoked": "ms_revoked",
|
||||||
|
"other-account": "ms_other_account",
|
||||||
|
}
|
||||||
|
self.session.add_all(
|
||||||
|
(
|
||||||
|
self._auth_session("current", "tenant-1", "user-1", "account-1"),
|
||||||
|
self._auth_session("other", "tenant-2", "user-1", "account-1"),
|
||||||
|
self._auth_session("expired", "tenant-1", "user-1", "account-1", expires=-1),
|
||||||
|
self._auth_session("revoked", "tenant-1", "user-1", "account-1", revoked=True),
|
||||||
|
self._auth_session("other-account", "tenant-1", "user-2", "account-2"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
AccessBase.metadata.drop_all(bind=self.engine)
|
||||||
|
scope_registry.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _auth_session(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
account_id: str,
|
||||||
|
*,
|
||||||
|
expires: int = 2,
|
||||||
|
revoked: bool = False,
|
||||||
|
) -> AuthSession:
|
||||||
|
return AuthSession(
|
||||||
|
id=f"session-{name}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
account_id=account_id,
|
||||||
|
token_hash=hash_session_token(self.tokens[name]),
|
||||||
|
expires_at=self.now + timedelta(hours=expires),
|
||||||
|
last_seen_at=self.now - timedelta(minutes=5),
|
||||||
|
revoked_at=self.now - timedelta(minutes=1) if revoked else None,
|
||||||
|
user_agent="Browser " + ("x" * 500),
|
||||||
|
ip_address="192.0.2.55",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_listing_is_account_scoped_bounded_and_redacted(self) -> None:
|
||||||
|
sensitive = {
|
||||||
|
"token",
|
||||||
|
"token_hash",
|
||||||
|
"csrf_token_hash",
|
||||||
|
"cookie",
|
||||||
|
"ip_address",
|
||||||
|
}
|
||||||
|
self.assertTrue(sensitive.isdisjoint(AccountSessionInfo.model_fields))
|
||||||
|
self.assertTrue(sensitive.isdisjoint(AdminSessionItem.model_fields))
|
||||||
|
active = list_account_sessions(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
current_session_id="session-current",
|
||||||
|
now=self.now,
|
||||||
|
)
|
||||||
|
self.assertEqual({"session-current", "session-other"}, {item.id for item in active})
|
||||||
|
self.assertTrue(next(item for item in active if item.id == "session-current").current)
|
||||||
|
self.assertTrue(all(len(item.client or "") <= MAX_CLIENT_LABEL_LENGTH for item in active))
|
||||||
|
self.assertNotIn("192.0.2.55", repr(active))
|
||||||
|
self.assertNotIn("token_hash", repr(active))
|
||||||
|
|
||||||
|
all_states = list_account_sessions(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
current_session_id="session-current",
|
||||||
|
include_inactive=True,
|
||||||
|
now=self.now,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{"active", "expired", "revoked"},
|
||||||
|
{item.status for item in all_states},
|
||||||
|
)
|
||||||
|
tenant_only = list_account_sessions(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
current_session_id="session-current",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
include_inactive=True,
|
||||||
|
now=self.now,
|
||||||
|
)
|
||||||
|
self.assertNotIn("session-other", {item.id for item in tenant_only})
|
||||||
|
|
||||||
|
def test_single_revocation_is_idempotent_and_effective_on_next_request(self) -> None:
|
||||||
|
item, changed = revoke_account_session(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
session_id="session-other",
|
||||||
|
protected_session_id="session-current",
|
||||||
|
now=self.now,
|
||||||
|
)
|
||||||
|
self.assertTrue(changed)
|
||||||
|
self.session.commit()
|
||||||
|
self.assertIsNotNone(item)
|
||||||
|
self.assertIsNone(
|
||||||
|
authenticate_session_token(self.session, self.tokens["other"])
|
||||||
|
)
|
||||||
|
|
||||||
|
repeated, changed = revoke_account_session(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
session_id="session-other",
|
||||||
|
protected_session_id="session-current",
|
||||||
|
now=self.now,
|
||||||
|
)
|
||||||
|
self.assertIs(item, repeated)
|
||||||
|
self.assertFalse(changed)
|
||||||
|
hidden, changed = revoke_account_session(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
session_id="session-other-account",
|
||||||
|
now=self.now,
|
||||||
|
)
|
||||||
|
self.assertIsNone(hidden)
|
||||||
|
self.assertFalse(changed)
|
||||||
|
|
||||||
|
def test_current_session_is_protected_and_revoke_others_skips_expired(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "current session"):
|
||||||
|
revoke_account_session(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
session_id="session-current",
|
||||||
|
protected_session_id="session-current",
|
||||||
|
now=self.now,
|
||||||
|
)
|
||||||
|
revoked = revoke_other_account_sessions(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
current_session_id="session-current",
|
||||||
|
now=self.now,
|
||||||
|
)
|
||||||
|
self.assertEqual(("session-other",), revoked)
|
||||||
|
self.assertIsNone(self.session.get(AuthSession, "session-current").revoked_at)
|
||||||
|
self.assertIsNone(self.session.get(AuthSession, "session-expired").revoked_at)
|
||||||
|
self.assertEqual(
|
||||||
|
(),
|
||||||
|
revoke_other_account_sessions(
|
||||||
|
self.session,
|
||||||
|
account_id=self.account.id,
|
||||||
|
current_session_id="session-current",
|
||||||
|
now=self.now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_administrative_revocation_requires_session_and_current_password(self) -> None:
|
||||||
|
self.account.password_hash = hash_password("correct horse")
|
||||||
|
membership = self.session.get(User, "user-1")
|
||||||
|
current = self.session.get(AuthSession, "session-current")
|
||||||
|
principal_ref = PrincipalRef(
|
||||||
|
account_id=self.account.id,
|
||||||
|
membership_id=membership.id,
|
||||||
|
tenant_id=membership.tenant_id,
|
||||||
|
scopes=frozenset({"access:membership:update"}),
|
||||||
|
auth_method="session",
|
||||||
|
session_id=current.id,
|
||||||
|
)
|
||||||
|
without_session = ApiPrincipal(
|
||||||
|
principal=principal_ref,
|
||||||
|
account=self.account,
|
||||||
|
user=membership,
|
||||||
|
)
|
||||||
|
with self.assertRaises(HTTPException) as missing:
|
||||||
|
_require_session_admin_reauthorization(without_session, "correct horse")
|
||||||
|
self.assertEqual(403, missing.exception.status_code)
|
||||||
|
|
||||||
|
principal = ApiPrincipal(
|
||||||
|
principal=principal_ref,
|
||||||
|
account=self.account,
|
||||||
|
user=membership,
|
||||||
|
auth_session=current,
|
||||||
|
)
|
||||||
|
with self.assertRaises(HTTPException) as incorrect:
|
||||||
|
_require_session_admin_reauthorization(principal, "incorrect")
|
||||||
|
self.assertEqual(403, incorrect.exception.status_code)
|
||||||
|
_require_session_admin_reauthorization(principal, "correct horse")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.base import AccessBase
|
||||||
|
from govoplan_access.backend.db.models import (
|
||||||
|
Account,
|
||||||
|
ApiKey,
|
||||||
|
AuthSession,
|
||||||
|
Group,
|
||||||
|
Role,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.tenant_erasure_provider import (
|
||||||
|
AccessTenantErasureProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_erasure_is_tenant_bounded_and_retains_global_account() -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
AccessBase.metadata.create_all(engine)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
with Session(engine) as session:
|
||||||
|
account = Account(
|
||||||
|
email="shared@example.test",
|
||||||
|
normalized_email="shared@example.test",
|
||||||
|
is_active=True,
|
||||||
|
auth_provider="local",
|
||||||
|
)
|
||||||
|
session.add(account)
|
||||||
|
session.flush()
|
||||||
|
first = User(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=account.id,
|
||||||
|
email="shared@example.test",
|
||||||
|
is_active=True,
|
||||||
|
is_tenant_admin=True,
|
||||||
|
auth_provider="local",
|
||||||
|
)
|
||||||
|
second = User(
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
account_id=account.id,
|
||||||
|
email="shared@example.test",
|
||||||
|
is_active=True,
|
||||||
|
is_tenant_admin=False,
|
||||||
|
auth_provider="local",
|
||||||
|
)
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
first,
|
||||||
|
second,
|
||||||
|
Group(tenant_id="tenant-1", slug="group", name="Group"),
|
||||||
|
Group(tenant_id="tenant-2", slug="group", name="Group"),
|
||||||
|
Role(tenant_id="tenant-1", slug="role", name="Role"),
|
||||||
|
Role(tenant_id="tenant-2", slug="role", name="Role"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
ApiKey(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=first.id,
|
||||||
|
name="key",
|
||||||
|
prefix="prefix",
|
||||||
|
key_hash="hash",
|
||||||
|
scopes=[],
|
||||||
|
),
|
||||||
|
AuthSession(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=first.id,
|
||||||
|
account_id=account.id,
|
||||||
|
token_hash="token-hash",
|
||||||
|
expires_at=now + timedelta(hours=1),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
provider = AccessTenantErasureProvider()
|
||||||
|
preview = provider.preview_tenant_erasure(session, "tenant-1")
|
||||||
|
|
||||||
|
assert preview.allowed
|
||||||
|
assert [step.step_id for step in preview.steps] == [
|
||||||
|
"revoke-tenant-credentials",
|
||||||
|
"erase-tenant-access",
|
||||||
|
]
|
||||||
|
assert "revoke-tenant-credentials" in preview.steps[1].depends_on
|
||||||
|
|
||||||
|
revoked = provider.execute_tenant_erasure_step(
|
||||||
|
session,
|
||||||
|
"tenant-1",
|
||||||
|
"revoke-tenant-credentials",
|
||||||
|
"operation:access:credentials",
|
||||||
|
)
|
||||||
|
erased = provider.execute_tenant_erasure_step(
|
||||||
|
session,
|
||||||
|
"tenant-1",
|
||||||
|
"erase-tenant-access",
|
||||||
|
"operation:access:tenant",
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
assert revoked.state == "completed"
|
||||||
|
assert erased.state == "completed"
|
||||||
|
assert provider.preview_tenant_erasure(session, "tenant-1").steps == ()
|
||||||
|
assert session.scalar(select(Account).where(Account.id == account.id)) is not None
|
||||||
|
assert session.scalar(select(User).where(User.tenant_id == "tenant-2")) is not None
|
||||||
|
assert session.scalar(select(Group).where(Group.tenant_id == "tenant-2")) is not None
|
||||||
|
assert session.scalar(select(Role).where(Role.tenant_id == "tenant-2")) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_erasure_replay_is_idempotent() -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
AccessBase.metadata.create_all(engine)
|
||||||
|
provider = AccessTenantErasureProvider()
|
||||||
|
with Session(engine) as session:
|
||||||
|
first = provider.execute_tenant_erasure_step(
|
||||||
|
session,
|
||||||
|
"tenant-1",
|
||||||
|
"erase-tenant-access",
|
||||||
|
"operation:access:tenant",
|
||||||
|
)
|
||||||
|
second = provider.reconcile_tenant_erasure_step(
|
||||||
|
session,
|
||||||
|
"tenant-1",
|
||||||
|
"erase-tenant-access",
|
||||||
|
"operation:access:tenant",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.metrics == {"deleted": 0}
|
||||||
|
assert second.metrics == {"deleted": 0}
|
||||||
+8
-5
@@ -1,8 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/access-webui",
|
"name": "@govoplan/access-webui",
|
||||||
"version": "0.1.11",
|
"version": "0.1.24",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
|
||||||
|
},
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -13,11 +16,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.11",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router": ">=8.3.0 <9"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const root = resolve(import.meta.dirname, "..");
|
||||||
|
const read = (path) => readFileSync(resolve(root, path), "utf8");
|
||||||
|
|
||||||
|
const adminPage = read("src/features/admin/AdminPage.tsx");
|
||||||
|
const users = read("src/features/admin/UsersPanel.tsx");
|
||||||
|
const groups = read("src/features/admin/GroupsPanel.tsx");
|
||||||
|
const roles = read("src/features/admin/RolesPanel.tsx");
|
||||||
|
const systemUsers = read("src/features/admin/SystemUsersPanel.tsx");
|
||||||
|
const systemRoles = read("src/features/admin/SystemRolesPanel.tsx");
|
||||||
|
const apiKeys = read("src/features/admin/ApiKeysPanel.tsx");
|
||||||
|
const serviceAccounts = read("src/features/admin/ServiceAccountsPanel.tsx");
|
||||||
|
const mappings = read("src/features/admin/ExternalFunctionRoleMappingsPanel.tsx");
|
||||||
|
const credentials = read("src/features/admin/CredentialEnvelopesPanel.tsx");
|
||||||
|
const files = read("src/features/admin/FileConnectorsPanel.tsx");
|
||||||
|
const mail = read("src/features/admin/MailProfilesPanel.tsx");
|
||||||
|
const moduleSource = read("src/module.ts");
|
||||||
|
const sessions = read("src/features/sessions/SessionSettingsPanel.tsx");
|
||||||
|
const surfaces = [users, groups, roles, systemUsers, systemRoles, apiKeys, mappings];
|
||||||
|
const allAdminSource = [adminPage, credentials, files, mail, serviceAccounts, ...surfaces].join("\n");
|
||||||
|
|
||||||
|
assert.match(adminPage, /TreeSubnav/);
|
||||||
|
assert.match(adminPage, /ActionBlockerHint/);
|
||||||
|
assert.match(adminPage, /ACCESS_WORKFLOW_DOCUMENTATION/);
|
||||||
|
|
||||||
|
for (const source of surfaces) {
|
||||||
|
assert.match(source, /AdminPageLayout/);
|
||||||
|
assert.match(source, /DataGrid/);
|
||||||
|
assert.match(source, /DocumentationHelpLink/);
|
||||||
|
assert.match(source, /disabledReason/);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const source of [users, groups, roles, systemUsers, systemRoles, apiKeys, mappings]) {
|
||||||
|
assert.match(source, /ConfirmDialog/);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.match(credentials, /CredentialEnvelopeManager/);
|
||||||
|
assert.match(credentials, /DocumentationHelpLink/);
|
||||||
|
assert.match(files, /usePlatformUiCapability<FilesConnectorsUiCapability>/);
|
||||||
|
assert.match(files, /ActionBlockerHint/);
|
||||||
|
assert.match(mail, /usePlatformUiCapability<MailProfilesUiCapability>/);
|
||||||
|
assert.match(mail, /ActionBlockerHint/);
|
||||||
|
assert.match(serviceAccounts, /Service accounts/);
|
||||||
|
assert.match(serviceAccounts, /createServiceAccountCredential/);
|
||||||
|
assert.match(serviceAccounts, /rotateServiceAccountCredential/);
|
||||||
|
assert.match(serviceAccounts, /revokeServiceAccountCredential/);
|
||||||
|
assert.match(serviceAccounts, /Secrets are shown once/);
|
||||||
|
assert.match(serviceAccounts, /<ConfirmDialog[\s\S]*Retire service account/);
|
||||||
|
|
||||||
|
for (const contextId of [
|
||||||
|
"access.api-keys.action.create",
|
||||||
|
"access.api-keys.action.revoke",
|
||||||
|
"access.api-keys.field.owner",
|
||||||
|
"access.api-keys.field.expiry",
|
||||||
|
"access.api-keys.field.scopes",
|
||||||
|
"access.api-keys.secret",
|
||||||
|
"access.api-keys.confirm-revoke"
|
||||||
|
]) {
|
||||||
|
assert.ok(apiKeys.includes(contextId), `API-key help context ${contextId} is missing`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const contextId of [
|
||||||
|
"access.service-accounts.action.create",
|
||||||
|
"access.service-accounts.action.activation",
|
||||||
|
"access.service-accounts.action.retire",
|
||||||
|
"access.service-accounts.field.scope-ceiling",
|
||||||
|
"access.service-accounts.action.rotate-credential",
|
||||||
|
"access.service-accounts.action.revoke-credential",
|
||||||
|
"access.service-accounts.field.credential-expiry",
|
||||||
|
"access.service-accounts.field.credential-scopes",
|
||||||
|
"access.service-accounts.secret",
|
||||||
|
"access.service-accounts.confirm-retire"
|
||||||
|
]) {
|
||||||
|
assert.ok(serviceAccounts.includes(contextId), `Service-account help context ${contextId} is missing`);
|
||||||
|
}
|
||||||
|
assert.match(moduleSource, /access\.admin\.tenant-service-accounts/);
|
||||||
|
assert.match(moduleSource, /access\.settings\.sessions/);
|
||||||
|
assert.match(moduleSource, /"settings\.sections": accessSettingsSections/);
|
||||||
|
assert.match(sessions, /PageActionBar/);
|
||||||
|
assert.match(sessions, /reloadAction/);
|
||||||
|
assert.match(sessions, /destructiveActions/);
|
||||||
|
assert.match(sessions, /DataGrid/);
|
||||||
|
assert.match(sessions, /ConfirmDialog/);
|
||||||
|
assert.doesNotMatch(sessions, /window\.(alert|confirm|prompt)\s*\(/);
|
||||||
|
assert.match(users, /fetchAdminUserSessions/);
|
||||||
|
assert.match(users, /revokeAdminUserSession/);
|
||||||
|
assert.match(users, /admin-user-sessions-v1/);
|
||||||
|
assert.match(users, /PasswordField/);
|
||||||
|
assert.match(users, /canRevokeSessions/);
|
||||||
|
assert.match(moduleSource, /translations,/);
|
||||||
|
assert.match(moduleSource, /version: "0\.1\.11"/);
|
||||||
|
|
||||||
|
assert.doesNotMatch(allAdminSource, /window\.(alert|confirm|prompt)\s*\(/);
|
||||||
|
assert.doesNotMatch(allAdminSource, /@govoplan\/(files|mail|organizations|idm)-webui\//);
|
||||||
|
|
||||||
|
console.log("Access interface pattern-language checks passed.");
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export type ActingContext = {
|
||||||
|
assignment_id: string;
|
||||||
|
acting_for_account_id: string;
|
||||||
|
function_id: string;
|
||||||
|
organization_unit_id: string;
|
||||||
|
valid_from?: string | null;
|
||||||
|
valid_until?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActingContextList = {
|
||||||
|
contexts: ActingContext[];
|
||||||
|
active_assignment_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchActingContexts(settings: ApiSettings): Promise<ActingContextList> {
|
||||||
|
return apiFetch<ActingContextList>(settings, "/api/v1/auth/acting-contexts", {
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function switchActingContext(
|
||||||
|
settings: ApiSettings,
|
||||||
|
assignmentId: string | null
|
||||||
|
): Promise<ActingContextList> {
|
||||||
|
return apiFetch<ActingContextList>(settings, "/api/v1/auth/switch-acting-context", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ assignment_id: assignmentId })
|
||||||
|
});
|
||||||
|
}
|
||||||
+110
-80
@@ -20,12 +20,6 @@ export type {
|
|||||||
TenantAdminItem
|
TenantAdminItem
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
|
|
||||||
export type TenantOwnerCandidate = {
|
|
||||||
account_id: string;
|
|
||||||
email: string;
|
|
||||||
display_name?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RoleSummary = {
|
export type RoleSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -178,24 +172,6 @@ export type LanguagePackage = {
|
|||||||
native_label?: string | null;
|
native_label?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TenantSettingsItem = {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
default_locale: string;
|
|
||||||
available_languages: LanguagePackage[];
|
|
||||||
system_enabled_language_codes: string[];
|
|
||||||
enabled_language_codes: string[];
|
|
||||||
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">;
|
|
||||||
settings: Pick<TenantSettingsItem, "settings">["settings"];
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type GovernanceAssignment = {
|
export type GovernanceAssignment = {
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
mode: "available" | "required";
|
mode: "available" | "required";
|
||||||
@@ -228,6 +204,43 @@ export type ApiKeyAdminItem = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ServiceAccountItem = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
scope_ceiling: string[];
|
||||||
|
is_active: boolean;
|
||||||
|
revision: number;
|
||||||
|
credential_count: number;
|
||||||
|
active_credential_count: number;
|
||||||
|
last_credential_used_at?: string | null;
|
||||||
|
retired_at?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceAccountCredentialItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
scopes: string[];
|
||||||
|
expires_at?: string | null;
|
||||||
|
last_used_at?: string | null;
|
||||||
|
revoked_at?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceAccountCredentialListResponse = {
|
||||||
|
service_account_revision: number;
|
||||||
|
items: ServiceAccountCredentialItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServiceAccountCredentialMutationResponse = {
|
||||||
|
service_account_revision: number;
|
||||||
|
credential: ServiceAccountCredentialItem;
|
||||||
|
};
|
||||||
|
|
||||||
export type ExternalFunctionRoleMappingItem = {
|
export type ExternalFunctionRoleMappingItem = {
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
@@ -251,14 +264,8 @@ export type GroupListDeltaResponse = { groups: GroupSummary[] } & DeltaResponseF
|
|||||||
export type RoleListDeltaResponse = { roles: RoleSummary[] } & DeltaResponseFields;
|
export type RoleListDeltaResponse = { roles: RoleSummary[] } & DeltaResponseFields;
|
||||||
export type SystemAccountListDeltaResponse = { accounts: SystemAccountItem[]; roles: RoleSummary[] } & DeltaResponseFields;
|
export type SystemAccountListDeltaResponse = { accounts: SystemAccountItem[]; roles: RoleSummary[] } & DeltaResponseFields;
|
||||||
export type ApiKeyListDeltaResponse = { api_keys: ApiKeyAdminItem[] } & DeltaResponseFields;
|
export type ApiKeyListDeltaResponse = { api_keys: ApiKeyAdminItem[] } & DeltaResponseFields;
|
||||||
export type TenantListDeltaResponse = { tenants: TenantAdminItem[] } & DeltaResponseFields;
|
|
||||||
export type GovernanceTemplateListDeltaResponse = { templates: GovernanceTemplateItem[] } & DeltaResponseFields;
|
export type GovernanceTemplateListDeltaResponse = { templates: GovernanceTemplateItem[] } & DeltaResponseFields;
|
||||||
export type ExternalFunctionRoleMappingListDeltaResponse = { mappings: ExternalFunctionRoleMappingItem[] } & DeltaResponseFields;
|
export type ExternalFunctionRoleMappingListDeltaResponse = { mappings: ExternalFunctionRoleMappingItem[] } & DeltaResponseFields;
|
||||||
export type TenantSettingsDeltaResponse = {
|
|
||||||
item?: TenantSettingsItem | null;
|
|
||||||
sections: TenantSettingsDeltaSections;
|
|
||||||
changed_sections: string[];
|
|
||||||
} & DeltaResponseFields;
|
|
||||||
|
|
||||||
function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
|
function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
|
||||||
return apiQuery(options);
|
return apiQuery(options);
|
||||||
@@ -266,56 +273,6 @@ function deltaSuffix(options: { since?: string | null; limit?: number } = {}): s
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function fetchTenantsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<TenantListDeltaResponse> {
|
|
||||||
const suffix = deltaSuffix(options);
|
|
||||||
return apiFetch(settings, `/api/v1/admin/tenants/delta${suffix}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
effective_governance: Record<string, boolean>;
|
|
||||||
is_active: boolean;
|
|
||||||
}>): Promise<TenantAdminItem> {
|
|
||||||
return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchTenantSettings(settings: ApiSettings): Promise<TenantSettingsItem> {
|
|
||||||
return apiFetch(settings, "/api/v1/admin/tenant/settings");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchTenantSettingsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<TenantSettingsDeltaResponse> {
|
|
||||||
const suffix = deltaSuffix(options);
|
|
||||||
return apiFetch(settings, `/api/v1/admin/tenant/settings/delta${suffix}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string; enabled_language_codes?: string[] | null }): Promise<TenantSettingsItem> {
|
|
||||||
return apiFetch(settings, "/api/v1/admin/tenant/settings", { method: "PATCH", body: JSON.stringify(payload) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> {
|
export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> {
|
||||||
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users");
|
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users");
|
||||||
return response.users;
|
return response.users;
|
||||||
@@ -527,6 +484,79 @@ export function revokeApiKey(settings: ApiSettings, keyId: string): Promise<ApiK
|
|||||||
return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" });
|
return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchServiceAccounts(settings: ApiSettings): Promise<ServiceAccountItem[]> {
|
||||||
|
const response = await apiFetch<{ items: ServiceAccountItem[] }>(settings, "/api/v1/admin/service-accounts");
|
||||||
|
return response.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServiceAccount(settings: ApiSettings, payload: {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
scope_ceiling: string[];
|
||||||
|
}): Promise<ServiceAccountItem> {
|
||||||
|
return apiFetch(settings, "/api/v1/admin/service-accounts", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateServiceAccount(settings: ApiSettings, serviceAccountId: string, payload: {
|
||||||
|
expected_revision: number;
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
scope_ceiling?: string[];
|
||||||
|
is_active?: boolean;
|
||||||
|
}): Promise<ServiceAccountItem> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retireServiceAccount(settings: ApiSettings, serviceAccountId: string, expectedRevision: number): Promise<ServiceAccountItem> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/retire`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ expected_revision: expectedRevision })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchServiceAccountCredentials(settings: ApiSettings, serviceAccountId: string, includeRevoked = true): Promise<ServiceAccountCredentialListResponse> {
|
||||||
|
return apiFetch(settings, apiPath(`/api/v1/admin/service-accounts/${serviceAccountId}/credentials`, {
|
||||||
|
include_revoked: includeRevoked
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServiceAccountCredential(settings: ApiSettings, serviceAccountId: string, payload: {
|
||||||
|
expected_revision: number;
|
||||||
|
name: string;
|
||||||
|
scopes: string[];
|
||||||
|
expires_at?: string | null;
|
||||||
|
}): Promise<ServiceAccountCredentialMutationResponse & { secret: string }> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/credentials`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotateServiceAccountCredential(settings: ApiSettings, serviceAccountId: string, credentialId: string, payload: {
|
||||||
|
expected_revision: number;
|
||||||
|
name?: string | null;
|
||||||
|
scopes?: string[] | null;
|
||||||
|
expires_at?: string | null;
|
||||||
|
}): Promise<ServiceAccountCredentialMutationResponse & { secret: string }> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/credentials/${credentialId}/rotate`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeServiceAccountCredential(settings: ApiSettings, serviceAccountId: string, credentialId: string, expectedRevision: number): Promise<ServiceAccountCredentialMutationResponse> {
|
||||||
|
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/credentials/${credentialId}/revoke`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ expected_revision: expectedRevision })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function createSystemAccount(settings: ApiSettings, payload: {
|
export function createSystemAccount(settings: ApiSettings, payload: {
|
||||||
email: string;
|
email: string;
|
||||||
display_name?: string | null;
|
display_name?: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export type AccountSession = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
current: boolean;
|
||||||
|
status: "active" | "expired" | "revoked";
|
||||||
|
created_at: string;
|
||||||
|
last_seen_at?: string | null;
|
||||||
|
expires_at: string;
|
||||||
|
revoked_at?: string | null;
|
||||||
|
client?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountSessionList = {
|
||||||
|
sessions: AccountSession[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchAccountSessions(
|
||||||
|
settings: ApiSettings
|
||||||
|
): Promise<AccountSessionList> {
|
||||||
|
return apiFetch<AccountSessionList>(settings, "/api/v1/auth/sessions", {
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeAccountSession(
|
||||||
|
settings: ApiSettings,
|
||||||
|
sessionId: string
|
||||||
|
): Promise<{ session: AccountSession; revoked: boolean }> {
|
||||||
|
return apiFetch(settings, `/api/v1/auth/sessions/${encodeURIComponent(sessionId)}/revoke`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeOtherAccountSessions(
|
||||||
|
settings: ApiSettings
|
||||||
|
): Promise<{ revoked_count: number }> {
|
||||||
|
return apiFetch(settings, "/api/v1/auth/sessions/revoke-others", {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminUserSessions(
|
||||||
|
settings: ApiSettings,
|
||||||
|
userId: string
|
||||||
|
): Promise<AccountSessionList> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/admin/users/${encodeURIComponent(userId)}/sessions`,
|
||||||
|
{ cache: "no-store" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeAdminUserSession(
|
||||||
|
settings: ApiSettings,
|
||||||
|
userId: string,
|
||||||
|
sessionId: string,
|
||||||
|
currentPassword: string
|
||||||
|
): Promise<{ session: AccountSession; revoked: boolean }> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/admin/users/${encodeURIComponent(userId)}/sessions/${encodeURIComponent(sessionId)}/revoke`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ current_password: currentPassword })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
fetchMe,
|
||||||
|
type ActingContextSelectorProps
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
fetchActingContexts,
|
||||||
|
switchActingContext,
|
||||||
|
type ActingContext
|
||||||
|
} from "../../api/actingContext";
|
||||||
|
|
||||||
|
export default function ActingContextSelector({
|
||||||
|
settings,
|
||||||
|
auth,
|
||||||
|
onAuthChange
|
||||||
|
}: ActingContextSelectorProps) {
|
||||||
|
const [contexts, setContexts] = useState<ActingContext[]>([]);
|
||||||
|
const [activeId, setActiveId] = useState<string>("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
void fetchActingContexts(settings)
|
||||||
|
.then((response) => {
|
||||||
|
if (!active) return;
|
||||||
|
setContexts(response.contexts);
|
||||||
|
setActiveId(response.active_assignment_id ?? "");
|
||||||
|
})
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
if (active) setError(reason instanceof Error ? reason.message : "Acting context could not be loaded.");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [settings.apiBaseUrl, settings.accessToken, settings.apiKey, auth.active_tenant?.id, auth.tenant.id]);
|
||||||
|
|
||||||
|
if (!contexts.length && !activeId) return null;
|
||||||
|
|
||||||
|
async function selectContext(assignmentId: string) {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await switchActingContext(settings, assignmentId || null);
|
||||||
|
setContexts(response.contexts);
|
||||||
|
setActiveId(response.active_assignment_id ?? "");
|
||||||
|
onAuthChange(await fetchMe(settings));
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Acting context could not be changed.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="acting-context-selector" title={error || "Select whose authority is represented by this session."}>
|
||||||
|
<span>Acting as</span>
|
||||||
|
<select
|
||||||
|
aria-label="Acting context"
|
||||||
|
value={activeId}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => void selectContext(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Own account</option>
|
||||||
|
{contexts.map((context) => (
|
||||||
|
<option key={context.assignment_id} value={context.assignment_id}>
|
||||||
|
{context.function_id} / {context.organization_unit_id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router";
|
||||||
import type {
|
import type {
|
||||||
AdminSectionContribution,
|
AdminSectionContribution,
|
||||||
AdminSectionsUiCapability,
|
AdminSectionsUiCapability,
|
||||||
@@ -11,24 +11,50 @@ import type {
|
|||||||
OrganizationFunctionPickerUiCapability
|
OrganizationFunctionPickerUiCapability
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { fetchShellAuth } from "@govoplan/core-webui";
|
import { fetchShellAuth } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { ActionBlockerHint } from "@govoplan/core-webui";
|
||||||
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
|
import { PageLayout, WorkspaceLayout } from "@govoplan/core-webui";
|
||||||
|
import { PageScrollViewport } from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
TreeSubnav,
|
||||||
|
type TreeSubnavNode
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
|
import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
|
||||||
import SystemUsersPanel from "./SystemUsersPanel";
|
import SystemUsersPanel from "./SystemUsersPanel";
|
||||||
import TenantSettingsPanel from "./TenantSettingsPanel";
|
|
||||||
import SystemRolesPanel from "./SystemRolesPanel";
|
import SystemRolesPanel from "./SystemRolesPanel";
|
||||||
import TenantsPanel from "./TenantsPanel";
|
|
||||||
import UsersPanel from "./UsersPanel";
|
import UsersPanel from "./UsersPanel";
|
||||||
import GroupsPanel from "./GroupsPanel";
|
import GroupsPanel from "./GroupsPanel";
|
||||||
import RolesPanel from "./RolesPanel";
|
import RolesPanel from "./RolesPanel";
|
||||||
import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPanel";
|
import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPanel";
|
||||||
import ApiKeysPanel from "./ApiKeysPanel";
|
import ApiKeysPanel from "./ApiKeysPanel";
|
||||||
|
import ServiceAccountsPanel from "./ServiceAccountsPanel";
|
||||||
import FileConnectorsPanel from "./FileConnectorsPanel";
|
import FileConnectorsPanel from "./FileConnectorsPanel";
|
||||||
import MailProfilesPanel from "./MailProfilesPanel";
|
import MailProfilesPanel from "./MailProfilesPanel";
|
||||||
import { usePlatformUiCapabilities, usePlatformUiCapability } from "@govoplan/core-webui";
|
import CredentialEnvelopesPanel from "./CredentialEnvelopesPanel";
|
||||||
|
import {
|
||||||
|
ACCESS_INTERFACE_I18N,
|
||||||
|
ACCESS_WORKFLOW_DOCUMENTATION
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
import {
|
||||||
|
isViewSurfaceVisible,
|
||||||
|
useEffectiveView,
|
||||||
|
usePlatformUiCapabilities,
|
||||||
|
usePlatformUiCapability,
|
||||||
|
useViewSurfaces
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
|
||||||
type AdminSection = string;
|
type AdminSection = string;
|
||||||
type OrderedAdminNavItem = { id: AdminSection; label: string; order: number };
|
type OrderedAdminNavItem = {
|
||||||
|
id: AdminSection;
|
||||||
|
label: string;
|
||||||
|
order: number;
|
||||||
|
moduleId?: string;
|
||||||
|
kind?: "management" | "settings";
|
||||||
|
};
|
||||||
|
type AdminNavGroup = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
items: OrderedAdminNavItem[];
|
||||||
|
};
|
||||||
|
|
||||||
const handledAdminSectionIds = new Set<string>([
|
const handledAdminSectionIds = new Set<string>([
|
||||||
"overview",
|
"overview",
|
||||||
@@ -36,27 +62,70 @@ const handledAdminSectionIds = new Set<string>([
|
|||||||
"system-configuration-changes",
|
"system-configuration-changes",
|
||||||
"system-configuration-packages",
|
"system-configuration-packages",
|
||||||
"system-modules",
|
"system-modules",
|
||||||
"system-tenants",
|
|
||||||
"system-roles",
|
"system-roles",
|
||||||
"system-role-templates",
|
"system-role-templates",
|
||||||
"system-groups",
|
"system-groups",
|
||||||
"system-users",
|
"system-users",
|
||||||
"system-file-connectors",
|
"system-file-connectors",
|
||||||
"system-mail-servers",
|
"system-mail-servers",
|
||||||
"tenant-settings",
|
"system-credentials",
|
||||||
"tenant-roles",
|
"tenant-roles",
|
||||||
"tenant-function-role-mappings",
|
"tenant-function-role-mappings",
|
||||||
"tenant-groups",
|
"tenant-groups",
|
||||||
"tenant-users",
|
"tenant-users",
|
||||||
"tenant-file-connectors",
|
"tenant-file-connectors",
|
||||||
"tenant-mail-servers",
|
"tenant-mail-servers",
|
||||||
|
"tenant-credentials",
|
||||||
"tenant-api-keys",
|
"tenant-api-keys",
|
||||||
|
"tenant-service-accounts",
|
||||||
"tenant-group-file-connectors",
|
"tenant-group-file-connectors",
|
||||||
"tenant-group-mail-servers",
|
"tenant-group-mail-servers",
|
||||||
|
"tenant-group-credentials",
|
||||||
"tenant-user-file-connectors",
|
"tenant-user-file-connectors",
|
||||||
"tenant-user-mail-servers"
|
"tenant-user-mail-servers",
|
||||||
|
"tenant-user-credentials"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const builtInAdminSurfaceIds: Record<string, string> = {
|
||||||
|
"system-roles": "access.admin.system-roles",
|
||||||
|
"system-users": "access.admin.system-users",
|
||||||
|
"system-credentials": "access.admin.system-credentials",
|
||||||
|
"tenant-roles": "access.admin.tenant-roles",
|
||||||
|
"tenant-function-role-mappings": "access.admin.tenant-function-mappings",
|
||||||
|
"tenant-groups": "access.admin.tenant-groups",
|
||||||
|
"tenant-users": "access.admin.tenant-users",
|
||||||
|
"tenant-credentials": "access.admin.tenant-credentials",
|
||||||
|
"tenant-api-keys": "access.admin.tenant-api-keys",
|
||||||
|
"tenant-service-accounts": "access.admin.tenant-service-accounts",
|
||||||
|
"tenant-group-credentials": "access.admin.group-credentials",
|
||||||
|
"tenant-user-credentials": "access.admin.user-credentials",
|
||||||
|
"system-mail-servers": "mail.admin.system-servers",
|
||||||
|
"tenant-mail-servers": "mail.admin.tenant-servers",
|
||||||
|
"tenant-group-mail-servers": "mail.admin.group-servers",
|
||||||
|
"tenant-user-mail-servers": "mail.admin.user-servers",
|
||||||
|
"tenant-group-file-connectors": "files.admin.group-connectors",
|
||||||
|
"tenant-user-file-connectors": "files.admin.user-connectors"
|
||||||
|
};
|
||||||
|
|
||||||
|
const builtInAdminSectionMetadata: Record<
|
||||||
|
string,
|
||||||
|
Pick<OrderedAdminNavItem, "moduleId" | "kind">
|
||||||
|
> = {
|
||||||
|
"system-settings": { moduleId: "admin", kind: "settings" },
|
||||||
|
"system-file-connectors": { moduleId: "files", kind: "settings" },
|
||||||
|
"system-mail-servers": { moduleId: "mail", kind: "settings" },
|
||||||
|
"system-credentials": { moduleId: "access", kind: "settings" },
|
||||||
|
"tenant-file-connectors": { moduleId: "files", kind: "settings" },
|
||||||
|
"tenant-mail-servers": { moduleId: "mail", kind: "settings" },
|
||||||
|
"tenant-credentials": { moduleId: "access", kind: "settings" },
|
||||||
|
"tenant-group-file-connectors": { moduleId: "files", kind: "settings" },
|
||||||
|
"tenant-group-mail-servers": { moduleId: "mail", kind: "settings" },
|
||||||
|
"tenant-group-credentials": { moduleId: "access", kind: "settings" },
|
||||||
|
"tenant-user-file-connectors": { moduleId: "files", kind: "settings" },
|
||||||
|
"tenant-user-mail-servers": { moduleId: "mail", kind: "settings" },
|
||||||
|
"tenant-user-credentials": { moduleId: "access", kind: "settings" }
|
||||||
|
};
|
||||||
|
|
||||||
export default function AdminPage({
|
export default function AdminPage({
|
||||||
settings,
|
settings,
|
||||||
auth,
|
auth,
|
||||||
@@ -70,14 +139,23 @@ export default function AdminPage({
|
|||||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||||
const organizationFunctionPicker = usePlatformUiCapability<OrganizationFunctionPickerUiCapability>("organizations.functionPicker");
|
const organizationFunctionPicker = usePlatformUiCapability<OrganizationFunctionPickerUiCapability>("organizations.functionPicker");
|
||||||
const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
|
const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
|
||||||
|
const effectiveView = useEffectiveView();
|
||||||
|
const viewSurfaces = useViewSurfaces();
|
||||||
const mailProfilesAvailable = Boolean(mailProfilesUi);
|
const mailProfilesAvailable = Boolean(mailProfilesUi);
|
||||||
const fileConnectorsAvailable = Boolean(fileConnectorsUi);
|
const fileConnectorsAvailable = Boolean(fileConnectorsUi);
|
||||||
const contributedSections = useMemo(
|
const contributedSections = useMemo(
|
||||||
() =>
|
() =>
|
||||||
adminSectionCapabilities
|
adminSectionCapabilities
|
||||||
.flatMap((capability) => capability.sections)
|
.flatMap((capability) => capability.sections)
|
||||||
|
.filter((section) =>
|
||||||
|
isViewSurfaceVisible(
|
||||||
|
effectiveView,
|
||||||
|
section.surfaceId,
|
||||||
|
viewSurfaces
|
||||||
|
)
|
||||||
|
)
|
||||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
|
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
|
||||||
[adminSectionCapabilities]
|
[adminSectionCapabilities, effectiveView, viewSurfaces]
|
||||||
);
|
);
|
||||||
const contributionById = useMemo(() => {
|
const contributionById = useMemo(() => {
|
||||||
const mapped = new Map<string, AdminSectionContribution>();
|
const mapped = new Map<string, AdminSectionContribution>();
|
||||||
@@ -95,7 +173,9 @@ export default function AdminPage({
|
|||||||
if (hasScope(auth, "system:settings:read")) {
|
if (hasScope(auth, "system:settings:read")) {
|
||||||
if (mailProfilesAvailable) sections.add("system-mail-servers");
|
if (mailProfilesAvailable) sections.add("system-mail-servers");
|
||||||
}
|
}
|
||||||
if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants");
|
if (hasAnyScope(auth, ["system:settings:read", "access:system_credential:read"])) {
|
||||||
|
sections.add("system-credentials");
|
||||||
|
}
|
||||||
if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users");
|
if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users");
|
||||||
if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles");
|
if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles");
|
||||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-users");
|
if (hasScope(auth, "admin:users:read")) sections.add("tenant-users");
|
||||||
@@ -103,6 +183,7 @@ export default function AdminPage({
|
|||||||
if (hasScope(auth, "admin:roles:read")) sections.add("tenant-roles");
|
if (hasScope(auth, "admin:roles:read")) sections.add("tenant-roles");
|
||||||
if (organizationFunctionPicker && hasAnyScope(auth, ["admin:roles:read", "access:function:read", "access:role:read"])) sections.add("tenant-function-role-mappings");
|
if (organizationFunctionPicker && hasAnyScope(auth, ["admin:roles:read", "access:function:read", "access:role:read"])) sections.add("tenant-function-role-mappings");
|
||||||
if (hasScope(auth, "admin:api_keys:read")) sections.add("tenant-api-keys");
|
if (hasScope(auth, "admin:api_keys:read")) sections.add("tenant-api-keys");
|
||||||
|
if (hasScope(auth, "access:service_account:read")) sections.add("tenant-service-accounts");
|
||||||
if (mailProfilesAvailable && hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) {
|
if (mailProfilesAvailable && hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) {
|
||||||
sections.add("tenant-mail-servers");
|
sections.add("tenant-mail-servers");
|
||||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-mail-servers");
|
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-mail-servers");
|
||||||
@@ -112,9 +193,21 @@ export default function AdminPage({
|
|||||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-file-connectors");
|
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-file-connectors");
|
||||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors");
|
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors");
|
||||||
}
|
}
|
||||||
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
|
if (hasAnyScope(auth, ["admin:settings:read", "access:credential:read"])) {
|
||||||
return sections;
|
sections.add("tenant-credentials");
|
||||||
}, [auth, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker]);
|
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-credentials");
|
||||||
|
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-credentials");
|
||||||
|
}
|
||||||
|
return new Set(
|
||||||
|
[...sections].filter((sectionId) =>
|
||||||
|
isViewSurfaceVisible(
|
||||||
|
effectiveView,
|
||||||
|
builtInAdminSurfaceIds[sectionId],
|
||||||
|
viewSurfaces
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [auth, contributedSections, effectiveView, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker, viewSurfaces]);
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const requestedSection = searchParams.get("section") as AdminSection | null;
|
const requestedSection = searchParams.get("section") as AdminSection | null;
|
||||||
const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview");
|
const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview");
|
||||||
@@ -137,19 +230,34 @@ export default function AdminPage({
|
|||||||
|
|
||||||
if (!hasAnyScope(auth, adminReadScopes)) {
|
if (!hasAnyScope(auth, adminReadScopes)) {
|
||||||
return (
|
return (
|
||||||
|
<PageScrollViewport>
|
||||||
<div className="content-pad">
|
<div className="content-pad">
|
||||||
<Card title="i18n:govoplan-access.administration_unavailable.b86d4cb5">
|
<ActionBlockerHint
|
||||||
<p>i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69</p>
|
tone="warning"
|
||||||
</Card>
|
reason={{
|
||||||
|
summary: "i18n:govoplan-access.administration_unavailable.b86d4cb5",
|
||||||
|
details: "i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69",
|
||||||
|
requiredAction: ACCESS_INTERFACE_I18N.requestAdministrationAccess,
|
||||||
|
actor: ACCESS_INTERFACE_I18N.accessAdministrator,
|
||||||
|
target: ACCESS_INTERFACE_I18N.accessAdministration
|
||||||
|
}}
|
||||||
|
labels={{
|
||||||
|
requiredAction: ACCESS_INTERFACE_I18N.requiredAction,
|
||||||
|
actor: ACCESS_INTERFACE_I18N.actor,
|
||||||
|
target: ACCESS_INTERFACE_I18N.destinationLabel
|
||||||
|
}}
|
||||||
|
documentation={ACCESS_WORKFLOW_DOCUMENTATION}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</PageScrollViewport>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminSubnav: ModuleSubnavGroup<AdminSection>[] = [
|
const adminNavGroups: AdminNavGroup[] = [
|
||||||
{
|
{
|
||||||
title: "ADMINISTRATION",
|
id: "administration",
|
||||||
items: asSubnavItems(
|
title: "i18n:govoplan-access.admin.4e7afebc",
|
||||||
sortNavItems([
|
items: sortNavItems([
|
||||||
...contributedNavItems(contributedSections, available, "ROOT"),
|
...contributedNavItems(contributedSections, available, "ROOT"),
|
||||||
visibleNavItem(available, "system-modules", "i18n:govoplan-access.modules.04e9462c", 10),
|
visibleNavItem(available, "system-modules", "i18n:govoplan-access.modules.04e9462c", 10),
|
||||||
visibleNavItem(available, "system-configuration-packages", "i18n:govoplan-access.packages.0a999012", 20),
|
visibleNavItem(available, "system-configuration-packages", "i18n:govoplan-access.packages.0a999012", 20),
|
||||||
@@ -157,75 +265,95 @@ export default function AdminPage({
|
|||||||
visibleNavItem(available, "system-configuration-changes", "i18n:govoplan-access.changes.8aa57de6", 40),
|
visibleNavItem(available, "system-configuration-changes", "i18n:govoplan-access.changes.8aa57de6", 40),
|
||||||
...contributedNavItems(contributedSections, available, "ADMINISTRATION", handledAdminSectionIds)
|
...contributedNavItems(contributedSections, available, "ADMINISTRATION", handledAdminSectionIds)
|
||||||
])
|
])
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "GLOBAL",
|
id: "global",
|
||||||
items: asSubnavItems(
|
title: "i18n:govoplan-access.global",
|
||||||
sortNavItems([
|
items: sortNavItems([
|
||||||
visibleNavItem(available, "system-tenants", "i18n:govoplan-access.tenants.1f7ae776", 10),
|
|
||||||
visibleNavItem(available, "system-roles", "i18n:govoplan-access.system_roles.a9461aa6", 20),
|
visibleNavItem(available, "system-roles", "i18n:govoplan-access.system_roles.a9461aa6", 20),
|
||||||
visibleNavItem(available, "system-role-templates", "i18n:govoplan-access.tenant_role_templates", 30),
|
visibleNavItem(available, "system-role-templates", "i18n:govoplan-access.tenant_role_templates", 30),
|
||||||
visibleNavItem(available, "system-groups", "i18n:govoplan-access.group_templates", 40),
|
visibleNavItem(available, "system-groups", "i18n:govoplan-access.group_templates", 40),
|
||||||
visibleNavItem(available, "system-users", "i18n:govoplan-access.users.57f2b181", 50),
|
visibleNavItem(available, "system-users", "i18n:govoplan-access.users.57f2b181", 50),
|
||||||
visibleNavItem(available, "system-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 60),
|
visibleNavItem(available, "system-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 60),
|
||||||
visibleNavItem(available, "system-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 70),
|
visibleNavItem(available, "system-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 70),
|
||||||
|
visibleNavItem(available, "system-credentials", "i18n:govoplan-core.credentials.dd097a22", 80),
|
||||||
...contributedNavItems(contributedSections, available, "GLOBAL", handledAdminSectionIds),
|
...contributedNavItems(contributedSections, available, "GLOBAL", handledAdminSectionIds),
|
||||||
...contributedNavItems(contributedSections, available, "SYSTEM", handledAdminSectionIds)
|
...contributedNavItems(contributedSections, available, "SYSTEM", handledAdminSectionIds)
|
||||||
])
|
])
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "TENANT",
|
id: "tenant",
|
||||||
items: asSubnavItems(
|
title: "i18n:govoplan-access.tenant.3ca93c78",
|
||||||
sortNavItems([
|
items: sortNavItems([
|
||||||
visibleNavItem(available, "tenant-roles", "i18n:govoplan-access.roles.47dcc27d", 10),
|
visibleNavItem(available, "tenant-roles", "i18n:govoplan-access.roles.47dcc27d", 10),
|
||||||
visibleNavItem(available, "tenant-function-role-mappings", "i18n:govoplan-access.function_role_mappings.2b64e9c3", 20),
|
visibleNavItem(available, "tenant-function-role-mappings", "i18n:govoplan-access.function_role_mappings.2b64e9c3", 20),
|
||||||
visibleNavItem(available, "tenant-groups", "i18n:govoplan-access.groups.ae9629f4", 30),
|
visibleNavItem(available, "tenant-groups", "i18n:govoplan-access.groups.ae9629f4", 30),
|
||||||
visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 40),
|
visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 40),
|
||||||
visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 50),
|
visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 50),
|
||||||
visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60),
|
visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60),
|
||||||
visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 70),
|
visibleNavItem(available, "tenant-credentials", "i18n:govoplan-core.credentials.dd097a22", 70),
|
||||||
visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 90),
|
visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 80),
|
||||||
|
visibleNavItem(available, "tenant-service-accounts", "Service accounts", 90),
|
||||||
...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds)
|
...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds)
|
||||||
])
|
])
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "GROUP",
|
id: "group",
|
||||||
items: asSubnavItems(
|
title: "i18n:govoplan-access.group.171a0606",
|
||||||
sortNavItems([
|
items: sortNavItems([
|
||||||
visibleNavItem(available, "tenant-group-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
|
visibleNavItem(available, "tenant-group-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
|
||||||
visibleNavItem(available, "tenant-group-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
|
visibleNavItem(available, "tenant-group-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
|
||||||
|
visibleNavItem(available, "tenant-group-credentials", "i18n:govoplan-core.credentials.dd097a22", 30),
|
||||||
...contributedNavItems(contributedSections, available, "GROUP", handledAdminSectionIds)
|
...contributedNavItems(contributedSections, available, "GROUP", handledAdminSectionIds)
|
||||||
])
|
])
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "USER",
|
id: "user",
|
||||||
items: asSubnavItems(
|
title: "i18n:govoplan-access.user.9f8a2389",
|
||||||
sortNavItems([
|
items: sortNavItems([
|
||||||
visibleNavItem(available, "tenant-user-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
|
visibleNavItem(available, "tenant-user-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
|
||||||
visibleNavItem(available, "tenant-user-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
|
visibleNavItem(available, "tenant-user-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
|
||||||
|
visibleNavItem(available, "tenant-user-credentials", "i18n:govoplan-core.credentials.dd097a22", 30),
|
||||||
...contributedNavItems(contributedSections, available, "USER", handledAdminSectionIds)
|
...contributedNavItems(contributedSections, available, "USER", handledAdminSectionIds)
|
||||||
])
|
])
|
||||||
)
|
|
||||||
}
|
}
|
||||||
].filter((group) => group.items.length > 0);
|
].filter((group) => group.items.length > 0);
|
||||||
|
const adminTree = adminNavigationTree(adminNavGroups);
|
||||||
const contributedSection = contributionById.get(active);
|
const contributedSection = contributionById.get(active);
|
||||||
const contributionContext = { settings, auth, onAuthChange, refreshAuth, availableSections: available, selectSection };
|
const contributionContext = { settings, auth, onAuthChange, refreshAuth, availableSections: available, selectSection };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workspace module-workspace">
|
<WorkspaceLayout
|
||||||
<ModuleSubnav active={active} groups={adminSubnav} onSelect={selectSection} />
|
className="module-workspace"
|
||||||
<section className="workspace-content">
|
primary={(
|
||||||
<div className="content-pad workspace-data-page">
|
<TreeSubnav
|
||||||
|
active={active}
|
||||||
|
nodes={adminTree}
|
||||||
|
onSelect={selectSection}
|
||||||
|
ariaLabel="i18n:govoplan-access.admin.4e7afebc"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
primaryLabel="i18n:govoplan-access.admin.4e7afebc"
|
||||||
|
contentLabel="i18n:govoplan-access.admin.4e7afebc"
|
||||||
|
documentationType="admin"
|
||||||
|
>
|
||||||
|
<PageLayout
|
||||||
|
archetype="workspace"
|
||||||
|
title="i18n:govoplan-access.admin.4e7afebc"
|
||||||
|
mode="workspace"
|
||||||
|
showHeader={false}
|
||||||
|
documentationType="admin"
|
||||||
|
>
|
||||||
{contributedSection && contributedSection.render(contributionContext)}
|
{contributedSection && contributedSection.render(contributionContext)}
|
||||||
{!contributedSection && active === "system-mail-servers" && (
|
{!contributedSection && active === "system-mail-servers" && (
|
||||||
<MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />
|
<MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />
|
||||||
)}
|
)}
|
||||||
{!contributedSection && active === "system-tenants" && (
|
{!contributedSection && active === "system-credentials" && (
|
||||||
<TenantsPanel settings={settings} auth={auth} canCreate={hasScope(auth, "system:tenants:create")} canUpdate={hasScope(auth, "system:tenants:update")} canSuspend={hasScope(auth, "system:tenants:suspend")} onAuthRefresh={refreshAuth} />
|
<CredentialEnvelopesPanel
|
||||||
|
settings={settings}
|
||||||
|
scopeType="system"
|
||||||
|
canWrite={hasAnyScope(auth, ["system:settings:write", "access:system_credential:write"])}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{!contributedSection && active === "system-users" && (
|
{!contributedSection && active === "system-users" && (
|
||||||
<SystemUsersPanel
|
<SystemUsersPanel
|
||||||
@@ -239,20 +367,22 @@ export default function AdminPage({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!contributedSection && active === "system-roles" && <SystemRolesPanel settings={settings} canWrite={hasScope(auth, "system:roles:write")} onAuthRefresh={refreshAuth} />}
|
{!contributedSection && active === "system-roles" && <SystemRolesPanel settings={settings} canWrite={hasScope(auth, "system:roles:write")} onAuthRefresh={refreshAuth} />}
|
||||||
{!contributedSection && active === "tenant-users" && <UsersPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:users:create")} canUpdate={hasScope(auth, "admin:users:update")} canSuspend={hasScope(auth, "admin:users:suspend")} canManageGroups={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
|
{!contributedSection && active === "tenant-users" && <UsersPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:users:create")} canUpdate={hasScope(auth, "admin:users:update")} canSuspend={hasScope(auth, "admin:users:suspend")} canManageGroups={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} canRevokeSessions={hasAnyScope(auth, ["admin:users:update", "access:membership:update"])} onAuthRefresh={refreshAuth} />}
|
||||||
{!contributedSection && active === "tenant-groups" && <GroupsPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:groups:write")} canManageMembers={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
|
{!contributedSection && active === "tenant-groups" && <GroupsPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:groups:write")} canManageMembers={hasScope(auth, "admin:groups:manage_members")} canAssignRoles={hasScope(auth, "admin:roles:assign")} onAuthRefresh={refreshAuth} />}
|
||||||
{!contributedSection && active === "tenant-roles" && <RolesPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:roles:write")} onAuthRefresh={refreshAuth} />}
|
{!contributedSection && active === "tenant-roles" && <RolesPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:roles:write")} onAuthRefresh={refreshAuth} />}
|
||||||
{!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && <ExternalFunctionRoleMappingsPanel settings={settings} auth={auth} functionPicker={organizationFunctionPicker} canWrite={hasAnyScope(auth, ["admin:roles:write", "access:function:write", "access:role:assign"])} onAuthRefresh={refreshAuth} />}
|
{!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && <ExternalFunctionRoleMappingsPanel settings={settings} auth={auth} functionPicker={organizationFunctionPicker} canWrite={hasAnyScope(auth, ["admin:roles:write", "access:function:write", "access:role:assign"])} onAuthRefresh={refreshAuth} />}
|
||||||
{!contributedSection && active === "tenant-api-keys" && <ApiKeysPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:api_keys:create")} canRevoke={hasScope(auth, "admin:api_keys:revoke")} />}
|
{!contributedSection && active === "tenant-api-keys" && <ApiKeysPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:api_keys:create")} canRevoke={hasScope(auth, "admin:api_keys:revoke")} />}
|
||||||
|
{!contributedSection && active === "tenant-service-accounts" && <ServiceAccountsPanel settings={settings} auth={auth} canWrite={hasScope(auth, "access:service_account:write")} />}
|
||||||
{!contributedSection && active === "tenant-mail-servers" && <MailProfilesPanel settings={settings} scopeType="tenant" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasScope(auth, "admin:policies:write")} />}
|
{!contributedSection && active === "tenant-mail-servers" && <MailProfilesPanel settings={settings} scopeType="tenant" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasScope(auth, "admin:policies:write")} />}
|
||||||
|
{!contributedSection && active === "tenant-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="tenant" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
|
||||||
{!contributedSection && active === "tenant-user-mail-servers" && <MailProfilesPanel settings={settings} scopeType="user" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
|
{!contributedSection && active === "tenant-user-mail-servers" && <MailProfilesPanel settings={settings} scopeType="user" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
|
||||||
{!contributedSection && active === "tenant-group-mail-servers" && <MailProfilesPanel settings={settings} scopeType="group" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
|
{!contributedSection && active === "tenant-group-mail-servers" && <MailProfilesPanel settings={settings} scopeType="group" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
|
||||||
|
{!contributedSection && active === "tenant-user-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
|
||||||
|
{!contributedSection && active === "tenant-group-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
|
||||||
{!contributedSection && active === "tenant-user-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
|
{!contributedSection && active === "tenant-user-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
|
||||||
{!contributedSection && active === "tenant-group-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
|
{!contributedSection && active === "tenant-group-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
|
||||||
{!contributedSection && active === "tenant-settings" && <TenantSettingsPanel settings={settings} canWrite={hasScope(auth, "admin:settings:write")} onAuthRefresh={refreshAuth} />}
|
</PageLayout>
|
||||||
</div>
|
</WorkspaceLayout>
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,17 +400,78 @@ function contributedNavItems(
|
|||||||
): OrderedAdminNavItem[] {
|
): OrderedAdminNavItem[] {
|
||||||
return sections
|
return sections
|
||||||
.filter((section) => (section.group ?? "SYSTEM") === group && available.has(section.id) && !excludedIds.has(section.id))
|
.filter((section) => (section.group ?? "SYSTEM") === group && available.has(section.id) && !excludedIds.has(section.id))
|
||||||
.map((section) => ({ id: section.id, label: section.label, order: section.order ?? 100 }));
|
.map((section) => ({
|
||||||
|
id: section.id,
|
||||||
|
label: section.label,
|
||||||
|
order: section.order ?? 100,
|
||||||
|
moduleId: section.moduleId,
|
||||||
|
kind: section.kind
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleNavItem(available: ReadonlySet<string>, id: AdminSection, label: string, order: number): OrderedAdminNavItem | null {
|
function visibleNavItem(available: ReadonlySet<string>, id: AdminSection, label: string, order: number): OrderedAdminNavItem | null {
|
||||||
return available.has(id) ? { id, label, order } : null;
|
return available.has(id)
|
||||||
|
? { id, label, order, ...builtInAdminSectionMetadata[id] }
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortNavItems(items: Array<OrderedAdminNavItem | null>): OrderedAdminNavItem[] {
|
function sortNavItems(items: Array<OrderedAdminNavItem | null>): OrderedAdminNavItem[] {
|
||||||
return items.filter((item): item is OrderedAdminNavItem => item !== null).sort((left, right) => left.order - right.order);
|
return items.filter((item): item is OrderedAdminNavItem => item !== null).sort((left, right) => left.order - right.order);
|
||||||
}
|
}
|
||||||
|
|
||||||
function asSubnavItems(items: OrderedAdminNavItem[]) {
|
function adminNavigationTree(
|
||||||
return items.map(({ id, label }) => ({ id, label }));
|
groups: AdminNavGroup[]
|
||||||
|
): TreeSubnavNode<AdminSection>[] {
|
||||||
|
return groups.map((group) => {
|
||||||
|
const managementItems = group.items.filter(
|
||||||
|
(item) => item.kind !== "settings"
|
||||||
|
);
|
||||||
|
const settingsItems = group.items.filter(
|
||||||
|
(item) => item.kind === "settings"
|
||||||
|
);
|
||||||
|
const children: TreeSubnavNode<AdminSection>[] = managementItems.map(
|
||||||
|
({ id, label }) => ({ id, label })
|
||||||
|
);
|
||||||
|
if (settingsItems.length > 0) {
|
||||||
|
const byModule = new Map<string, OrderedAdminNavItem[]>();
|
||||||
|
for (const item of settingsItems) {
|
||||||
|
const moduleId = item.moduleId ?? "platform";
|
||||||
|
byModule.set(moduleId, [...(byModule.get(moduleId) ?? []), item]);
|
||||||
|
}
|
||||||
|
children.push({
|
||||||
|
branchId: `admin-${group.id}-settings`,
|
||||||
|
label: "i18n:govoplan-core.settings.c7f73bb5",
|
||||||
|
defaultExpanded: false,
|
||||||
|
children: [...byModule.entries()]
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([moduleId, items]) => ({
|
||||||
|
branchId: `admin-${group.id}-settings-${moduleId}`,
|
||||||
|
label: moduleLabel(moduleId),
|
||||||
|
defaultExpanded: items.some(
|
||||||
|
(item) => item.id === "system-settings"
|
||||||
|
),
|
||||||
|
children: items.map(({ id, label }) => ({ id, label }))
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
branchId: `admin-${group.id}`,
|
||||||
|
label: group.title,
|
||||||
|
defaultExpanded: group.id === "administration",
|
||||||
|
children
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleLabel(moduleId: string): string {
|
||||||
|
if (moduleId === "platform") return "i18n:govoplan-access.platform_administration";
|
||||||
|
if (moduleId === "access") return "i18n:govoplan-access.access.2f81a22d";
|
||||||
|
if (moduleId === "admin") return "i18n:govoplan-access.admin.4e7afebc";
|
||||||
|
if (moduleId === "files") return "i18n:govoplan-access.files.6ce6c512";
|
||||||
|
if (moduleId === "mail") return "i18n:govoplan-access.mail_servers.d627326a";
|
||||||
|
return moduleId
|
||||||
|
.split(/[-_]/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => part[0].toUpperCase() + part.slice(1))
|
||||||
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Plus, Search, Trash2 } from "lucide-react";
|
import { Plus, Search, Trash2 } from "lucide-react";
|
||||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||||
import { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
|
import { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
@@ -10,9 +11,10 @@ import { DateTimeField } from "@govoplan/core-webui";
|
|||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, useDeltaWatermarks } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, useDeltaWatermarks } from "@govoplan/core-webui";
|
||||||
import { scopeGrants, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { scopeGrants, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
import { ACCESS_INTERFACE_I18N, ACCESS_REFERENCE_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||||
|
|
||||||
function defaultDraft(userId: string) {
|
function defaultDraft(userId: string) {
|
||||||
return { name: "", userId, scopes: ["campaign:read"], expiresAt: "" };
|
return { name: "", userId, scopes: ["campaign:read"], expiresAt: "" };
|
||||||
@@ -101,8 +103,8 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
|
|||||||
{ id: "last_used", header: "i18n:govoplan-access.last_used.f1109d3d", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) },
|
{ id: "last_used", header: "i18n:govoplan-access.last_used.f1109d3d", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) },
|
||||||
{ id: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa" },
|
{ id: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa" },
|
||||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 108, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 108, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, helpContextId: "access.api-keys.action.inspect", helpModuleId: "access", onClick: () => setViewing(row) },
|
||||||
{ id: "revoke", label: i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.revoked_at, disabled: !canRevoke, onClick: () => setRevoking(row) }
|
{ id: "revoke", label: i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name }), icon: <Trash2 />, variant: "danger", helpContextId: "access.api-keys.action.revoke", helpModuleId: "access", applicable: !row.revoked_at, disabled: !canRevoke, disabledReason: row.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : !canRevoke ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => setRevoking(row) }
|
||||||
]} /> }],
|
]} /> }],
|
||||||
[canRevoke]);
|
[canRevoke]);
|
||||||
|
|
||||||
@@ -151,33 +153,33 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminPageLayout title="i18n:govoplan-access.tenant_api_keys.4b1d81f8" description="i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae" loading={loading} error={error} success={success} actions={<><ToggleSwitch label="i18n:govoplan-access.show_revoked.b4265807" checked={showRevoked} onChange={setShowRevoked} /><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_api_key.725d9988" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} /></>}>
|
<AdminPageLayout title="i18n:govoplan-access.tenant_api_keys.4b1d81f8" description="i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae" loading={loading} error={error} success={success} helpContextId="access.admin.api-keys" helpModuleId="access" actions={<><DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} /><ToggleSwitch label="i18n:govoplan-access.show_revoked.b4265807" checked={showRevoked} helpContextId="access.api-keys.field.show-revoked" helpModuleId="access" onChange={setShowRevoked} /><Button helpContextId="access.api-keys.action.reload" helpModuleId="access" onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_api_key.725d9988" icon={<Plus />} variant="primary" helpContextId="access.api-keys.action.create" helpModuleId="access" onClick={openCreate} disabled={!canCreate || !users.length} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : !users.length ? ACCESS_INTERFACE_I18N.selectUserAndScopes : undefined} /></>}>
|
||||||
<div className="admin-table-surface"><DataGrid id="admin-api-keys-v3" rows={keys} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_api_keys_found.1f377128" /></div>
|
<div className="admin-table-surface"><DataGrid id="admin-api-keys-v3" rows={keys} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_api_keys_found.1f377128" /></div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|
||||||
<Dialog open={creating} title="i18n:govoplan-access.create_api_key.d7b30388" onClose={() => !busy && setCreating(false)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setCreating(false)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.userId || !draft.scopes.length}>{busy ? "i18n:govoplan-access.creating.94d7d8ee" : "i18n:govoplan-access.create_key.e028cb09"}</Button></>}>
|
<Dialog variant="administration" size="wide" open={creating} title="i18n:govoplan-access.create_api_key.d7b30388" helpContextId="access.api-keys.action.create" helpModuleId="access" onClose={() => !busy && setCreating(false)} className="" footer={<><Button onClick={() => setCreating(false)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" helpContextId="access.api-keys.action.create" helpModuleId="access" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canCreate, complete: Boolean(draft.name.trim() && draft.userId && draft.scopes.length) })}>{busy ? "i18n:govoplan-access.creating.94d7d8ee" : "i18n:govoplan-access.create_key.e028cb09"}</Button></>}>
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.name.709a2322" helpContextId="access.api-keys.field.name" helpModuleId="access"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.owner.89ff3122"><select value={draft.userId} onChange={(event) => {const userId = event.target.value;const user = users.find((item) => item.id === userId);const allowed = new Set(permissions.filter((permission) => user?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope))).map((permission) => permission.scope));setDraft({ ...draft, userId, scopes: draft.scopes.filter((scope) => allowed.has(scope)) });}}><option value="">i18n:govoplan-access.select_user.b8a1d9de</option>{users.map((user) => <option key={user.id} value={user.id}>{user.display_name || user.email} — {user.email}</option>)}</select></FormField>
|
<FormField label="i18n:govoplan-access.owner.89ff3122" helpContextId="access.api-keys.field.owner" helpModuleId="access"><select value={draft.userId} onChange={(event) => {const userId = event.target.value;const user = users.find((item) => item.id === userId);const allowed = new Set(permissions.filter((permission) => user?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope))).map((permission) => permission.scope));setDraft({ ...draft, userId, scopes: draft.scopes.filter((scope) => allowed.has(scope)) });}}><option value="">i18n:govoplan-access.select_user.b8a1d9de</option>{users.map((user) => <option key={user.id} value={user.id}>{user.display_name || user.email} — {user.email}</option>)}</select></FormField>
|
||||||
<FormField label="i18n:govoplan-access.expiry.ba8f571e"><DateTimeField value={draft.expiresAt} onChange={(value) => setDraft({ ...draft, expiresAt: value })} /></FormField>
|
<FormField label="i18n:govoplan-access.expiry.ba8f571e" helpContextId="access.api-keys.field.expiry" helpModuleId="access"><DateTimeField value={draft.expiresAt} onChange={(value) => setDraft({ ...draft, expiresAt: value })} /></FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
<div className="form-field"><span className="form-label">i18n:govoplan-access.allowed_scopes.d94515ff</span><AdminSelectionList options={allowedPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: i18nMessage("i18n:govoplan-access.value_value.0e2772ed", { value0: permission.scope, value1: permission.description }) }))} selected={draft.scopes} onChange={(scopes) => setDraft({ ...draft, scopes })} emptyText="i18n:govoplan-access.the_selected_user_has_no_tenant_permissions_avai.96985ec7" /></div>
|
<div className="form-field" data-help-context-id="access.api-keys.field.scopes" data-help-module-id="access"><span className="form-label">i18n:govoplan-access.allowed_scopes.d94515ff</span><AdminSelectionList options={allowedPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: i18nMessage("i18n:govoplan-access.value_value.0e2772ed", { value0: permission.scope, value1: permission.description }) }))} selected={draft.scopes} onChange={(scopes) => setDraft({ ...draft, scopes })} emptyText="i18n:govoplan-access.the_selected_user_has_no_tenant_permissions_avai.96985ec7" /></div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.api_key_details.f70c16be" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.api_key_details.f70c16be" helpContextId="access.api-keys.action.inspect" helpModuleId="access" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||||
{viewing && <><dl className="admin-details-grid">
|
{viewing && <><DescriptionList>
|
||||||
<div><dt>i18n:govoplan-access.name.709a2322</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.prefix.90eceb01</dt><dd>{viewing.prefix}…</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.name.709a2322</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.prefix.90eceb01</>}>{viewing.prefix}…</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.owner.89ff3122</dt><dd>{viewing.user_email}</dd></div><div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : "i18n:govoplan-access.active.a733b809"}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.owner.89ff3122</>}>{viewing.user_email}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.status.bae7d5be</>}>{viewing.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : "i18n:govoplan-access.active.a733b809"}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-access.last_used.f1109d3d</dt><dd>{formatDateTime(viewing.last_used_at)}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.created.accf40c8</>}>{formatDateTime(viewing.created_at)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.last_used.f1109d3d</>}>{formatDateTime(viewing.last_used_at)}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.expires.a99be3da</dt><dd>{viewing.expires_at ? formatDateTime(viewing.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa"}</dd></div><div><dt>i18n:govoplan-access.revoked.85f17ac0</dt><dd>{formatDateTime(viewing.revoked_at)}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.expires.a99be3da</>}>{viewing.expires_at ? formatDateTime(viewing.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.revoked.85f17ac0</>}>{formatDateTime(viewing.revoked_at)}</DescriptionItem>
|
||||||
</dl><h3>i18n:govoplan-access.scopes.c23540e5</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
</DescriptionList><h3>i18n:govoplan-access.scopes.c23540e5</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(secret)} title="i18n:govoplan-access.api_key_secret.00b16050" onClose={() => setSecret(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setSecret(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
<Dialog variant="administration" size="large" open={Boolean(secret)} title="i18n:govoplan-access.api_key_secret.00b16050" helpContextId="access.api-keys.secret" helpModuleId="access" onClose={() => setSecret(null)} className="" footer={<Button variant="primary" helpContextId="access.api-keys.secret" helpModuleId="access" onClick={() => setSecret(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
||||||
{secret && <><p>i18n:govoplan-access.the_secret_for.c60737ef <strong>{secret.name}</strong> i18n:govoplan-access.is_shown_once.af2b1235</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588</p></>}
|
{secret && <><p>i18n:govoplan-access.the_secret_for.c60737ef <strong>{secret.name}</strong> i18n:govoplan-access.is_shown_once.af2b1235</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588</p></>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog open={Boolean(revoking)} title="i18n:govoplan-access.revoke_api_key.3160aa7e" message={i18nMessage("i18n:govoplan-access.revoke_value_existing_clients_will_immediately_l.c70f07fd", { value0: revoking?.name })} confirmLabel="i18n:govoplan-access.revoke_key.acb203e7" tone="danger" busy={busy} onCancel={() => setRevoking(null)} onConfirm={() => void revoke()} />
|
<ConfirmDialog open={Boolean(revoking)} title="i18n:govoplan-access.revoke_api_key.3160aa7e" message={i18nMessage("i18n:govoplan-access.revoke_value_existing_clients_will_immediately_l.c70f07fd", { value0: revoking?.name })} confirmLabel="i18n:govoplan-access.revoke_key.acb203e7" tone="danger" busy={busy} helpContextId="access.api-keys.confirm-revoke" helpModuleId="access" onCancel={() => setRevoking(null)} onConfirm={() => void revoke()} />
|
||||||
</>);
|
</>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
AdminPageLayout,
|
||||||
|
CredentialEnvelopeManager,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
adminErrorMessage,
|
||||||
|
useDeltaWatermarks,
|
||||||
|
type ApiSettings,
|
||||||
|
type CredentialEnvelopeTargetOption,
|
||||||
|
type MailProfileScope
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
fetchGroupsDelta,
|
||||||
|
fetchUsersDelta,
|
||||||
|
type GroupSummary,
|
||||||
|
type UserAdminItem
|
||||||
|
} from "../../api/admin";
|
||||||
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
import {
|
||||||
|
ACCESS_INTERFACE_I18N,
|
||||||
|
CREDENTIAL_DOCUMENTATION
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
|
type ScopeType = Extract<MailProfileScope, "system" | "tenant" | "user" | "group">;
|
||||||
|
|
||||||
|
export default function CredentialEnvelopesPanel({
|
||||||
|
settings,
|
||||||
|
scopeType,
|
||||||
|
canWrite
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
scopeType: ScopeType;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const [targets, setTargets] = useState<CredentialEnvelopeTargetOption[]>([]);
|
||||||
|
const [loadingTargets, setLoadingTargets] = useState(
|
||||||
|
scopeType === "user" || scopeType === "group"
|
||||||
|
);
|
||||||
|
const [targetError, setTargetError] = useState("");
|
||||||
|
const usersRef = useRef<UserAdminItem[]>([]);
|
||||||
|
const groupsRef = useRef<GroupSummary[]>([]);
|
||||||
|
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } =
|
||||||
|
useDeltaWatermarks();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
usersRef.current = [];
|
||||||
|
groupsRef.current = [];
|
||||||
|
resetDeltaWatermark();
|
||||||
|
void loadTargets();
|
||||||
|
}, [
|
||||||
|
resetDeltaWatermark,
|
||||||
|
scopeType,
|
||||||
|
settings.accessToken,
|
||||||
|
settings.apiBaseUrl,
|
||||||
|
settings.apiKey
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function loadTargets() {
|
||||||
|
if (scopeType !== "user" && scopeType !== "group") {
|
||||||
|
setTargets([]);
|
||||||
|
setLoadingTargets(false);
|
||||||
|
setTargetError("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingTargets(true);
|
||||||
|
setTargetError("");
|
||||||
|
try {
|
||||||
|
if (scopeType === "user") {
|
||||||
|
const users = await loadDeltaRows(
|
||||||
|
usersRef.current,
|
||||||
|
"access:credential-users",
|
||||||
|
getDeltaWatermark,
|
||||||
|
setDeltaWatermark,
|
||||||
|
(since) => fetchUsersDelta(settings, { since }),
|
||||||
|
(response) => response.users,
|
||||||
|
(user) => user.id,
|
||||||
|
"access_user",
|
||||||
|
(left, right) => left.email.localeCompare(right.email)
|
||||||
|
);
|
||||||
|
usersRef.current = users;
|
||||||
|
setTargets(
|
||||||
|
users.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
label: user.display_name || user.email,
|
||||||
|
secondary: user.display_name ? user.email : null
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const groups = await loadDeltaRows(
|
||||||
|
groupsRef.current,
|
||||||
|
"access:credential-groups",
|
||||||
|
getDeltaWatermark,
|
||||||
|
setDeltaWatermark,
|
||||||
|
(since) => fetchGroupsDelta(settings, { since }),
|
||||||
|
(response) => response.groups,
|
||||||
|
(group) => group.id,
|
||||||
|
"access_group",
|
||||||
|
(left, right) =>
|
||||||
|
left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug)
|
||||||
|
);
|
||||||
|
groupsRef.current = groups;
|
||||||
|
setTargets(
|
||||||
|
groups.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
label: group.name,
|
||||||
|
secondary: group.slug
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setTargets([]);
|
||||||
|
setTargetError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoadingTargets(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminPageLayout
|
||||||
|
title={scopeTitle(scopeType)}
|
||||||
|
description={scopeDescription(scopeType)}
|
||||||
|
loading={loadingTargets}
|
||||||
|
error={targetError}
|
||||||
|
actions={<DocumentationHelpLink reference={CREDENTIAL_DOCUMENTATION} />}
|
||||||
|
>
|
||||||
|
<CredentialEnvelopeManager
|
||||||
|
settings={settings}
|
||||||
|
scopeType={scopeType}
|
||||||
|
targetOptions={targets}
|
||||||
|
targetLabel={scopeType === "group" ? "i18n:govoplan-access.group.171a0606" : "i18n:govoplan-access.user.9f8a2389"}
|
||||||
|
title={ACCESS_INTERFACE_I18N.reusableCredentials}
|
||||||
|
canWrite={canWrite}
|
||||||
|
/>
|
||||||
|
</AdminPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeTitle(scopeType: ScopeType): string {
|
||||||
|
if (scopeType === "system") return ACCESS_INTERFACE_I18N.systemCredentials;
|
||||||
|
if (scopeType === "tenant") return ACCESS_INTERFACE_I18N.tenantCredentials;
|
||||||
|
if (scopeType === "group") return ACCESS_INTERFACE_I18N.groupCredentials;
|
||||||
|
return ACCESS_INTERFACE_I18N.userCredentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeDescription(scopeType: ScopeType): string {
|
||||||
|
if (scopeType === "system") {
|
||||||
|
return ACCESS_INTERFACE_I18N.systemCredentialDescription;
|
||||||
|
}
|
||||||
|
if (scopeType === "tenant") {
|
||||||
|
return ACCESS_INTERFACE_I18N.tenantCredentialDescription;
|
||||||
|
}
|
||||||
|
return scopeType === "group"
|
||||||
|
? ACCESS_INTERFACE_I18N.groupCredentialDescription
|
||||||
|
: ACCESS_INTERFACE_I18N.userCredentialDescription;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
import type { ApiSettings, AuthInfo, OrganizationFunctionPickerUiCapability, OrganizationFunctionSelection } from "@govoplan/core-webui";
|
import type { FormGrid, ApiSettings, AuthInfo, OrganizationFunctionPickerUiCapability, OrganizationFunctionSelection } from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
createExternalFunctionRoleMapping,
|
createExternalFunctionRoleMapping,
|
||||||
deleteExternalFunctionRoleMapping,
|
deleteExternalFunctionRoleMapping,
|
||||||
@@ -15,9 +15,10 @@ import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
|||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
|
||||||
import { i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
import { ACCESS_INTERFACE_I18N, FUNCTION_MAPPING_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||||
|
|
||||||
const emptyDraft = {
|
const emptyDraft = {
|
||||||
sourceModule: "organizations",
|
sourceModule: "organizations",
|
||||||
@@ -258,8 +259,8 @@ export default function ExternalFunctionRoleMappingsPanel({
|
|||||||
resizable: false,
|
resizable: false,
|
||||||
align: "right",
|
align: "right",
|
||||||
render: (row) => <TableActionGroup actions={[
|
render: (row) => <TableActionGroup actions={[
|
||||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id }), icon: <Pencil />, disabled: !canWrite, onClick: () => openEdit(row) },
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id }), icon: <Pencil />, disabled: !canWrite, disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.function_id }), icon: <Trash2 />, variant: "danger", disabled: !canWrite, onClick: () => setDeleting(row) }
|
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.function_id }), icon: <Trash2 />, variant: "danger", disabled: !canWrite, disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => setDeleting(row) }
|
||||||
]} />
|
]} />
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -276,8 +277,9 @@ export default function ExternalFunctionRoleMappingsPanel({
|
|||||||
success={success}
|
success={success}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button>
|
<DocumentationHelpLink reference={FUNCTION_MAPPING_DOCUMENTATION} />
|
||||||
<AdminIconButton label="i18n:govoplan-access.add_function_role_mapping.1bc376ac" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite || !assignableRoles.length} />
|
<Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button>
|
||||||
|
<AdminIconButton label="i18n:govoplan-access.add_function_role_mapping.1bc376ac" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite || !assignableRoles.length} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.createPermissionRequired : !assignableRoles.length ? ACCESS_INTERFACE_I18N.selectAssignableRole : undefined} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -293,21 +295,21 @@ export default function ExternalFunctionRoleMappingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|
||||||
<Dialog
|
<Dialog variant="administration" size="large"
|
||||||
open={editing !== null}
|
open={editing !== null}
|
||||||
title={editing === "new" ? "i18n:govoplan-access.create_function_role_mapping.3718168d" : "i18n:govoplan-access.edit_function_role_mapping.91ee75af"}
|
title={editing === "new" ? "i18n:govoplan-access.create_function_role_mapping.3718168d" : "i18n:govoplan-access.edit_function_role_mapping.91ee75af"}
|
||||||
onClose={() => !busy && closeEditor()}
|
onClose={() => !busy && closeEditor()}
|
||||||
className="admin-dialog"
|
className=""
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button onClick={closeEditor} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button>
|
<Button onClick={closeEditor} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button>
|
||||||
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.functionId.trim() || !draft.roleId}>
|
<Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canWrite, complete: Boolean(draft.functionId.trim() && draft.roleId) })}>
|
||||||
{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_mapping.a4ac90e9"}
|
{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_mapping.a4ac90e9"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="admin-form-grid">
|
<FormGrid columns={1} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="i18n:govoplan-access.function_id.e5e08937">
|
<FormField label="i18n:govoplan-access.function_id.e5e08937">
|
||||||
{functionPicker.renderPicker({
|
{functionPicker.renderPicker({
|
||||||
settings,
|
settings,
|
||||||
@@ -325,7 +327,7 @@ export default function ExternalFunctionRoleMappingsPanel({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
<p className="muted small-note">i18n:govoplan-access.function_role_mapping_help.0cf9996a</p>
|
<p className="muted small-note">i18n:govoplan-access.function_role_mapping_help.0cf9996a</p>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { ApiSettings, FileConnectorScope, FileConnectorTargetOption, FilesConnectorsUiCapability } from "@govoplan/core-webui";
|
import type { ApiSettings, FileConnectorScope, FileConnectorTargetOption, FilesConnectorsUiCapability } from "@govoplan/core-webui";
|
||||||
import { AdminPageLayout, Card, adminErrorMessage, useDeltaWatermarks, usePlatformUiCapability } from "@govoplan/core-webui";
|
import { ActionBlockerHint, AdminPageLayout, adminErrorMessage, useDeltaWatermarks, usePlatformUiCapability } from "@govoplan/core-webui";
|
||||||
import { fetchGroupsDelta, fetchUsersDelta, type GroupSummary, type UserAdminItem } from "../../api/admin";
|
import { fetchGroupsDelta, fetchUsersDelta, type GroupSummary, type UserAdminItem } from "../../api/admin";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
import {
|
||||||
|
ACCESS_INTERFACE_I18N,
|
||||||
|
FILE_CONNECTOR_DOCUMENTATION
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
@@ -82,9 +86,21 @@ export default function FileConnectorsPanel({ settings, scopeType, canWrite }: P
|
|||||||
if (!FileConnectorScopeManager) {
|
if (!FileConnectorScopeManager) {
|
||||||
return (
|
return (
|
||||||
<AdminPageLayout title={labels.title} description={labels.description}>
|
<AdminPageLayout title={labels.title} description={labels.description}>
|
||||||
<Card title="i18n:govoplan-access.files_module_unavailable.0ee90db1">
|
<ActionBlockerHint
|
||||||
<p className="muted">i18n:govoplan-access.install_and_enable_the_files_module_to_manage_fi.f842c153</p>
|
reason={{
|
||||||
</Card>
|
summary: "i18n:govoplan-access.files_module_unavailable.0ee90db1",
|
||||||
|
details: "i18n:govoplan-access.install_and_enable_the_files_module_to_manage_fi.f842c153",
|
||||||
|
requiredAction: ACCESS_INTERFACE_I18N.installFiles,
|
||||||
|
actor: ACCESS_INTERFACE_I18N.systemModuleAdministrator,
|
||||||
|
target: ACCESS_INTERFACE_I18N.moduleManagement
|
||||||
|
}}
|
||||||
|
labels={{
|
||||||
|
requiredAction: ACCESS_INTERFACE_I18N.requiredAction,
|
||||||
|
actor: ACCESS_INTERFACE_I18N.actor,
|
||||||
|
target: ACCESS_INTERFACE_I18N.destinationLabel
|
||||||
|
}}
|
||||||
|
documentation={FILE_CONNECTOR_DOCUMENTATION}
|
||||||
|
/>
|
||||||
</AdminPageLayout>);
|
</AdminPageLayout>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { ContentGrid, DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||||
import { createGroup, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
|
import { createGroup, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
@@ -8,8 +9,13 @@ import { Dialog } from "@govoplan/core-webui";
|
|||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
import {
|
||||||
|
ACCESS_INTERFACE_I18N,
|
||||||
|
ACCESS_WORKFLOW_DOCUMENTATION,
|
||||||
|
saveDisabledReason
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] };
|
const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] };
|
||||||
|
|
||||||
@@ -133,39 +139,39 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember
|
|||||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !(canDefine || canManageMembers || canAssignRoles), onClick: () => openEdit(row) },
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !(canDefine || canManageMembers || canAssignRoles), disabledReason: !(canDefine || canManageMembers || canAssignRoles) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canDefine || Boolean(row.system_required), onClick: () => setDeactivating(row) }
|
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canDefine || Boolean(row.system_required), disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canDefine ? ACCESS_INTERFACE_I18N.writePermissionRequired : row.system_required ? ACCESS_INTERFACE_I18N.systemManagedObject : undefined, onClick: () => setDeactivating(row) }
|
||||||
]} /> }],
|
]} /> }],
|
||||||
[canAssignRoles, canDefine, canManageMembers]);
|
[canAssignRoles, canDefine, canManageMembers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminPageLayout title="i18n:govoplan-access.tenant_groups.47e6cc05" description="i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309" loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_group.2fca464f" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
|
<AdminPageLayout title="i18n:govoplan-access.tenant_groups.47e6cc05" description="i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309" loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_group.2fca464f" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} disabledReason={!canDefine ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||||
<div className="admin-table-surface"><DataGrid id="admin-groups-v3" rows={groups} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_groups_found.627ca913" /></div>
|
<div className="admin-table-surface"><DataGrid id="admin-groups-v3" rows={groups} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_groups_found.627ca913" /></div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|
||||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_group.5a0b1c17" : "i18n:govoplan-access.edit_group.edb57d8e"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim() || (editing === "new" ? !canDefine : !(canDefine || canManageMembers || canAssignRoles))}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_group.36ca6865"}</Button></>}>
|
<Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_group.5a0b1c17" : "i18n:govoplan-access.edit_group.edb57d8e"} onClose={() => !busy && setEditing(null)} className="" footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: editing === "new" ? canDefine : canDefine || canManageMembers || canAssignRoles, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_group.36ca6865"}</Button></>}>
|
||||||
{editing !== "new" && editing?.system_template_id && <p className="admin-managed-notice">i18n:govoplan-access.this_group_definition_is_managed_by_the_system_n.640b235e</p>}
|
{editing !== "new" && editing?.system_template_id && <p className="admin-managed-notice">i18n:govoplan-access.this_group_definition_is_managed_by_the_system_n.640b235e</p>}
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new" || !canDefine} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new" || !canDefine} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_required)} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
<FormField label="i18n:govoplan-access.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_required)} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
||||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
<div className="admin-assignment-grid">
|
<ContentGrid columns={2} spacing="block" collapseAt="wide">
|
||||||
<div><span className="form-label">i18n:govoplan-access.members.1cb449c1</span><AdminSelectionList options={users.map((user) => ({ id: user.id, label: user.display_name || user.email, description: user.email, disabled: !canManageMembers || !user.is_active || !user.account_is_active }))} selected={draft.memberIds} onChange={(memberIds) => setDraft({ ...draft, memberIds })} emptyText="i18n:govoplan-access.no_tenant_users_exist.96b4a88a" /></div>
|
<div><span className="form-label">i18n:govoplan-access.members.1cb449c1</span><AdminSelectionList options={users.map((user) => ({ id: user.id, label: user.display_name || user.email, description: user.email, disabled: !canManageMembers || !user.is_active || !user.account_is_active }))} selected={draft.memberIds} onChange={(memberIds) => setDraft({ ...draft, memberIds })} emptyText="i18n:govoplan-access.no_tenant_users_exist.96b4a88a" /></div>
|
||||||
<div><span className="form-label">i18n:govoplan-access.inherited_roles.8def9f05</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div>
|
<div><span className="form-label">i18n:govoplan-access.inherited_roles.8def9f05</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div>
|
||||||
</div>
|
</ContentGrid>
|
||||||
<p className="muted small-note">i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc</p>
|
<p className="muted small-note">i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc</p>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.group_details.df844ae3" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.group_details.df844ae3" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||||
{viewing && <dl className="admin-details-grid">
|
{viewing && <DescriptionList>
|
||||||
<div><dt>i18n:govoplan-access.group.171a0606</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.group.171a0606</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.slug.094da9b9</>}>{viewing.slug}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-access.management.63cecca6</dt><dd>{viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_managed.843eed93"}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.status.bae7d5be</>}>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.management.63cecca6</>}>{viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_managed.843eed93"}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.members.1cb449c1</dt><dd>{viewing.member_count}</dd></div><div><dt>i18n:govoplan-access.roles.47dcc27d</dt><dd>{joinLabels(viewing.roles)}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.members.1cb449c1</>}>{viewing.member_count}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.roles.47dcc27d</>}>{joinLabels(viewing.roles)}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-access.updated.f2f8570d</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.created.accf40c8</>}>{formatDateTime(viewing.created_at)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.updated.f2f8570d</>}>{formatDateTime(viewing.updated_at)}</DescriptionItem>
|
||||||
</dl>}
|
</DescriptionList>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog open={Boolean(deactivating)} title="i18n:govoplan-access.deactivate_group.f1b8ceea" message={i18nMessage("i18n:govoplan-access.deactivate_value_memberships_remain_stored_but_r.4693e4fd", { value0: deactivating?.name })} confirmLabel="i18n:govoplan-access.deactivate_group.f1b8ceea" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
<ConfirmDialog open={Boolean(deactivating)} title="i18n:govoplan-access.deactivate_group.f1b8ceea" message={i18nMessage("i18n:govoplan-access.deactivate_value_memberships_remain_stored_but_r.4693e4fd", { value0: deactivating?.name })} confirmLabel="i18n:govoplan-access.deactivate_group.f1b8ceea" tone="danger" busy={busy} onCancel={() => setDeactivating(null)} onConfirm={() => void deactivate()} />
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { ApiSettings, MailProfileScope, MailProfilesUiCapability, MailProfileTargetOption } from "@govoplan/core-webui";
|
import type { ApiSettings, MailProfileScope, MailProfilesUiCapability, MailProfileTargetOption } from "@govoplan/core-webui";
|
||||||
import { fetchGroupsDelta, fetchUsersDelta, type GroupSummary, type UserAdminItem } from "../../api/admin";
|
import { fetchGroupsDelta, fetchUsersDelta, type GroupSummary, type UserAdminItem } from "../../api/admin";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { ActionBlockerHint, AdminPageLayout, adminErrorMessage, useDeltaWatermarks } from "@govoplan/core-webui";
|
||||||
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks } from "@govoplan/core-webui";
|
|
||||||
import { usePlatformUiCapability } from "@govoplan/core-webui";
|
import { usePlatformUiCapability } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
import {
|
||||||
|
ACCESS_INTERFACE_I18N,
|
||||||
|
MAIL_PROFILE_DOCUMENTATION
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
@@ -106,9 +109,21 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
|
|||||||
if (!MailProfileScopeManager) {
|
if (!MailProfileScopeManager) {
|
||||||
return (
|
return (
|
||||||
<AdminPageLayout title={labels.title} description={labels.description}>
|
<AdminPageLayout title={labels.title} description={labels.description}>
|
||||||
<Card title="i18n:govoplan-access.mail_module_unavailable.b4e95104">
|
<ActionBlockerHint
|
||||||
<p className="muted">i18n:govoplan-access.install_and_enable_the_mail_module_to_manage_mai.a8ad5b3a</p>
|
reason={{
|
||||||
</Card>
|
summary: "i18n:govoplan-access.mail_module_unavailable.b4e95104",
|
||||||
|
details: "i18n:govoplan-access.install_and_enable_the_mail_module_to_manage_mai.a8ad5b3a",
|
||||||
|
requiredAction: ACCESS_INTERFACE_I18N.installMail,
|
||||||
|
actor: ACCESS_INTERFACE_I18N.systemModuleAdministrator,
|
||||||
|
target: ACCESS_INTERFACE_I18N.moduleManagement
|
||||||
|
}}
|
||||||
|
labels={{
|
||||||
|
requiredAction: ACCESS_INTERFACE_I18N.requiredAction,
|
||||||
|
actor: ACCESS_INTERFACE_I18N.actor,
|
||||||
|
target: ACCESS_INTERFACE_I18N.destinationLabel
|
||||||
|
}}
|
||||||
|
documentation={MAIL_PROFILE_DOCUMENTATION}
|
||||||
|
/>
|
||||||
</AdminPageLayout>);
|
</AdminPageLayout>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||||
import { createRole, deleteRole, fetchPermissionCatalog, fetchRolesDelta, updateRole, type PermissionItem, type RoleSummary } from "../../api/admin";
|
import { createRole, deleteRole, fetchPermissionCatalog, fetchRolesDelta, updateRole, type PermissionItem, type RoleSummary } from "../../api/admin";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
@@ -8,9 +9,10 @@ import { Dialog } from "@govoplan/core-webui";
|
|||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage } from "@govoplan/core-webui";
|
||||||
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
import { ACCESS_INTERFACE_I18N, ACCESS_WORKFLOW_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||||
|
|
||||||
const emptyDraft = { slug: "", name: "", description: "", permissions: [] as string[], isAssignable: true };
|
const emptyDraft = { slug: "", name: "", description: "", permissions: [] as string[], isAssignable: true };
|
||||||
|
|
||||||
@@ -126,36 +128,36 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
|
|||||||
{ id: "type", header: "i18n:govoplan-access.type.3deb7456", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : row.system_template_id ? "i18n:govoplan-access.system.bc0792d8" : "i18n:govoplan-access.custom.081ae3fd"} /> },
|
{ id: "type", header: "i18n:govoplan-access.type.3deb7456", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : row.system_template_id ? "i18n:govoplan-access.system.bc0792d8" : "i18n:govoplan-access.custom.081ae3fd"} /> },
|
||||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine, onClick: () => openEdit(row) },
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine, disabledReason: row.is_builtin || row.system_template_id ? ACCESS_INTERFACE_I18N.systemManagedObject : !canDefine ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine || row.user_assignments + row.group_assignments > 0, onClick: () => setDeleting(row) }
|
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine || row.user_assignments + row.group_assignments > 0, disabledReason: row.is_builtin || row.system_template_id ? ACCESS_INTERFACE_I18N.systemManagedObject : !canDefine ? ACCESS_INTERFACE_I18N.writePermissionRequired : row.user_assignments + row.group_assignments > 0 ? ACCESS_INTERFACE_I18N.assignedObjectCannotBeDeleted : undefined, onClick: () => setDeleting(row) }
|
||||||
]} /> }],
|
]} /> }],
|
||||||
[canDefine, permissions]);
|
[canDefine, permissions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminPageLayout title="i18n:govoplan-access.tenant_roles.51aca82d" description="i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa" loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_role.d8d5d55c" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
|
<AdminPageLayout title="i18n:govoplan-access.tenant_roles.51aca82d" description="i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa" loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_role.d8d5d55c" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} disabledReason={!canDefine ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||||
<div className="admin-table-surface"><DataGrid id="admin-roles-v3" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_roles_found.70f7c0c9" /></div>
|
<div className="admin-table-surface"><DataGrid id="admin-roles-v3" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_roles_found.70f7c0c9" /></div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|
||||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_role.db859bad" : "i18n:govoplan-access.edit_role.61dd63e9"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={!canDefine || busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
|
<Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_role.db859bad" : "i18n:govoplan-access.edit_role.61dd63e9"} onClose={() => !busy && setEditing(null)} className="" footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canDefine, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||||
{editing !== "new" && <FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>}
|
{editing !== "new" && <FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>}
|
||||||
</div>
|
</FormGrid>
|
||||||
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => {
|
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => {
|
||||||
const scopes = items.map((permission) => permission.scope);
|
const scopes = items.map((permission) => permission.scope);
|
||||||
return <fieldset key={category} className="admin-permission-group"><legend>{category}</legend><AdminSelectionList options={items.map((permission) => ({ id: permission.scope, label: permission.label, description: <>{permission.description}<code>{permission.scope}</code></> }))} selected={draft.permissions.filter((scope) => scopes.includes(scope))} onChange={(selected) => setPermissionGroup(scopes, selected)} /></fieldset>;
|
return <fieldset key={category} className="admin-permission-group"><legend>{category}</legend><AdminSelectionList options={items.map((permission) => ({ id: permission.scope, label: permission.label, description: <>{permission.description}<code>{permission.scope}</code></> }))} selected={draft.permissions.filter((scope) => scopes.includes(scope))} onChange={(selected) => setPermissionGroup(scopes, selected)} /></fieldset>;
|
||||||
})}</div>
|
})}</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.role_details.a16b5d9f" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.role_details.a16b5d9f" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||||
{viewing && <><dl className="admin-details-grid">
|
{viewing && <><DescriptionList>
|
||||||
<div><dt>i18n:govoplan-access.role.c3f104d1</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.role.c3f104d1</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.slug.094da9b9</>}>{viewing.slug}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.type.3deb7456</dt><dd>{viewing.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_custom.4307081e"}</dd></div><div><dt>i18n:govoplan-access.assignable.a88debc5</dt><dd>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.type.3deb7456</>}>{viewing.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_custom.4307081e"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.assignable.a88debc5</>}>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.user_assignments.bc7cc801</dt><dd>{viewing.user_assignments}</dd></div><div><dt>i18n:govoplan-access.group_assignments.e534bb56</dt><dd>{viewing.group_assignments}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.user_assignments.bc7cc801</>}>{viewing.user_assignments}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.group_assignments.e534bb56</>}>{viewing.group_assignments}</DescriptionItem>
|
||||||
</dl><h3>i18n:govoplan-access.permissions.d06d5557</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
</DescriptionList><h3>i18n:govoplan-access.permissions.d06d5557</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_role.fbf0667e" message={i18nMessage("i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_role.fbf0667e" message={i18nMessage("i18n:govoplan-access.delete_value_only_unassigned_tenant_defined_role.e48b13e7", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
||||||
|
|||||||
@@ -0,0 +1,558 @@
|
|||||||
|
import { MetricGrid } from "@govoplan/core-webui";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
KeyRound,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
ShieldOff,
|
||||||
|
Trash2
|
||||||
|
} from "lucide-react";
|
||||||
|
import { FormGrid, ActionToolbar,
|
||||||
|
AdminIconButton,
|
||||||
|
AdminPageLayout,
|
||||||
|
AdminSelectionList,
|
||||||
|
Button,
|
||||||
|
ConfirmDialog,
|
||||||
|
DataGrid,
|
||||||
|
DateTimeField,
|
||||||
|
Dialog,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
FormField,
|
||||||
|
MetricCard,
|
||||||
|
StatusBadge,
|
||||||
|
TableActionGroup,
|
||||||
|
ToggleSwitch,
|
||||||
|
adminErrorMessage,
|
||||||
|
formatAdminDateTime as formatDateTime,
|
||||||
|
hasScope,
|
||||||
|
scopeGrants,
|
||||||
|
type ApiSettings,
|
||||||
|
type AuthInfo,
|
||||||
|
type DataGridColumn,
|
||||||
|
type PermissionItem
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
createServiceAccount,
|
||||||
|
createServiceAccountCredential,
|
||||||
|
fetchPermissionCatalog,
|
||||||
|
fetchServiceAccountCredentials,
|
||||||
|
fetchServiceAccounts,
|
||||||
|
retireServiceAccount,
|
||||||
|
revokeServiceAccountCredential,
|
||||||
|
rotateServiceAccountCredential,
|
||||||
|
updateServiceAccount,
|
||||||
|
type ServiceAccountCredentialItem,
|
||||||
|
type ServiceAccountItem
|
||||||
|
} from "../../api/admin";
|
||||||
|
import {
|
||||||
|
ACCESS_INTERFACE_I18N,
|
||||||
|
ACCESS_REFERENCE_DOCUMENTATION
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
|
type AccountDraft = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
scopes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CredentialDraft = {
|
||||||
|
name: string;
|
||||||
|
scopes: string[];
|
||||||
|
expiresAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CredentialEditor = {
|
||||||
|
mode: "create" | "rotate";
|
||||||
|
credential?: ServiceAccountCredentialItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ServiceAccountsPanel({
|
||||||
|
settings,
|
||||||
|
auth,
|
||||||
|
canWrite
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
auth: AuthInfo;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const [accounts, setAccounts] = useState<ServiceAccountItem[]>([]);
|
||||||
|
const [permissions, setPermissions] = useState<PermissionItem[]>([]);
|
||||||
|
const [managing, setManaging] = useState<ServiceAccountItem | null>(null);
|
||||||
|
const [credentials, setCredentials] = useState<ServiceAccountCredentialItem[]>([]);
|
||||||
|
const [showRevoked, setShowRevoked] = useState(true);
|
||||||
|
const [accountEditor, setAccountEditor] = useState<"create" | "edit" | null>(null);
|
||||||
|
const [accountDraft, setAccountDraft] = useState<AccountDraft>(emptyAccountDraft());
|
||||||
|
const [credentialEditor, setCredentialEditor] = useState<CredentialEditor | null>(null);
|
||||||
|
const [credentialDraft, setCredentialDraft] = useState<CredentialDraft>(emptyCredentialDraft());
|
||||||
|
const [secret, setSecret] = useState<{ name: string; value: string } | null>(null);
|
||||||
|
const [revoking, setRevoking] = useState<ServiceAccountCredentialItem | null>(null);
|
||||||
|
const [retiring, setRetiring] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
const grantablePermissions = useMemo(
|
||||||
|
() => permissions.filter((permission) => permission.level === "tenant" && hasScope(auth, permission.scope)),
|
||||||
|
[auth, permissions]
|
||||||
|
);
|
||||||
|
const credentialPermissions = useMemo(
|
||||||
|
() => grantablePermissions.filter((permission) => managing?.scope_ceiling.some((scope) => scopeGrants(scope, permission.scope))),
|
||||||
|
[grantablePermissions, managing]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [nextAccounts, nextPermissions] = await Promise.all([
|
||||||
|
fetchServiceAccounts(settings),
|
||||||
|
fetchPermissionCatalog(settings)
|
||||||
|
]);
|
||||||
|
setAccounts(nextAccounts);
|
||||||
|
setPermissions(nextPermissions);
|
||||||
|
if (managing) {
|
||||||
|
const refreshed = nextAccounts.find((item) => item.id === managing.id) ?? null;
|
||||||
|
setManaging(refreshed);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openManager(account: ServiceAccountItem) {
|
||||||
|
setManaging(account);
|
||||||
|
setCredentials([]);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await fetchServiceAccountCredentials(settings, account.id, true);
|
||||||
|
setCredentials(response.items);
|
||||||
|
setManaging({ ...account, revision: response.service_account_revision });
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshManaged(serviceAccountId: string) {
|
||||||
|
const [nextAccounts, response] = await Promise.all([
|
||||||
|
fetchServiceAccounts(settings),
|
||||||
|
fetchServiceAccountCredentials(settings, serviceAccountId, true)
|
||||||
|
]);
|
||||||
|
const selected = nextAccounts.find((item) => item.id === serviceAccountId) ?? null;
|
||||||
|
setAccounts(nextAccounts);
|
||||||
|
setCredentials(response.items);
|
||||||
|
setManaging(selected ? { ...selected, revision: response.service_account_revision } : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [settings.accessToken, settings.apiBaseUrl, (auth.active_tenant ?? auth.tenant).id]);
|
||||||
|
|
||||||
|
const accountColumns = useMemo<DataGridColumn<ServiceAccountItem>[]>(() => [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
header: "Name",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
minWidth: 190,
|
||||||
|
resizable: true,
|
||||||
|
fill: true,
|
||||||
|
sticky: "start",
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (row) => row.name,
|
||||||
|
render: (row) => <div><strong>{row.name}</strong>{row.description && <div className="muted small-note">{row.description}</div>}</div>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
width: 120,
|
||||||
|
resizable: false,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (row) => row.is_active ? "active" : "inactive",
|
||||||
|
render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scope_ceiling",
|
||||||
|
header: "Scope ceiling",
|
||||||
|
width: 140,
|
||||||
|
resizable: false,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
filterType: "integer",
|
||||||
|
value: (row) => row.scope_ceiling.length,
|
||||||
|
render: (row) => String(row.scope_ceiling.length)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "credentials",
|
||||||
|
header: "Credentials",
|
||||||
|
width: 150,
|
||||||
|
resizable: false,
|
||||||
|
sortable: true,
|
||||||
|
value: (row) => row.active_credential_count,
|
||||||
|
render: (row) => `${row.active_credential_count} active / ${row.credential_count}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "last_used",
|
||||||
|
header: "Last used",
|
||||||
|
width: 180,
|
||||||
|
minWidth: 150,
|
||||||
|
resizable: true,
|
||||||
|
sortable: true,
|
||||||
|
value: (row) => row.last_credential_used_at || "",
|
||||||
|
render: (row) => formatDateTime(row.last_credential_used_at)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
width: 108,
|
||||||
|
sticky: "end",
|
||||||
|
resizable: false,
|
||||||
|
align: "right",
|
||||||
|
render: (row) => <TableActionGroup actions={[
|
||||||
|
{ id: "manage", label: `Manage ${row.name}`, icon: <Search />, helpContextId: "access.service-accounts.action.manage", helpModuleId: "access", onClick: () => void openManager(row) }
|
||||||
|
]} />
|
||||||
|
}
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const credentialColumns = useMemo<DataGridColumn<ServiceAccountCredentialItem>[]>(() => [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
header: "Name",
|
||||||
|
width: "minmax(190px, 1fr)",
|
||||||
|
minWidth: 170,
|
||||||
|
fill: true,
|
||||||
|
resizable: true,
|
||||||
|
value: (row) => row.name,
|
||||||
|
render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.prefix}...</div></div>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
width: 120,
|
||||||
|
resizable: false,
|
||||||
|
value: credentialStatus,
|
||||||
|
render: (row) => <StatusBadge status={credentialStatus(row)} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scopes",
|
||||||
|
header: "Scopes",
|
||||||
|
width: 100,
|
||||||
|
resizable: false,
|
||||||
|
value: (row) => row.scopes.length,
|
||||||
|
render: (row) => String(row.scopes.length)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "last_used",
|
||||||
|
header: "Last used",
|
||||||
|
width: 170,
|
||||||
|
resizable: true,
|
||||||
|
value: (row) => row.last_used_at || "",
|
||||||
|
render: (row) => formatDateTime(row.last_used_at)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "expires",
|
||||||
|
header: "Expires",
|
||||||
|
width: 170,
|
||||||
|
resizable: true,
|
||||||
|
value: (row) => row.expires_at || "",
|
||||||
|
render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "No expiry"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
width: 108,
|
||||||
|
sticky: "end",
|
||||||
|
resizable: false,
|
||||||
|
align: "right",
|
||||||
|
render: (row) => <TableActionGroup actions={[
|
||||||
|
{
|
||||||
|
id: "rotate",
|
||||||
|
label: `Rotate ${row.name}`,
|
||||||
|
icon: <RefreshCw />,
|
||||||
|
helpContextId: "access.service-accounts.action.rotate-credential",
|
||||||
|
helpModuleId: "access",
|
||||||
|
applicable: !row.revoked_at,
|
||||||
|
disabled: !canWrite || !managing?.is_active,
|
||||||
|
disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : !managing?.is_active ? "Activate the service account first." : undefined,
|
||||||
|
onClick: () => openCredentialEditor("rotate", row)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "revoke",
|
||||||
|
label: `Revoke ${row.name}`,
|
||||||
|
icon: <Trash2 />,
|
||||||
|
variant: "danger",
|
||||||
|
helpContextId: "access.service-accounts.action.revoke-credential",
|
||||||
|
helpModuleId: "access",
|
||||||
|
applicable: !row.revoked_at,
|
||||||
|
disabled: !canWrite,
|
||||||
|
disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined,
|
||||||
|
onClick: () => setRevoking(row)
|
||||||
|
}
|
||||||
|
]} />
|
||||||
|
}
|
||||||
|
], [canWrite, managing]);
|
||||||
|
|
||||||
|
function openCreateAccount() {
|
||||||
|
setAccountDraft(emptyAccountDraft());
|
||||||
|
setAccountEditor("create");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditAccount() {
|
||||||
|
if (!managing) return;
|
||||||
|
setAccountDraft({
|
||||||
|
name: managing.name,
|
||||||
|
description: managing.description ?? "",
|
||||||
|
scopes: [...managing.scope_ceiling]
|
||||||
|
});
|
||||||
|
setAccountEditor("edit");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAccount() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
if (accountEditor === "create") {
|
||||||
|
const created = await createServiceAccount(settings, {
|
||||||
|
name: accountDraft.name,
|
||||||
|
description: accountDraft.description || null,
|
||||||
|
scope_ceiling: accountDraft.scopes
|
||||||
|
});
|
||||||
|
setSuccess(`Service account ${created.name} created.`);
|
||||||
|
} else if (managing) {
|
||||||
|
await updateServiceAccount(settings, managing.id, {
|
||||||
|
expected_revision: managing.revision,
|
||||||
|
name: accountDraft.name,
|
||||||
|
description: accountDraft.description || null,
|
||||||
|
scope_ceiling: accountDraft.scopes
|
||||||
|
});
|
||||||
|
setSuccess(`Service account ${accountDraft.name} updated.`);
|
||||||
|
await refreshManaged(managing.id);
|
||||||
|
}
|
||||||
|
setAccountEditor(null);
|
||||||
|
if (accountEditor === "create") await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
if (managing) await refreshAfterConflict(managing.id);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setActive(active: boolean) {
|
||||||
|
if (!managing) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await updateServiceAccount(settings, managing.id, {
|
||||||
|
expected_revision: managing.revision,
|
||||||
|
is_active: active
|
||||||
|
});
|
||||||
|
setSuccess(`${managing.name} ${active ? "activated" : "deactivated"}.`);
|
||||||
|
await refreshManaged(managing.id);
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
await refreshAfterConflict(managing.id);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retire() {
|
||||||
|
if (!managing) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await retireServiceAccount(settings, managing.id, managing.revision);
|
||||||
|
setSuccess(`${managing.name} retired and its credentials revoked.`);
|
||||||
|
setRetiring(false);
|
||||||
|
await refreshManaged(managing.id);
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
await refreshAfterConflict(managing.id);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCredentialEditor(mode: "create" | "rotate", credential?: ServiceAccountCredentialItem) {
|
||||||
|
setCredentialDraft(credential ? {
|
||||||
|
name: credential.name,
|
||||||
|
scopes: [...credential.scopes],
|
||||||
|
expiresAt: ""
|
||||||
|
} : emptyCredentialDraft());
|
||||||
|
setCredentialEditor({ mode, credential });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCredential() {
|
||||||
|
if (!managing || !credentialEditor) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
expected_revision: managing.revision,
|
||||||
|
name: credentialDraft.name,
|
||||||
|
scopes: credentialDraft.scopes,
|
||||||
|
expires_at: credentialDraft.expiresAt ? new Date(credentialDraft.expiresAt).toISOString() : null
|
||||||
|
};
|
||||||
|
const response = credentialEditor.mode === "create"
|
||||||
|
? await createServiceAccountCredential(settings, managing.id, payload)
|
||||||
|
: await rotateServiceAccountCredential(settings, managing.id, credentialEditor.credential!.id, payload);
|
||||||
|
setSecret({ name: response.credential.name, value: response.secret });
|
||||||
|
setSuccess(credentialEditor.mode === "create" ? "Credential created." : "Credential rotated; the previous credential is revoked.");
|
||||||
|
setCredentialEditor(null);
|
||||||
|
await refreshManaged(managing.id);
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
await refreshAfterConflict(managing.id);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeCredential() {
|
||||||
|
if (!managing || !revoking) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await revokeServiceAccountCredential(settings, managing.id, revoking.id, managing.revision);
|
||||||
|
setSuccess(`Credential ${revoking.name} revoked.`);
|
||||||
|
setRevoking(null);
|
||||||
|
await refreshManaged(managing.id);
|
||||||
|
} catch (err) {
|
||||||
|
setError(adminErrorMessage(err));
|
||||||
|
await refreshAfterConflict(managing.id);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAfterConflict(serviceAccountId: string) {
|
||||||
|
try {
|
||||||
|
await refreshManaged(serviceAccountId);
|
||||||
|
} catch {
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleCredentials = showRevoked ? credentials : credentials.filter((item) => !item.revoked_at);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminPageLayout
|
||||||
|
title="Service accounts"
|
||||||
|
description="Manage non-login automation principals and their independently rotatable, scope-bounded credentials."
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
success={success}
|
||||||
|
helpContextId="access.admin.service-accounts"
|
||||||
|
helpModuleId="access"
|
||||||
|
actions={<>
|
||||||
|
<DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} />
|
||||||
|
<Button helpContextId="access.service-accounts.action.reload" helpModuleId="access" onClick={() => void load()} disabled={loading}>Reload</Button>
|
||||||
|
<AdminIconButton label="Add service account" icon={<Plus />} variant="primary" helpContextId="access.service-accounts.action.create" helpModuleId="access" onClick={openCreateAccount} disabled={!canWrite} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined} />
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
<div className="admin-table-surface">
|
||||||
|
<DataGrid id="admin-service-accounts-v1" rows={accounts} columns={accountColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="No service accounts found." />
|
||||||
|
</div>
|
||||||
|
</AdminPageLayout>
|
||||||
|
|
||||||
|
<Dialog variant="administration" size="wide"
|
||||||
|
open={Boolean(accountEditor)}
|
||||||
|
title={accountEditor === "create" ? "Create service account" : "Edit service account"}
|
||||||
|
helpContextId="access.service-accounts.account-editor"
|
||||||
|
helpModuleId="access"
|
||||||
|
onClose={() => !busy && setAccountEditor(null)}
|
||||||
|
className=""
|
||||||
|
footer={<><Button onClick={() => setAccountEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" helpContextId="access.service-accounts.action.save" helpModuleId="access" onClick={() => void saveAccount()} disabled={!canWrite || busy || !accountDraft.name.trim()}>{busy ? "Saving..." : "Save"}</Button></>}
|
||||||
|
>
|
||||||
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
|
<FormField label="Name" helpContextId="access.service-accounts.field.name" helpModuleId="access"><input value={accountDraft.name} onChange={(event) => setAccountDraft({ ...accountDraft, name: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Description" helpContextId="access.service-accounts.field.description" helpModuleId="access"><input value={accountDraft.description} onChange={(event) => setAccountDraft({ ...accountDraft, description: event.target.value })} /></FormField>
|
||||||
|
</FormGrid>
|
||||||
|
<div className="form-field" data-help-context-id="access.service-accounts.field.scope-ceiling" data-help-module-id="access">
|
||||||
|
<span className="form-label">Scope ceiling</span>
|
||||||
|
<AdminSelectionList options={grantablePermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: `${permission.scope} - ${permission.description}` }))} selected={accountDraft.scopes} onChange={(scopes) => setAccountDraft({ ...accountDraft, scopes })} emptyText="No tenant scopes can be delegated by your current account." />
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog variant="administration" size="wide"
|
||||||
|
open={Boolean(managing)}
|
||||||
|
title={managing?.name ?? "Service account"}
|
||||||
|
helpContextId="access.service-accounts.action.manage"
|
||||||
|
helpModuleId="access"
|
||||||
|
onClose={() => !busy && setManaging(null)}
|
||||||
|
className=""
|
||||||
|
footer={<Button onClick={() => setManaging(null)} disabled={busy}>Close</Button>}
|
||||||
|
>
|
||||||
|
{managing && <>
|
||||||
|
<MetricGrid density="compact">
|
||||||
|
<MetricCard label="Status" value={managing.is_active ? "Active" : "Inactive"} tone={managing.is_active ? "good" : "warning"} />
|
||||||
|
<MetricCard label="Active credentials" value={managing.active_credential_count} />
|
||||||
|
<MetricCard label="Scope ceiling" value={managing.scope_ceiling.length} />
|
||||||
|
<MetricCard label="Revision" value={managing.revision} />
|
||||||
|
</MetricGrid>
|
||||||
|
<ActionToolbar className="admin-toolbar-row">
|
||||||
|
<Button helpContextId="access.service-accounts.action.edit" helpModuleId="access" onClick={openEditAccount} disabled={!canWrite || busy}><Pencil aria-hidden="true" /> Edit</Button>
|
||||||
|
<Button helpContextId="access.service-accounts.action.activation" helpModuleId="access" onClick={() => void setActive(!managing.is_active)} disabled={!canWrite || busy}>{managing.is_active ? <ShieldOff aria-hidden="true" /> : <RefreshCw aria-hidden="true" />} {managing.is_active ? "Deactivate" : "Activate"}</Button>
|
||||||
|
<Button variant="danger" helpContextId="access.service-accounts.action.retire" helpModuleId="access" onClick={() => setRetiring(true)} disabled={!canWrite || busy || !managing.is_active}><Trash2 aria-hidden="true" /> Retire</Button>
|
||||||
|
<AdminIconButton label="Create credential" icon={<KeyRound />} variant="primary" helpContextId="access.service-accounts.action.create-credential" helpModuleId="access" onClick={() => openCredentialEditor("create")} disabled={!canWrite || !managing.is_active} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : !managing.is_active ? "Activate the service account first." : undefined} />
|
||||||
|
</ActionToolbar>
|
||||||
|
<ActionToolbar className="admin-toolbar-row">
|
||||||
|
<ToggleSwitch label="Show revoked credentials" checked={showRevoked} helpContextId="access.service-accounts.field.show-revoked" helpModuleId="access" onChange={setShowRevoked} />
|
||||||
|
</ActionToolbar>
|
||||||
|
<div className="admin-table-surface">
|
||||||
|
<DataGrid id="admin-service-account-credentials-v1" rows={visibleCredentials} columns={credentialColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="No credentials found." />
|
||||||
|
</div>
|
||||||
|
<p className="muted small-note">Secrets are shown once. Authentication always intersects a credential grant with this account's current scope ceiling, so reducing the ceiling takes effect immediately.</p>
|
||||||
|
</>}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog variant="administration" size="wide"
|
||||||
|
open={Boolean(credentialEditor)}
|
||||||
|
title={credentialEditor?.mode === "rotate" ? "Rotate credential" : "Create credential"}
|
||||||
|
helpContextId="access.service-accounts.credential-editor"
|
||||||
|
helpModuleId="access"
|
||||||
|
onClose={() => !busy && setCredentialEditor(null)}
|
||||||
|
className=""
|
||||||
|
footer={<><Button onClick={() => setCredentialEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" helpContextId="access.service-accounts.action.save-credential" helpModuleId="access" onClick={() => void saveCredential()} disabled={!canWrite || busy || !credentialDraft.name.trim() || credentialDraft.scopes.length === 0}>{busy ? "Saving..." : credentialEditor?.mode === "rotate" ? "Rotate" : "Create"}</Button></>}
|
||||||
|
>
|
||||||
|
{credentialEditor?.mode === "rotate" && <p className="muted small-note">Rotation creates a new secret and revokes the previous credential in the same transaction.</p>}
|
||||||
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
|
<FormField label="Name" helpContextId="access.service-accounts.field.credential-name" helpModuleId="access"><input value={credentialDraft.name} onChange={(event) => setCredentialDraft({ ...credentialDraft, name: event.target.value })} /></FormField>
|
||||||
|
<FormField label="Expiry" helpContextId="access.service-accounts.field.credential-expiry" helpModuleId="access"><DateTimeField value={credentialDraft.expiresAt} onChange={(value) => setCredentialDraft({ ...credentialDraft, expiresAt: value })} /></FormField>
|
||||||
|
</FormGrid>
|
||||||
|
<div className="form-field" data-help-context-id="access.service-accounts.field.credential-scopes" data-help-module-id="access">
|
||||||
|
<span className="form-label">Credential scopes</span>
|
||||||
|
<AdminSelectionList options={credentialPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: `${permission.scope} - ${permission.description}` }))} selected={credentialDraft.scopes} onChange={(scopes) => setCredentialDraft({ ...credentialDraft, scopes })} emptyText="The service account has no credential scopes available." />
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog variant="administration" size="large" open={Boolean(secret)} title="Service-account secret" helpContextId="access.service-accounts.secret" helpModuleId="access" onClose={() => setSecret(null)} className="" footer={<Button variant="primary" helpContextId="access.service-accounts.secret" helpModuleId="access" onClick={() => setSecret(null)}>I have recorded it</Button>}>
|
||||||
|
{secret && <><p>The secret for <strong>{secret.name}</strong> is shown once.</p><code className="admin-secret">{secret.value}</code><p className="muted small-note">Store it in a secret manager. GovOPlaN retains only a one-way hash and the visible prefix.</p></>}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog open={Boolean(revoking)} title="Revoke credential" message={`Revoke ${revoking?.name ?? "this credential"}? Existing clients using it will immediately lose access.`} confirmLabel="Revoke credential" tone="danger" busy={busy} helpContextId="access.service-accounts.confirm-revoke-credential" helpModuleId="access" onCancel={() => setRevoking(null)} onConfirm={() => void revokeCredential()} />
|
||||||
|
<ConfirmDialog open={retiring} title="Retire service account" message={`Retire ${managing?.name ?? "this service account"} and revoke all ${managing?.active_credential_count ?? 0} active credentials?`} confirmLabel="Retire and revoke" tone="danger" busy={busy} helpContextId="access.service-accounts.confirm-retire" helpModuleId="access" onCancel={() => setRetiring(false)} onConfirm={() => void retire()} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyAccountDraft(): AccountDraft {
|
||||||
|
return { name: "", description: "", scopes: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyCredentialDraft(): CredentialDraft {
|
||||||
|
return { name: "", scopes: [], expiresAt: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function credentialStatus(item: ServiceAccountCredentialItem): string {
|
||||||
|
if (item.revoked_at) return "revoked";
|
||||||
|
if (item.expires_at && new Date(item.expires_at).getTime() <= Date.now()) return "expired";
|
||||||
|
return "active";
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||||
import type { ApiSettings } from "@govoplan/core-webui";
|
import type { FormGrid, ApiSettings } from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
createSystemRole,
|
createSystemRole,
|
||||||
deleteSystemRole,
|
deleteSystemRole,
|
||||||
@@ -16,8 +17,9 @@ import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
|||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
import { ACCESS_INTERFACE_I18N, ACCESS_REFERENCE_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||||
|
|
||||||
const emptyDraft = {
|
const emptyDraft = {
|
||||||
slug: "",
|
slug: "",
|
||||||
@@ -225,8 +227,8 @@ export default function SystemRolesPanel({
|
|||||||
const protectedOwner = row.slug === "system_owner";
|
const protectedOwner = row.slug === "system_owner";
|
||||||
return <TableActionGroup actions={[
|
return <TableActionGroup actions={[
|
||||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !protectedOwner, disabled: !canWrite, onClick: () => openEdit(row) },
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !protectedOwner, disabled: !canWrite, disabledReason: protectedOwner ? ACCESS_INTERFACE_I18N.systemManagedObject : !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !protectedOwner, disabled: !canWrite || row.user_assignments > 0, onClick: () => setDeleting(row) }
|
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !protectedOwner, disabled: !canWrite || row.user_assignments > 0, disabledReason: protectedOwner ? ACCESS_INTERFACE_I18N.systemManagedObject : !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : row.user_assignments > 0 ? ACCESS_INTERFACE_I18N.assignedObjectCannotBeDeleted : undefined, onClick: () => setDeleting(row) }
|
||||||
]} />;
|
]} />;
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
@@ -240,26 +242,26 @@ export default function SystemRolesPanel({
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
success={success}
|
success={success}
|
||||||
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_system_role.f9ef262b" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} /></>}>
|
actions={<><DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_system_role.f9ef262b" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||||
|
|
||||||
<div className="admin-table-surface">
|
<div className="admin-table-surface">
|
||||||
<DataGrid id="admin-system-role-definitions-v4" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_system_roles_found.051cf727" />
|
<DataGrid id="admin-system-role-definitions-v4" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_system_roles_found.051cf727" />
|
||||||
</div>
|
</div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|
||||||
<Dialog
|
<Dialog variant="administration" size="wide"
|
||||||
open={editing !== null}
|
open={editing !== null}
|
||||||
title={editing === "new" ? "i18n:govoplan-access.create_system_role.a1e40b25" : "i18n:govoplan-access.edit_system_role.6ebb7cb0"}
|
title={editing === "new" ? "i18n:govoplan-access.create_system_role.a1e40b25" : "i18n:govoplan-access.edit_system_role.6ebb7cb0"}
|
||||||
onClose={() => !busy && setEditing(null)}
|
onClose={() => !busy && setEditing(null)}
|
||||||
className="admin-dialog admin-dialog-wide"
|
className=""
|
||||||
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
|
footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canWrite, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
|
||||||
|
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>
|
<FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>
|
||||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<span className="form-label">i18n:govoplan-access.system_permissions.53ff0ab2</span>
|
<span className="form-label">i18n:govoplan-access.system_permissions.53ff0ab2</span>
|
||||||
<AdminSelectionList
|
<AdminSelectionList
|
||||||
@@ -271,8 +273,8 @@ export default function SystemRolesPanel({
|
|||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(viewing)} title={viewing?.name || "i18n:govoplan-access.system_role_details.3d6a8f15"} onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title={viewing?.name || "i18n:govoplan-access.system_role_details.3d6a8f15"} onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||||
{viewing && <dl className="admin-details-grid"><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div><div><dt>i18n:govoplan-access.protected.28531336</dt><dd>{viewing.slug === "system_owner" ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</dd></div><div><dt>i18n:govoplan-access.assignable.a88debc5</dt><dd>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</dd></div><div><dt>i18n:govoplan-access.account_assignments.f5a91f2a</dt><dd>{viewing.user_assignments}</dd></div><div><dt>i18n:govoplan-access.description.55f8ebc8</dt><dd>{viewing.description || "—"}</dd></div><div><dt>i18n:govoplan-access.effective_permissions.17c0fe8a</dt><dd>{viewing.effective_permission_count}</dd></div><div><dt>i18n:govoplan-access.assigned_scopes.c7b09b12</dt><dd>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</dd></div></dl>}
|
{viewing && <DescriptionList><DescriptionItem term={<>i18n:govoplan-access.slug.094da9b9</>}>{viewing.slug}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.protected.28531336</>}>{viewing.slug === "system_owner" ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.assignable.a88debc5</>}>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.account_assignments.f5a91f2a</>}>{viewing.user_assignments}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.description.55f8ebc8</>}>{viewing.description || "—"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.effective_permissions.17c0fe8a</>}>{viewing.effective_permission_count}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.assigned_scopes.c7b09b12</>}>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</DescriptionItem></DescriptionList>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_system_role.e2d84a56" message={i18nMessage("i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
<ConfirmDialog open={Boolean(deleting)} title="i18n:govoplan-access.delete_system_role.e2d84a56" message={i18nMessage("i18n:govoplan-access.delete_value_the_role_must_have_no_account_assig.020eb657", { value0: deleting?.name })} confirmLabel="i18n:govoplan-access.delete_role.fbf0667e" tone="danger" busy={busy} onCancel={() => setDeleting(null)} onConfirm={() => void remove()} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { ContentGrid, DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
import type { ApiSettings } from "@govoplan/core-webui";
|
import type { FormGrid, ApiSettings } from "@govoplan/core-webui";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
@@ -20,7 +21,8 @@ import {
|
|||||||
type SystemMembershipDraft,
|
type SystemMembershipDraft,
|
||||||
type TenantAdminItem
|
type TenantAdminItem
|
||||||
} from "../../api/admin";
|
} from "../../api/admin";
|
||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
|
import { ACCESS_INTERFACE_I18N, ACCESS_REFERENCE_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||||
|
|
||||||
const emptyDraft = {
|
const emptyDraft = {
|
||||||
email: "",
|
email: "",
|
||||||
@@ -214,8 +216,8 @@ export default function SystemUsersPanel({
|
|||||||
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), onClick: () => openEdit(row) },
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), disabledReason: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.memberships.some((membership) => membership.is_last_active_owner), onClick: () => setDeactivating(row) }
|
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.memberships.some((membership) => membership.is_last_active_owner), disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canSuspend ? ACCESS_INTERFACE_I18N.updatePermissionRequired : row.memberships.some((membership) => membership.is_last_active_owner) ? ACCESS_INTERFACE_I18N.lastOwnerCannotBeDeactivated : undefined, onClick: () => setDeactivating(row) }
|
||||||
]} /> }],
|
]} /> }],
|
||||||
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
||||||
|
|
||||||
@@ -227,18 +229,20 @@ export default function SystemUsersPanel({
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
success={success}
|
success={success}
|
||||||
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_global_account.18e4df22" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
|
actions={<><DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_global_account.18e4df22" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||||
|
|
||||||
<div className="admin-table-surface"><DataGrid id="admin-system-users-v3" rows={accounts} columns={columns} initialFit="container" getRowKey={(row) => row.account_id} emptyText="i18n:govoplan-access.no_global_accounts_found.29d96a9e" /></div>
|
<div className="admin-table-surface"><DataGrid id="admin-system-users-v3" rows={accounts} columns={columns} initialFit="container" getRowKey={(row) => row.account_id} emptyText="i18n:govoplan-access.no_global_accounts_found.29d96a9e" /></div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|
||||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_global_account.e821f016" : "i18n:govoplan-access.edit_global_account.d13b8485"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canAssignRoles || canManageMemberships))}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_account.0b761f5c"}</Button></>}>
|
<Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_global_account.e821f016" : "i18n:govoplan-access.edit_global_account.d13b8485"} onClose={() => !busy && setEditing(null)} className="" footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: editing === "new" ? canCreate : canUpdate || canSuspend || canAssignRoles || canManageMemberships, complete: Boolean(draft.email.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_account.0b761f5c"}</Button></>}>
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||||
{editing === "new" &&
|
{editing === "new" &&
|
||||||
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
|
<FormField label="i18n:govoplan-access.initial_password.2278be8c" helpContextId="access.admin.system-users.initial-password" helpModuleId="access">
|
||||||
<PasswordField
|
<PasswordField
|
||||||
|
helpContextId="access.admin.system-users.initial-password"
|
||||||
|
helpModuleId="access"
|
||||||
value={draft.password}
|
value={draft.password}
|
||||||
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
@@ -247,20 +251,20 @@ export default function SystemUsersPanel({
|
|||||||
</FormField>
|
</FormField>
|
||||||
}
|
}
|
||||||
<FormField label="i18n:govoplan-access.account_status.8dd86c6d"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.memberships.some((membership) => membership.is_last_active_owner)))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
<FormField label="i18n:govoplan-access.account_status.8dd86c6d"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.memberships.some((membership) => membership.is_last_active_owner)))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
<div className="admin-assignment-grid">
|
<ContentGrid columns={2} spacing="block" collapseAt="wide">
|
||||||
<div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
|
<div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
|
||||||
<div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><AdminSelectionList options={tenants.map((tenant) => ({ id: tenant.id, label: tenant.name, description: tenant.slug, disabled: !canManageMemberships || Boolean(draft.memberships.find((item) => item.tenant_id === tenant.id)?.is_last_active_owner) }))} selected={draft.memberships.map((item) => item.tenant_id)} onChange={setMembershipSelection} /></div>
|
<div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><AdminSelectionList options={tenants.map((tenant) => ({ id: tenant.id, label: tenant.name, description: tenant.slug, disabled: !canManageMemberships || Boolean(draft.memberships.find((item) => item.tenant_id === tenant.id)?.is_last_active_owner) }))} selected={draft.memberships.map((item) => item.tenant_id)} onChange={setMembershipSelection} /></div>
|
||||||
</div>
|
</ContentGrid>
|
||||||
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>}
|
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>}
|
||||||
<p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>
|
<p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.global_account_details.0a0cf240" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.global_account_details.0a0cf240" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||||
{viewing && <dl className="admin-details-grid"><div><dt>i18n:govoplan-access.account.85dfa32c</dt><dd>{viewing.email}</dd></div><div><dt>i18n:govoplan-access.display_name.c7874aaa</dt><dd>{viewing.display_name || "—"}</dd></div><div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-access.last_login.43dab84f</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div><div><dt>i18n:govoplan-access.system_roles.a9461aa6</dt><dd>{joinLabels(viewing.roles)}</dd></div><div><dt>i18n:govoplan-access.tenants.1f7ae776</dt><dd>{viewing.memberships.map((item) => item.tenant_name).join(", ") || "—"}</dd></div></dl>}
|
{viewing && <DescriptionList><DescriptionItem term={<>i18n:govoplan-access.account.85dfa32c</>}>{viewing.email}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.display_name.c7874aaa</>}>{viewing.display_name || "—"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.status.bae7d5be</>}>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.last_login.43dab84f</>}>{formatDateTime(viewing.last_login_at)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.system_roles.a9461aa6</>}>{joinLabels(viewing.roles)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.tenants.1f7ae776</>}>{viewing.memberships.map((item) => item.tenant_name).join(", ") || "—"}</DescriptionItem></DescriptionList>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
<Dialog variant="administration" size="large" open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
||||||
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>}
|
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.value}</code></>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import type { ApiSettings } from "@govoplan/core-webui";
|
|
||||||
import { Button } from "@govoplan/core-webui";
|
|
||||||
import { Card } from "@govoplan/core-webui";
|
|
||||||
import { FormField } from "@govoplan/core-webui";
|
|
||||||
import { fetchTenantSettingsDelta, updateTenantSettings, type TenantSettingsDeltaSections, type TenantSettingsItem } from "../../api/admin";
|
|
||||||
import { AdminPageLayout, AdminSelectionList, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
|
||||||
|
|
||||||
const DELTA_KEY = "access:tenant-settings";
|
|
||||||
|
|
||||||
const fallback: TenantSettingsItem = {
|
|
||||||
id: "",
|
|
||||||
slug: "",
|
|
||||||
name: "",
|
|
||||||
default_locale: "en",
|
|
||||||
available_languages: [
|
|
||||||
{ code: "en", label: "English", native_label: "English" },
|
|
||||||
{ code: "de", label: "German", native_label: "Deutsch" }
|
|
||||||
],
|
|
||||||
system_enabled_language_codes: ["en", "de"],
|
|
||||||
enabled_language_codes: ["en", "de"],
|
|
||||||
settings: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TenantSettingsPanel({
|
|
||||||
settings,
|
|
||||||
canWrite,
|
|
||||||
onAuthRefresh
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}: {settings: ApiSettings;canWrite: boolean;onAuthRefresh: () => Promise<void>;}) {
|
|
||||||
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);
|
|
||||||
|
|
||||||
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 });
|
|
||||||
setDraft(saved);
|
|
||||||
setSavedDraft(saved);
|
|
||||||
resetDeltaWatermark(DELTA_KEY);
|
|
||||||
setSuccess("i18n:govoplan-access.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-access.tenant_general_settings.db1c3ba8"
|
|
||||||
description="i18n:govoplan-access.settings_for_the_active_tenant_context.ad267b86"
|
|
||||||
loading={loading}
|
|
||||||
error={error}
|
|
||||||
success={success}
|
|
||||||
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.default_locale.trim()}>{busy ? "i18n:govoplan-access.saving.ae7e8875" : "i18n:govoplan-access.save_general_settings.5c90f8c4"}</Button></>}>
|
|
||||||
|
|
||||||
<div className="admin-settings-form">
|
|
||||||
<Card title="i18n:govoplan-access.locale.8970f0e6">
|
|
||||||
<FormField label="i18n:govoplan-access.tenant_locale.8fc19914" help="i18n:govoplan-access.used_as_this_tenant_s_locale_default_for_tenant_.cf298b8b">
|
|
||||||
<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">i18n:govoplan-access.tenant_languages_help</p>
|
|
||||||
<dl className="detail-list">
|
|
||||||
<div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{draft.name || "-"}</dd></div>
|
|
||||||
<div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{draft.slug || "-"}</dd></div>
|
|
||||||
<div><dt>i18n:govoplan-access.available.7c62a142</dt><dd>{draft.available_languages.map((item) => item.code.toUpperCase()).join(", ") || "-"}</dd></div>
|
|
||||||
</dl>
|
|
||||||
</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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyTenantSettingsSections(item: TenantSettingsItem, sections: TenantSettingsDeltaSections): TenantSettingsItem {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
...(sections.identity ?? {}),
|
|
||||||
...(sections.locale ?? {}),
|
|
||||||
...(sections.languages ?? {}),
|
|
||||||
...(sections.settings ? { settings: sections.settings } : {})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
|
||||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
|
||||||
import { createTenant, fetchSystemSettings, fetchTenantOwnerCandidates, fetchTenantsDelta, updateTenant, type SystemSettingsItem, type TenantAdminItem, type TenantOwnerCandidate } from "../../api/admin";
|
|
||||||
import { Button } from "@govoplan/core-webui";
|
|
||||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
|
||||||
import { Dialog } from "@govoplan/core-webui";
|
|
||||||
import { FormField } from "@govoplan/core-webui";
|
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
|
||||||
import { AdminIconButton, AdminPageLayout, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
|
||||||
|
|
||||||
type OverrideValue = "inherit" | "allow" | "deny";
|
|
||||||
type TenantDraft = {
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
ownerAccountId: string;
|
|
||||||
description: string;
|
|
||||||
defaultLocale: string;
|
|
||||||
isActive: boolean;
|
|
||||||
customGroups: OverrideValue;
|
|
||||||
customRoles: OverrideValue;
|
|
||||||
apiKeys: OverrideValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyDraft: TenantDraft = {
|
|
||||||
slug: "",
|
|
||||||
name: "",
|
|
||||||
ownerAccountId: "",
|
|
||||||
description: "",
|
|
||||||
defaultLocale: "en",
|
|
||||||
isActive: true,
|
|
||||||
customGroups: "inherit",
|
|
||||||
customRoles: "inherit",
|
|
||||||
apiKeys: "inherit"
|
|
||||||
};
|
|
||||||
|
|
||||||
function fromOverride(value?: boolean | null): OverrideValue {
|
|
||||||
if (value === true) return "allow";
|
|
||||||
if (value === false) return "deny";
|
|
||||||
return "inherit";
|
|
||||||
}
|
|
||||||
|
|
||||||
function toOverride(value: OverrideValue): boolean | null {
|
|
||||||
if (value === "allow") return true;
|
|
||||||
if (value === "deny") return false;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TenantsPanel({
|
|
||||||
settings,
|
|
||||||
auth,
|
|
||||||
canCreate,
|
|
||||||
canUpdate,
|
|
||||||
canSuspend,
|
|
||||||
onAuthRefresh
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;onAuthRefresh: () => Promise<void>;}) {
|
|
||||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
|
||||||
const [systemSettings, setSystemSettings] = useState<SystemSettingsItem | null>(null);
|
|
||||||
const [ownerCandidates, setOwnerCandidates] = useState<TenantOwnerCandidate[]>([]);
|
|
||||||
const 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 || "en",
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
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-access.tenant_value_created_with_value_as_owner.1c18b6fb", { value0: created.name, value1: selectedOwner?.display_name || selectedOwner?.email || "i18n:govoplan-access.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-access.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-access.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 ? "" : "i18n:govoplan-access.custom_groups.453a605c",
|
|
||||||
systemAllowsCustomRoles ? "" : "i18n:govoplan-access.custom_roles.d48dc976",
|
|
||||||
systemAllowsApiKeys ? "" : "i18n:govoplan-access.api_keys.94fcf3c2"].
|
|
||||||
filter(Boolean).join(", ");
|
|
||||||
const columns = useMemo<DataGridColumn<TenantAdminItem>[]>(() => [
|
|
||||||
{ id: "name", header: "i18n:govoplan-access.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-access.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-access.groups.ae9629f4", width: 95, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.groups ?? 0 },
|
|
||||||
{ id: "campaigns", header: "i18n:govoplan-access.campaigns.01a23a28", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.campaigns ?? 0 },
|
|
||||||
{ id: "files", header: "i18n:govoplan-access.files.6ce6c512", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 0 },
|
|
||||||
{ id: "locale", header: "i18n:govoplan-access.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-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
|
||||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
|
||||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
|
||||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !canUpdate, onClick: () => openEdit(row) },
|
|
||||||
{ id: "suspend", label: i18nMessage("i18n:govoplan-access.suspend_value.03a74b32", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.id === activeTenantId, onClick: () => setConfirmSuspend(row) }
|
|
||||||
]} /> }],
|
|
||||||
[activeTenantId, canSuspend, canUpdate]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AdminPageLayout
|
|
||||||
title="i18n:govoplan-access.tenants.1f7ae776"
|
|
||||||
description="i18n:govoplan-access.create_and_govern_tenant_spaces_suspension_retai.1b76d377"
|
|
||||||
loading={loading}
|
|
||||||
error={error}
|
|
||||||
success={success}
|
|
||||||
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_tenant.b8e32af0" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
|
|
||||||
|
|
||||||
<div className="admin-table-surface"><DataGrid id="admin-tenants-v3" rows={tenants} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_tenants_found.72d04cf4" /></div>
|
|
||||||
</AdminPageLayout>
|
|
||||||
|
|
||||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_tenant.4dbd55d9" : "i18n:govoplan-access.edit_tenant.e2ba43f9"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={!(editing === "new" ? canCreate : canUpdate) || busy || !draft.name.trim() || !draft.slug.trim() || editing === "new" && !draft.ownerAccountId}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_tenant.9eb2ac74"}</Button></>}>
|
|
||||||
<div className="admin-form-grid two-columns">
|
|
||||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
|
||||||
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new" || !canCreate} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
|
||||||
{editing === "new" && <FormField label="i18n:govoplan-access.initial_tenant_owner.682291a9"><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-access.value_value.c189e8bc", { value0: candidate.display_name, value1: candidate.email }) : candidate.email}</option>)}</select></FormField>}
|
|
||||||
<FormField label="i18n:govoplan-access.default_locale.b99d021f"><input value={draft.defaultLocale} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, defaultLocale: event.target.value })} /></FormField>
|
|
||||||
{editing !== "new" && <FormField label="i18n:govoplan-access.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={!canSuspend} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.suspended.794696a7</option></select></FormField>}
|
|
||||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={4} value={draft.description} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
|
||||||
</div>
|
|
||||||
<h3>i18n:govoplan-access.system_governance_overrides.97cdf3ce</h3>
|
|
||||||
<div className="admin-form-grid two-columns">
|
|
||||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomGroups} label="i18n:govoplan-access.custom_tenant_groups.570ee603" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
|
|
||||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomRoles} label="i18n:govoplan-access.custom_tenant_roles.a738c37c" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
|
|
||||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsApiKeys} label="i18n:govoplan-access.tenant_api_keys.4b1d81f8" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
|
|
||||||
</div>
|
|
||||||
<p className="muted small-note">i18n:govoplan-access.inherit_follows_the_current_system_setting_expli.60d4d868</p>
|
|
||||||
{systemDeniedGovernance && <p className="muted small-note">i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a {systemDeniedGovernance} i18n:govoplan-access.because_the_current_system_setting_denies_it.3f59c244</p>}
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.tenant_details.5976ba72" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
|
||||||
{viewing && <><dl className="admin-details-grid">
|
|
||||||
<div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div>
|
|
||||||
<div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.suspended.794696a7"}</dd></div><div><dt>i18n:govoplan-access.default_locale.b99d021f</dt><dd>{viewing.default_locale}</dd></div>
|
|
||||||
<div><dt>i18n:govoplan-access.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-access.updated.f2f8570d</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
|
|
||||||
<div><dt>i18n:govoplan-access.custom_groups.1f7b7c8f</dt><dd>{viewing.effective_governance.allow_custom_groups ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_custom_groups)})</dd></div>
|
|
||||||
<div><dt>i18n:govoplan-access.custom_roles.e78ef63d</dt><dd>{viewing.effective_governance.allow_custom_roles ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_custom_roles)})</dd></div>
|
|
||||||
<div><dt>i18n:govoplan-access.api_keys.94fcf3c2</dt><dd>{viewing.effective_governance.allow_api_keys ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_api_keys)})</dd></div>
|
|
||||||
<div><dt>i18n:govoplan-access.objects.72a83add</dt><dd>{viewing.counts.users ?? 0} i18n:govoplan-access.users.81651889 {viewing.counts.groups ?? 0} i18n:govoplan-access.groups.07551586 {viewing.counts.campaigns ?? 0} i18n:govoplan-access.campaigns.2282ffeb {viewing.counts.files ?? 0} files</dd></div>
|
|
||||||
</dl>{viewing.description && <p>{viewing.description}</p>}</>}
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<ConfirmDialog open={Boolean(confirmSuspend)} title="i18n:govoplan-access.suspend_tenant.151d283a" message={i18nMessage("i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78", { value0: confirmSuspend?.name })} confirmLabel="i18n:govoplan-access.suspend_tenant.151d283a" tone="danger" busy={busy} onCancel={() => setConfirmSuspend(null)} onConfirm={() => void suspend()} />
|
|
||||||
</>);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function GovernanceSelect({ label, value, onChange, disabled = false, allowDisabled = false }: {label: string;value: OverrideValue;onChange: (value: OverrideValue) => void;disabled?: boolean;allowDisabled?: boolean;}) {
|
|
||||||
return <FormField label={label}><select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as OverrideValue)}><option value="inherit">i18n:govoplan-access.inherit_system_setting.7f125156</option><option value="allow" disabled={allowDisabled}>i18n:govoplan-access.allow_when_system_allows.4c5178cb</option><option value="deny">i18n:govoplan-access.explicitly_deny.17ad945a</option></select></FormField>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { ContentGrid, DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { KeyRound, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
import { KeyRound, MonitorSmartphone, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||||
import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUserAccessExplanation, fetchUsersDelta, updateUser, type AccessRoleSourceItem, type FunctionFactExplanationItem, type GroupSummary, type RoleSummary, type UserAccessExplanationResponse, type UserAdminItem } from "../../api/admin";
|
import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUserAccessExplanation, fetchUsersDelta, updateUser, type AccessRoleSourceItem, type FunctionFactExplanationItem, type GroupSummary, type RoleSummary, type UserAccessExplanationResponse, type UserAdminItem } from "../../api/admin";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
@@ -10,9 +11,19 @@ import { PasswordField } from "@govoplan/core-webui";
|
|||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
|
||||||
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
import {
|
||||||
|
fetchAdminUserSessions,
|
||||||
|
revokeAdminUserSession,
|
||||||
|
type AccountSession
|
||||||
|
} from "../../api/sessions";
|
||||||
|
import {
|
||||||
|
ACCESS_INTERFACE_I18N,
|
||||||
|
ACCESS_WORKFLOW_DOCUMENTATION,
|
||||||
|
saveDisabledReason
|
||||||
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
const emptyDraft = {
|
const emptyDraft = {
|
||||||
email: "",
|
email: "",
|
||||||
@@ -24,7 +35,7 @@ const emptyDraft = {
|
|||||||
roleIds: [] as string[]
|
roleIds: [] as string[]
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSuspend, canManageGroups, canAssignRoles, onAuthRefresh
|
export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSuspend, canManageGroups, canAssignRoles, canRevokeSessions, onAuthRefresh
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -33,7 +44,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canManageGroups: boolean;canAssignRoles: boolean;onAuthRefresh: () => Promise<void>;}) {
|
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;canManageGroups: boolean;canAssignRoles: boolean;canRevokeSessions: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||||
const [users, setUsers] = useState<UserAdminItem[]>([]);
|
const [users, setUsers] = useState<UserAdminItem[]>([]);
|
||||||
const [groups, setGroups] = useState<GroupSummary[]>([]);
|
const [groups, setGroups] = useState<GroupSummary[]>([]);
|
||||||
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
const [roles, setRoles] = useState<RoleSummary[]>([]);
|
||||||
@@ -47,6 +58,12 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
const [accessExplanation, setAccessExplanation] = useState<UserAccessExplanationResponse | null>(null);
|
const [accessExplanation, setAccessExplanation] = useState<UserAccessExplanationResponse | null>(null);
|
||||||
const [accessExplanationLoading, setAccessExplanationLoading] = useState(false);
|
const [accessExplanationLoading, setAccessExplanationLoading] = useState(false);
|
||||||
const [deactivating, setDeactivating] = useState<UserAdminItem | null>(null);
|
const [deactivating, setDeactivating] = useState<UserAdminItem | null>(null);
|
||||||
|
const [sessionUser, setSessionUser] = useState<UserAdminItem | null>(null);
|
||||||
|
const [accountSessions, setAccountSessions] = useState<AccountSession[]>([]);
|
||||||
|
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||||
|
const [sessionError, setSessionError] = useState("");
|
||||||
|
const [revokingSession, setRevokingSession] = useState<AccountSession | null>(null);
|
||||||
|
const [reauthorizationPassword, setReauthorizationPassword] = useState("");
|
||||||
const [draft, setDraft] = useState(emptyDraft);
|
const [draft, setDraft] = useState(emptyDraft);
|
||||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||||
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;password: string;} | null>(null);
|
const [temporaryPassword, setTemporaryPassword] = useState<{email: string;password: string;} | null>(null);
|
||||||
@@ -180,6 +197,67 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadUserSessions(user: UserAdminItem) {
|
||||||
|
setSessionsLoading(true);
|
||||||
|
setSessionError("");
|
||||||
|
try {
|
||||||
|
const response = await fetchAdminUserSessions(settings, user.id);
|
||||||
|
setAccountSessions(response.sessions);
|
||||||
|
} catch (err) {
|
||||||
|
setSessionError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setSessionsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUserSessions(user: UserAdminItem) {
|
||||||
|
setSessionUser(user);
|
||||||
|
setAccountSessions([]);
|
||||||
|
setRevokingSession(null);
|
||||||
|
setReauthorizationPassword("");
|
||||||
|
void loadUserSessions(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeSelectedSession() {
|
||||||
|
if (!sessionUser || !revokingSession || !reauthorizationPassword) return;
|
||||||
|
setBusy(true);
|
||||||
|
setSessionError("");
|
||||||
|
try {
|
||||||
|
await revokeAdminUserSession(
|
||||||
|
settings,
|
||||||
|
sessionUser.id,
|
||||||
|
revokingSession.id,
|
||||||
|
reauthorizationPassword
|
||||||
|
);
|
||||||
|
setSuccess("i18n:govoplan-access.session_revoked.5e551008");
|
||||||
|
setRevokingSession(null);
|
||||||
|
setReauthorizationPassword("");
|
||||||
|
await loadUserSessions(sessionUser);
|
||||||
|
} catch (err) {
|
||||||
|
setSessionError(adminErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionColumns = useMemo<DataGridColumn<AccountSession>[]>(() => [
|
||||||
|
{ id: "client", header: "i18n:govoplan-access.device_or_client.5e551002", width: "minmax(220px, 1fr)", fill: true, value: (row) => row.client || "", render: (row) => <div><strong>{row.current ? "i18n:govoplan-access.current_session.5e551003" : "i18n:govoplan-access.other_session.5e551004"}</strong><div className="muted small-note">{row.client || "i18n:govoplan-access.client_details_unavailable.5e551005"}</div></div> },
|
||||||
|
{ id: "last_seen", header: "i18n:govoplan-access.last_seen.5e551006", width: 180, value: (row) => row.last_seen_at || "", render: (row) => formatDateTime(row.last_seen_at) },
|
||||||
|
{ id: "created", header: "i18n:govoplan-access.created.accf40c8", width: 180, value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) },
|
||||||
|
{ id: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, value: (row) => row.expires_at, render: (row) => formatDateTime(row.expires_at) },
|
||||||
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 96, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{
|
||||||
|
id: "revoke-session",
|
||||||
|
label: "i18n:govoplan-access.revoke_session.5e551007",
|
||||||
|
variant: "danger",
|
||||||
|
helpContextId: "access.sessions.action.revoke",
|
||||||
|
helpModuleId: "access",
|
||||||
|
applicable: !row.current,
|
||||||
|
disabled: busy || !canRevokeSessions,
|
||||||
|
disabledReason: !canRevokeSessions ? "i18n:govoplan-access.session_revocation_permission_required.5e551016" : busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined,
|
||||||
|
onClick: () => { setRevokingSession(row); setReauthorizationPassword(""); setSessionError(""); }
|
||||||
|
}]} /> }
|
||||||
|
], [busy, canRevokeSessions]);
|
||||||
|
|
||||||
const columns = useMemo<DataGridColumn<UserAdminItem>[]>(() => [
|
const columns = useMemo<DataGridColumn<UserAdminItem>[]>(() => [
|
||||||
{ id: "user", header: "i18n:govoplan-access.user.9f8a2389", width: "minmax(230px, 1fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
|
{ id: "user", header: "i18n:govoplan-access.user.9f8a2389", width: "minmax(230px, 1fr)", minWidth: 210, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.display_name || ""} ${row.email}`, render: (row) => <div><strong>{row.display_name || row.email}</strong><div className="muted small-note">{row.email}</div></div> },
|
||||||
{ id: "groups", header: "i18n:govoplan-access.groups.ae9629f4", width: 210, minWidth: 150, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.groups) },
|
{ id: "groups", header: "i18n:govoplan-access.groups.ae9629f4", width: 210, minWidth: 150, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.groups) },
|
||||||
@@ -189,25 +267,28 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 190, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 190, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
|
{ id: "sessions", label: i18nMessage("i18n:govoplan-access.inspect_sessions_for_value.5e551017", { value0: row.email }), icon: <MonitorSmartphone />, onClick: () => openUserSessions(row) },
|
||||||
{ id: "explain", label: i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email }), icon: <KeyRound />, onClick: () => void openAccessExplanation(row) },
|
{ id: "explain", label: i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email }), icon: <KeyRound />, onClick: () => void openAccessExplanation(row) },
|
||||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canManageGroups || canAssignRoles), onClick: () => openEdit(row) },
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canManageGroups || canAssignRoles), disabledReason: !(canUpdate || canSuspend || canManageGroups || canAssignRoles) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.is_last_active_owner, onClick: () => setDeactivating(row) }
|
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.is_last_active_owner, disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canSuspend ? ACCESS_INTERFACE_I18N.updatePermissionRequired : row.is_last_active_owner ? ACCESS_INTERFACE_I18N.lastOwnerCannotBeDeactivated : undefined, onClick: () => setDeactivating(row) }
|
||||||
]} /> }],
|
]} /> }],
|
||||||
[canAssignRoles, canManageGroups, canSuspend, canUpdate, settings]);
|
[canAssignRoles, canManageGroups, canRevokeSessions, canSuspend, canUpdate, settings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminPageLayout title="i18n:govoplan-access.tenant_users.cb800b38" description="i18n:govoplan-access.manage_memberships_groups_and_direct_roles_in_th.25af86bb" loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_tenant_user.36f37ce7" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
|
<AdminPageLayout title="i18n:govoplan-access.tenant_users.cb800b38" description="i18n:govoplan-access.manage_memberships_groups_and_direct_roles_in_th.25af86bb" loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_tenant_user.36f37ce7" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} disabledReason={!canCreate ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||||
<div className="admin-table-surface"><DataGrid id="admin-users-v3" rows={users} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_tenant_users_found.74bb615f" /></div>
|
<div className="admin-table-surface"><DataGrid id="admin-users-v3" rows={users} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_tenant_users_found.74bb615f" /></div>
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
|
|
||||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.add_tenant_user.36f37ce7" : "i18n:govoplan-access.edit_tenant_user.99121a61"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.email.trim() || (editing === "new" ? !canCreate : !(canUpdate || canSuspend || canManageGroups || canAssignRoles))}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_user.0d071b89"}</Button></>}>
|
<Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.add_tenant_user.36f37ce7" : "i18n:govoplan-access.edit_tenant_user.99121a61"} onClose={() => !busy && setEditing(null)} className="" footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: editing === "new" ? canCreate : canUpdate || canSuspend || canManageGroups || canAssignRoles, complete: Boolean(draft.email.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_user.0d071b89"}</Button></>}>
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||||
{editing === "new" &&
|
{editing === "new" &&
|
||||||
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
|
<FormField label="i18n:govoplan-access.initial_password.2278be8c" helpContextId="access.admin.tenant-users.initial-password" helpModuleId="access">
|
||||||
<PasswordField
|
<PasswordField
|
||||||
|
helpContextId="access.admin.tenant-users.initial-password"
|
||||||
|
helpModuleId="access"
|
||||||
value={draft.password}
|
value={draft.password}
|
||||||
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
@@ -216,36 +297,52 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
</FormField>
|
</FormField>
|
||||||
}
|
}
|
||||||
<FormField label="i18n:govoplan-access.membership_status.b77fc732"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
<FormField label="i18n:govoplan-access.membership_status.b77fc732"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
{editing === "new" && <ToggleSwitch label="i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3" checked={draft.passwordResetRequired} onChange={(passwordResetRequired) => setDraft({ ...draft, passwordResetRequired })} />}
|
{editing === "new" && <ToggleSwitch label="i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3" checked={draft.passwordResetRequired} helpContextId="access.admin.tenant-users.require-password-change" helpModuleId="access" onChange={(passwordResetRequired) => setDraft({ ...draft, passwordResetRequired })} />}
|
||||||
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f</p>}
|
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f</p>}
|
||||||
<div className="admin-assignment-grid">
|
<ContentGrid columns={2} spacing="block" collapseAt="wide">
|
||||||
<div><span className="form-label">i18n:govoplan-access.groups.ae9629f4</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="i18n:govoplan-access.no_groups_exist_yet.9cd029f6" /></div>
|
<div><span className="form-label">i18n:govoplan-access.groups.ae9629f4</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="i18n:govoplan-access.no_groups_exist_yet.9cd029f6" /></div>
|
||||||
<div><span className="form-label">i18n:govoplan-access.direct_roles.c4db7156</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div>
|
<div><span className="form-label">i18n:govoplan-access.direct_roles.c4db7156</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div>
|
||||||
</div>
|
</ContentGrid>
|
||||||
<p className="muted small-note">i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16</p>
|
<p className="muted small-note">i18n:govoplan-access.group_roles_and_direct_roles_are_combined_the_ba.a43c2f16</p>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.tenant_user_details.fbab9079" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.tenant_user_details.fbab9079" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||||
{viewing && <dl className="admin-details-grid">
|
{viewing && <DescriptionList>
|
||||||
<div><dt>i18n:govoplan-access.user.9f8a2389</dt><dd>{viewing.display_name || viewing.email}</dd></div><div><dt>i18n:govoplan-access.email.84add5b2</dt><dd>{viewing.email}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.user.9f8a2389</>}>{viewing.display_name || viewing.email}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.email.84add5b2</>}>{viewing.email}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.membership.53bc9670</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div><div><dt>i18n:govoplan-access.global_account.e1b00cf5</dt><dd>{viewing.account_is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.membership.53bc9670</>}>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.global_account.e1b00cf5</>}>{viewing.account_is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.groups.ae9629f4</dt><dd>{joinLabels(viewing.groups)}</dd></div><div><dt>i18n:govoplan-access.direct_roles.c4db7156</dt><dd>{joinLabels(viewing.roles)}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.groups.ae9629f4</>}>{joinLabels(viewing.groups)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.direct_roles.c4db7156</>}>{joinLabels(viewing.roles)}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.effective_permissions.17c0fe8a</dt><dd>{hasTenantWildcard(viewing.effective_scopes) ? "i18n:govoplan-access.all_tenant_permissions.cca8b6e4" : viewing.effective_scopes.join(", ") || "i18n:govoplan-access.none.6eef6648"}</dd></div><div><dt>i18n:govoplan-access.last_login.43dab84f</dt><dd>{formatDateTime(viewing.last_login_at)}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.effective_permissions.17c0fe8a</>}>{hasTenantWildcard(viewing.effective_scopes) ? "i18n:govoplan-access.all_tenant_permissions.cca8b6e4" : viewing.effective_scopes.join(", ") || "i18n:govoplan-access.none.6eef6648"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.last_login.43dab84f</>}>{formatDateTime(viewing.last_login_at)}</DescriptionItem>
|
||||||
</dl>}
|
</DescriptionList>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(explaining)} title="i18n:govoplan-access.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setExplaining(null); setAccessExplanation(null); } }} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => { setExplaining(null); setAccessExplanation(null); }} disabled={accessExplanationLoading}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
<Dialog variant="administration" size="wide" open={Boolean(sessionUser && !revokingSession)} title="i18n:govoplan-access.user_sessions.5e551018" onClose={() => !busy && setSessionUser(null)} className="" footer={<><Button onClick={() => sessionUser && void loadUserSessions(sessionUser)} disabled={sessionsLoading || busy} disabledReason={sessionsLoading || busy ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><Button onClick={() => setSessionUser(null)} disabled={busy}>i18n:govoplan-access.close.bbfa773e</Button></>}>
|
||||||
|
{sessionError && <p className="admin-protection-note">{sessionError}</p>}
|
||||||
|
{sessionUser && <>
|
||||||
|
<p className="muted small-note">{sessionUser.display_name || sessionUser.email} · {sessionUser.email}</p>
|
||||||
|
<div className="admin-table-surface"><DataGrid id="admin-user-sessions-v1" rows={accountSessions} columns={sessionColumns} initialFit="container" getRowKey={(row) => row.id} loading={sessionsLoading} emptyText="i18n:govoplan-access.no_active_sessions.5e551013" /></div>
|
||||||
|
</>}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog variant="administration" size="large" open={Boolean(revokingSession)} title="i18n:govoplan-access.revoke_session.5e551007" helpContextId="access.sessions.action.revoke" helpModuleId="access" onClose={() => !busy && setRevokingSession(null)} className="" footer={<><Button onClick={() => setRevokingSession(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="danger" helpContextId="access.sessions.action.revoke" helpModuleId="access" onClick={() => void revokeSelectedSession()} disabled={busy || !reauthorizationPassword} disabledReason={!reauthorizationPassword ? "i18n:govoplan-access.current_password_required.5e551019" : busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.revoke_session.5e551007</Button></>}>
|
||||||
|
{sessionError && <p className="admin-protection-note">{sessionError}</p>}
|
||||||
|
<p>i18n:govoplan-access.admin_session_revocation_confirmation.5e551020</p>
|
||||||
|
<FormField label="i18n:govoplan-access.current_password.5e551021" helpContextId="access.sessions.field.current-password" helpModuleId="access">
|
||||||
|
<PasswordField helpContextId="access.sessions.field.current-password" helpModuleId="access" value={reauthorizationPassword} autoComplete="current-password" onValueChange={setReauthorizationPassword} />
|
||||||
|
</FormField>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog variant="administration" size="wide" open={Boolean(explaining)} title="i18n:govoplan-access.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setExplaining(null); setAccessExplanation(null); } }} className="" footer={<Button onClick={() => { setExplaining(null); setAccessExplanation(null); }} disabled={accessExplanationLoading} disabledReason={accessExplanationLoading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||||
{accessExplanationLoading && <p className="muted small-note">i18n:govoplan-access.loading_access_explanation.04a7c934</p>}
|
{accessExplanationLoading && <p className="muted small-note">i18n:govoplan-access.loading_access_explanation.04a7c934</p>}
|
||||||
{accessExplanation && <>
|
{accessExplanation && <>
|
||||||
<dl className="admin-details-grid">
|
<DescriptionList>
|
||||||
<div><dt>i18n:govoplan-access.user.9f8a2389</dt><dd>{accessExplanation.user.display_name || accessExplanation.user.email}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.user.9f8a2389</>}>{accessExplanation.user.display_name || accessExplanation.user.email}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.account.85dfa32c</dt><dd>{accessExplanation.user.account_id}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.account.85dfa32c</>}>{accessExplanation.user.account_id}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.role_sources.6f42a672</dt><dd>{accessExplanation.role_sources.length}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.role_sources.6f42a672</>}>{accessExplanation.role_sources.length}</DescriptionItem>
|
||||||
<div><dt>i18n:govoplan-access.function_facts.848b32cc</dt><dd>{accessExplanation.function_facts.length}</dd></div>
|
<DescriptionItem term={<>i18n:govoplan-access.function_facts.848b32cc</>}>{accessExplanation.function_facts.length}</DescriptionItem>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
<h3>i18n:govoplan-access.role_sources.6f42a672</h3>
|
<h3>i18n:govoplan-access.role_sources.6f42a672</h3>
|
||||||
{accessExplanation.role_sources.length ? <div className="admin-assignment-grid">
|
{accessExplanation.role_sources.length ? <ContentGrid columns={2} spacing="block" collapseAt="wide">
|
||||||
{accessExplanation.role_sources.map((source) => <div key={sourceKey(source)}>
|
{accessExplanation.role_sources.map((source) => <div key={sourceKey(source)}>
|
||||||
<strong>{source.role_name}</strong>
|
<strong>{source.role_name}</strong>
|
||||||
<div className="muted small-note">
|
<div className="muted small-note">
|
||||||
@@ -257,19 +354,16 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
<AccessExplanationLinks organizationHref={organizationHrefForSource(source)} idmHref={idmHrefForSource(source)} />
|
<AccessExplanationLinks organizationHref={organizationHrefForSource(source)} idmHref={idmHrefForSource(source)} />
|
||||||
<div className="admin-scope-list">{source.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div>
|
<div className="admin-scope-list">{source.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
</div> : <p className="muted small-note">i18n:govoplan-access.no_role_sources_found.91013aa5</p>}
|
</ContentGrid> : <p className="muted small-note">i18n:govoplan-access.no_role_sources_found.91013aa5</p>}
|
||||||
<h3>i18n:govoplan-access.function_facts.848b32cc</h3>
|
<h3>i18n:govoplan-access.function_facts.848b32cc</h3>
|
||||||
{accessExplanation.function_facts.length ? <dl className="admin-details-grid">
|
{accessExplanation.function_facts.length ? <DescriptionList>
|
||||||
{accessExplanation.function_facts.map((fact) => <div key={fact.assignment_id}>
|
{accessExplanation.function_facts.map((fact) => <DescriptionItem key={fact.assignment_id} term={fact.function_name || fact.function_id}>
|
||||||
<dt>{fact.function_name || fact.function_id}</dt>
|
|
||||||
<dd>
|
|
||||||
<span>i18n:govoplan-access.organization_unit.9832b383</span>: {fact.organization_unit_name || fact.organization_unit_id}<br />
|
<span>i18n:govoplan-access.organization_unit.9832b383</span>: {fact.organization_unit_name || fact.organization_unit_id}<br />
|
||||||
<span>i18n:govoplan-access.assignment_source.5fac1f72</span>: {fact.assignment_source}<br />
|
<span>i18n:govoplan-access.assignment_source.5fac1f72</span>: {fact.assignment_source}<br />
|
||||||
<span>i18n:govoplan-access.mapped_roles.504d9ff9</span>: {factRoles(fact)}
|
<span>i18n:govoplan-access.mapped_roles.504d9ff9</span>: {factRoles(fact)}
|
||||||
<AccessExplanationLinks organizationHref={organizationHrefForFact(fact)} idmHref={idmHrefForFact(fact)} />
|
<AccessExplanationLinks organizationHref={organizationHrefForFact(fact)} idmHref={idmHrefForFact(fact)} />
|
||||||
</dd>
|
</DescriptionItem>)}
|
||||||
</div>)}
|
</DescriptionList> : <p className="muted small-note">i18n:govoplan-access.no_function_facts_found.b2ecee17</p>}
|
||||||
</dl> : <p className="muted small-note">i18n:govoplan-access.no_function_facts_found.b2ecee17</p>}
|
|
||||||
<h3>i18n:govoplan-access.effective_permissions.17c0fe8a</h3>
|
<h3>i18n:govoplan-access.effective_permissions.17c0fe8a</h3>
|
||||||
<div className="admin-scope-list">
|
<div className="admin-scope-list">
|
||||||
{accessExplanation.scopes.map((scope) => <code key={scope.scope} title={scope.sources.map((source) => source.role_name).join(", ")}>{scope.scope}</code>)}
|
{accessExplanation.scopes.map((scope) => <code key={scope.scope} title={scope.sources.map((source) => source.role_name).join(", ")}>{scope.scope}</code>)}
|
||||||
@@ -277,7 +371,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
</>}
|
</>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="admin-dialog" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
<Dialog variant="administration" size="large" open={Boolean(temporaryPassword)} title="i18n:govoplan-access.temporary_password.62d60628" onClose={() => setTemporaryPassword(null)} className="" footer={<Button variant="primary" onClick={() => setTemporaryPassword(null)}>i18n:govoplan-access.i_have_recorded_it.7522da18</Button>}>
|
||||||
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.password}</code><p className="muted small-note">i18n:govoplan-access.transmit_it_through_a_separate_secure_channel.ab892c7b</p></>}
|
{temporaryPassword && <><p>i18n:govoplan-access.this_is_shown_once_for.b0f0f526 <strong>{temporaryPassword.email}</strong>.</p><code className="admin-secret">{temporaryPassword.password}</code><p className="muted small-note">i18n:govoplan-access.transmit_it_through_a_separate_secure_channel.ab892c7b</p></>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export const ACCESS_WORKFLOW_DOCUMENTATION = {
|
||||||
|
topicId: "access.workflow.grant-user-access",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const ACCESS_REFERENCE_DOCUMENTATION = {
|
||||||
|
topicId: "access.reference.admin-access-fields",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const FUNCTION_MAPPING_DOCUMENTATION = {
|
||||||
|
topicId: "access.reference.external-function-role-mappings",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const CREDENTIAL_DOCUMENTATION = {
|
||||||
|
contextId: "access.credentials",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const FILE_CONNECTOR_DOCUMENTATION = {
|
||||||
|
topicId: "files.governed-connectors-and-provenance",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const MAIL_PROFILE_DOCUMENTATION = {
|
||||||
|
topicId: "mail.profiles-and-policy",
|
||||||
|
documentationType: "admin"
|
||||||
|
} satisfies DocumentationHelpReference;
|
||||||
|
|
||||||
|
export const ACCESS_INTERFACE_I18N = {
|
||||||
|
loading: "i18n:govoplan-access.administration_data_is_loading.4af2c001",
|
||||||
|
createPermissionRequired: "i18n:govoplan-access.create_permission_is_required.4af2c002",
|
||||||
|
updatePermissionRequired: "i18n:govoplan-access.update_or_assignment_permission_is_required.4af2c003",
|
||||||
|
writePermissionRequired: "i18n:govoplan-access.write_permission_is_required.4af2c004",
|
||||||
|
completeRequiredFields: "i18n:govoplan-access.complete_the_required_fields_before_saving.4af2c005",
|
||||||
|
selectUserAndScopes: "i18n:govoplan-access.select_an_account_and_at_least_one_scope.4af2c006",
|
||||||
|
selectAssignableRole: "i18n:govoplan-access.select_an_assignable_role_before_creating_a_mapping.4af2c007",
|
||||||
|
assignedObjectCannotBeDeleted: "i18n:govoplan-access.remove_existing_assignments_before_deleting.4af2c008",
|
||||||
|
lastOwnerCannotBeDeactivated: "i18n:govoplan-access.assign_another_operational_owner_before_deactivating.4af2c009",
|
||||||
|
systemManagedObject: "i18n:govoplan-access.this_definition_is_managed_by_the_system.4af2c010",
|
||||||
|
operationInProgress: "i18n:govoplan-access.an_access_administration_operation_is_in_progress.4af2c011",
|
||||||
|
requiredAction: "i18n:govoplan-access.required_action.4af2c012",
|
||||||
|
actor: "i18n:govoplan-access.who_can_fix_it.4af2c013",
|
||||||
|
destinationLabel: "i18n:govoplan-access.where_to_go.4af2c014",
|
||||||
|
requestAdministrationAccess: "i18n:govoplan-access.request_an_administrative_role_for_the_required_scope.4af2c015",
|
||||||
|
accessAdministrator: "i18n:govoplan-access.a_tenant_owner_or_system_access_administrator.4af2c016",
|
||||||
|
accessAdministration: "i18n:govoplan-access.admin_users_groups_and_roles.4af2c017",
|
||||||
|
installFiles: "i18n:govoplan-access.install_and_enable_the_files_module.4af2c018",
|
||||||
|
installMail: "i18n:govoplan-access.install_and_enable_the_mail_module.4af2c019",
|
||||||
|
systemModuleAdministrator: "i18n:govoplan-access.a_system_module_administrator.4af2c020",
|
||||||
|
moduleManagement: "i18n:govoplan-access.admin_modules.4af2c021",
|
||||||
|
reusableCredentials: "i18n:govoplan-access.reusable_credentials.4af2c022",
|
||||||
|
systemCredentials: "i18n:govoplan-access.system_credentials.4af2c023",
|
||||||
|
tenantCredentials: "i18n:govoplan-access.tenant_credentials.4af2c024",
|
||||||
|
groupCredentials: "i18n:govoplan-access.group_credentials.4af2c025",
|
||||||
|
userCredentials: "i18n:govoplan-access.user_credentials.4af2c026",
|
||||||
|
systemCredentialDescription: "i18n:govoplan-access.instance_credentials_can_be_inherited_and_governed.4af2c027",
|
||||||
|
tenantCredentialDescription: "i18n:govoplan-access.tenant_credentials_can_be_shared_with_permitted_modules.4af2c028",
|
||||||
|
groupCredentialDescription: "i18n:govoplan-access.reusable_credentials_owned_by_the_selected_group.4af2c029",
|
||||||
|
userCredentialDescription: "i18n:govoplan-access.reusable_credentials_owned_by_the_selected_user.4af2c030"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function saveDisabledReason({
|
||||||
|
busy,
|
||||||
|
permitted,
|
||||||
|
complete
|
||||||
|
}: {
|
||||||
|
busy: boolean;
|
||||||
|
permitted: boolean;
|
||||||
|
complete: boolean;
|
||||||
|
}): string | undefined {
|
||||||
|
if (busy) return ACCESS_INTERFACE_I18N.operationInProgress;
|
||||||
|
if (!permitted) return ACCESS_INTERFACE_I18N.writePermissionRequired;
|
||||||
|
if (!complete) return ACCESS_INTERFACE_I18N.completeRequiredFields;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
ConfirmDialog,
|
||||||
|
ContentGrid,
|
||||||
|
DataGrid,
|
||||||
|
DismissibleAlert,
|
||||||
|
PageActionBar,
|
||||||
|
StatusBadge,
|
||||||
|
TableActionGroup,
|
||||||
|
formatAdminDateTime,
|
||||||
|
type ApiSettings,
|
||||||
|
type AuthInfo,
|
||||||
|
type DataGridColumn
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
fetchAccountSessions,
|
||||||
|
revokeAccountSession,
|
||||||
|
revokeOtherAccountSessions,
|
||||||
|
type AccountSession
|
||||||
|
} from "../../api/sessions";
|
||||||
|
|
||||||
|
export default function SessionSettingsPanel({
|
||||||
|
settings,
|
||||||
|
auth
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
auth: AuthInfo;
|
||||||
|
}) {
|
||||||
|
const [sessions, setSessions] = useState<AccountSession[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
const [revoking, setRevoking] = useState<AccountSession | null>(null);
|
||||||
|
const [revokingOthers, setRevokingOthers] = useState(false);
|
||||||
|
const interactive = auth.principal?.auth_method === "session";
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!interactive) {
|
||||||
|
setSessions([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await fetchAccountSessions(settings);
|
||||||
|
setSessions(response.sessions);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : String(reason));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [
|
||||||
|
auth.principal?.session_id,
|
||||||
|
settings.accessToken,
|
||||||
|
settings.apiBaseUrl,
|
||||||
|
settings.apiKey
|
||||||
|
]);
|
||||||
|
|
||||||
|
const columns = useMemo<DataGridColumn<AccountSession>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "client",
|
||||||
|
header: "i18n:govoplan-access.device_or_client.5e551002",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
minWidth: 180,
|
||||||
|
fill: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
value: (row) => row.client || "",
|
||||||
|
render: (row) => (
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{row.current
|
||||||
|
? "i18n:govoplan-access.current_session.5e551003"
|
||||||
|
: "i18n:govoplan-access.other_session.5e551004"}
|
||||||
|
</strong>
|
||||||
|
<div className="muted small-note">
|
||||||
|
{row.client || "i18n:govoplan-access.client_details_unavailable.5e551005"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "i18n:govoplan-access.status.bae7d5be",
|
||||||
|
width: 120,
|
||||||
|
value: (row) => row.status,
|
||||||
|
render: (row) => <StatusBadge status={row.status} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "last_seen",
|
||||||
|
header: "i18n:govoplan-access.last_seen.5e551006",
|
||||||
|
width: 180,
|
||||||
|
sortable: true,
|
||||||
|
value: (row) => row.last_seen_at || "",
|
||||||
|
render: (row) => formatAdminDateTime(row.last_seen_at)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "created",
|
||||||
|
header: "i18n:govoplan-access.created.accf40c8",
|
||||||
|
width: 180,
|
||||||
|
sortable: true,
|
||||||
|
value: (row) => row.created_at,
|
||||||
|
render: (row) => formatAdminDateTime(row.created_at)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "expires",
|
||||||
|
header: "i18n:govoplan-access.expires.a99be3da",
|
||||||
|
width: 180,
|
||||||
|
sortable: true,
|
||||||
|
value: (row) => row.expires_at,
|
||||||
|
render: (row) => formatAdminDateTime(row.expires_at)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "i18n:govoplan-access.actions.c3cd636a",
|
||||||
|
width: 96,
|
||||||
|
sticky: "end",
|
||||||
|
align: "right",
|
||||||
|
render: (row) => (
|
||||||
|
<TableActionGroup
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
id: "revoke",
|
||||||
|
label: "i18n:govoplan-access.revoke_session.5e551007",
|
||||||
|
variant: "danger",
|
||||||
|
helpContextId: "access.sessions.action.revoke",
|
||||||
|
helpModuleId: "access",
|
||||||
|
applicable: !row.current,
|
||||||
|
disabled: busy,
|
||||||
|
disabledReason: busy
|
||||||
|
? "i18n:govoplan-access.an_access_administration_operation_is_in_progress.4af2c011"
|
||||||
|
: undefined,
|
||||||
|
onClick: () => setRevoking(row)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[busy]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function revokeOne() {
|
||||||
|
if (!revoking) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await revokeAccountSession(settings, revoking.id);
|
||||||
|
setRevoking(null);
|
||||||
|
setSuccess("i18n:govoplan-access.session_revoked.5e551008");
|
||||||
|
await load();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : String(reason));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeOthers() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await revokeOtherAccountSessions(settings);
|
||||||
|
setRevokingOthers(false);
|
||||||
|
setSuccess(
|
||||||
|
response.revoked_count
|
||||||
|
? "i18n:govoplan-access.other_sessions_revoked.5e551009"
|
||||||
|
: "i18n:govoplan-access.no_other_active_sessions.5e551010"
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : String(reason));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!interactive) {
|
||||||
|
return (
|
||||||
|
<ContentGrid columns={1} collapseAt="workspace" className="">
|
||||||
|
<Card title="i18n:govoplan-access.sessions_and_devices.5e551001">
|
||||||
|
<p>i18n:govoplan-access.browser_session_required.5e551011</p>
|
||||||
|
</Card>
|
||||||
|
</ContentGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContentGrid columns={1} collapseAt="workspace" className="">
|
||||||
|
<PageActionBar
|
||||||
|
variant="detail"
|
||||||
|
actionScope="workspace"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{
|
||||||
|
onReload: () => void load(),
|
||||||
|
loading,
|
||||||
|
disabledReason: loading
|
||||||
|
? "i18n:govoplan-access.administration_data_is_loading.4af2c001"
|
||||||
|
: undefined
|
||||||
|
}}
|
||||||
|
destructiveActions={
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
helpContextId="access.sessions.action.revoke-others"
|
||||||
|
helpModuleId="access"
|
||||||
|
disabled={busy || sessions.filter((item) => !item.current).length === 0}
|
||||||
|
disabledReason={
|
||||||
|
busy
|
||||||
|
? "i18n:govoplan-access.an_access_administration_operation_is_in_progress.4af2c011"
|
||||||
|
: sessions.filter((item) => !item.current).length === 0
|
||||||
|
? "i18n:govoplan-access.no_other_active_sessions.5e551010"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={() => setRevokingOthers(true)}
|
||||||
|
>
|
||||||
|
i18n:govoplan-access.revoke_all_other_sessions.5e551012
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{error && <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
{success && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
|
||||||
|
<Card title="i18n:govoplan-access.sessions_and_devices.5e551001">
|
||||||
|
<div className="admin-table-surface">
|
||||||
|
<DataGrid
|
||||||
|
id="personal-sessions-v1"
|
||||||
|
rows={sessions}
|
||||||
|
columns={columns}
|
||||||
|
initialFit="container"
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
emptyText="i18n:govoplan-access.no_active_sessions.5e551013"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(revoking)}
|
||||||
|
title="i18n:govoplan-access.revoke_session.5e551007"
|
||||||
|
message="i18n:govoplan-access.revoke_session_confirmation.5e551014"
|
||||||
|
confirmLabel="i18n:govoplan-access.revoke_session.5e551007"
|
||||||
|
tone="danger"
|
||||||
|
busy={busy}
|
||||||
|
helpContextId="access.sessions.action.revoke"
|
||||||
|
helpModuleId="access"
|
||||||
|
onCancel={() => setRevoking(null)}
|
||||||
|
onConfirm={() => void revokeOne()}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={revokingOthers}
|
||||||
|
title="i18n:govoplan-access.revoke_all_other_sessions.5e551012"
|
||||||
|
message="i18n:govoplan-access.revoke_other_sessions_confirmation.5e551015"
|
||||||
|
confirmLabel="i18n:govoplan-access.revoke_all_other_sessions.5e551012"
|
||||||
|
tone="danger"
|
||||||
|
busy={busy}
|
||||||
|
helpContextId="access.sessions.action.revoke-others"
|
||||||
|
helpModuleId="access"
|
||||||
|
onCancel={() => setRevokingOthers(false)}
|
||||||
|
onConfirm={() => void revokeOthers()}
|
||||||
|
/>
|
||||||
|
</ContentGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,57 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
|
|||||||
|
|
||||||
export const generatedTranslations: PlatformTranslations = {
|
export const generatedTranslations: PlatformTranslations = {
|
||||||
"en": {
|
"en": {
|
||||||
|
"i18n:govoplan-access.sessions_and_devices.5e551001": "Sessions and devices",
|
||||||
|
"i18n:govoplan-access.device_or_client.5e551002": "Device or client",
|
||||||
|
"i18n:govoplan-access.current_session.5e551003": "Current session",
|
||||||
|
"i18n:govoplan-access.other_session.5e551004": "Other session",
|
||||||
|
"i18n:govoplan-access.client_details_unavailable.5e551005": "Client details unavailable",
|
||||||
|
"i18n:govoplan-access.last_seen.5e551006": "Last seen",
|
||||||
|
"i18n:govoplan-access.revoke_session.5e551007": "Revoke session",
|
||||||
|
"i18n:govoplan-access.session_revoked.5e551008": "Session revoked.",
|
||||||
|
"i18n:govoplan-access.other_sessions_revoked.5e551009": "All other active sessions were revoked.",
|
||||||
|
"i18n:govoplan-access.no_other_active_sessions.5e551010": "There are no other active sessions.",
|
||||||
|
"i18n:govoplan-access.browser_session_required.5e551011": "Session management is available only from an interactive browser session.",
|
||||||
|
"i18n:govoplan-access.revoke_all_other_sessions.5e551012": "Revoke all other sessions",
|
||||||
|
"i18n:govoplan-access.no_active_sessions.5e551013": "No active sessions were found.",
|
||||||
|
"i18n:govoplan-access.revoke_session_confirmation.5e551014": "This device or client will lose access on its next authenticated request. The current session remains active.",
|
||||||
|
"i18n:govoplan-access.revoke_other_sessions_confirmation.5e551015": "Revoke every other active session for this account? This current session remains active.",
|
||||||
|
"i18n:govoplan-access.session_revocation_permission_required.5e551016": "Membership update permission is required to revoke sessions.",
|
||||||
|
"i18n:govoplan-access.inspect_sessions_for_value.5e551017": "Inspect sessions for {value0}",
|
||||||
|
"i18n:govoplan-access.user_sessions.5e551018": "User sessions",
|
||||||
|
"i18n:govoplan-access.current_password_required.5e551019": "Enter your current password to continue.",
|
||||||
|
"i18n:govoplan-access.admin_session_revocation_confirmation.5e551020": "Re-authorize this administrative action with your current password. The selected session will lose access on its next authenticated request.",
|
||||||
|
"i18n:govoplan-access.current_password.5e551021": "Current password",
|
||||||
|
"i18n:govoplan-access.administration_data_is_loading.4af2c001": "Administration data is loading.",
|
||||||
|
"i18n:govoplan-access.create_permission_is_required.4af2c002": "Create permission is required for this action.",
|
||||||
|
"i18n:govoplan-access.update_or_assignment_permission_is_required.4af2c003": "Update or assignment permission is required for this action.",
|
||||||
|
"i18n:govoplan-access.write_permission_is_required.4af2c004": "Write permission is required for this action.",
|
||||||
|
"i18n:govoplan-access.complete_the_required_fields_before_saving.4af2c005": "Complete the required fields before saving.",
|
||||||
|
"i18n:govoplan-access.select_an_account_and_at_least_one_scope.4af2c006": "Select an account and at least one allowed scope.",
|
||||||
|
"i18n:govoplan-access.select_an_assignable_role_before_creating_a_mapping.4af2c007": "Create an assignable role before adding a function mapping.",
|
||||||
|
"i18n:govoplan-access.remove_existing_assignments_before_deleting.4af2c008": "Remove existing assignments before deleting this definition.",
|
||||||
|
"i18n:govoplan-access.assign_another_operational_owner_before_deactivating.4af2c009": "Assign another operational owner before deactivating this account or membership.",
|
||||||
|
"i18n:govoplan-access.this_definition_is_managed_by_the_system.4af2c010": "This protected definition is managed by the system.",
|
||||||
|
"i18n:govoplan-access.an_access_administration_operation_is_in_progress.4af2c011": "An access administration operation is in progress.",
|
||||||
|
"i18n:govoplan-access.required_action.4af2c012": "Required action",
|
||||||
|
"i18n:govoplan-access.who_can_fix_it.4af2c013": "Who can fix it",
|
||||||
|
"i18n:govoplan-access.where_to_go.4af2c014": "Where to go",
|
||||||
|
"i18n:govoplan-access.request_an_administrative_role_for_the_required_scope.4af2c015": "Request an administrative role for the required scope.",
|
||||||
|
"i18n:govoplan-access.a_tenant_owner_or_system_access_administrator.4af2c016": "A tenant owner or system access administrator",
|
||||||
|
"i18n:govoplan-access.admin_users_groups_and_roles.4af2c017": "Admin > Users, Groups, or Roles",
|
||||||
|
"i18n:govoplan-access.install_and_enable_the_files_module.4af2c018": "Install and enable the Files module.",
|
||||||
|
"i18n:govoplan-access.install_and_enable_the_mail_module.4af2c019": "Install and enable the Mail module.",
|
||||||
|
"i18n:govoplan-access.a_system_module_administrator.4af2c020": "A system module administrator",
|
||||||
|
"i18n:govoplan-access.admin_modules.4af2c021": "Admin > Modules",
|
||||||
|
"i18n:govoplan-access.reusable_credentials.4af2c022": "Reusable credentials",
|
||||||
|
"i18n:govoplan-access.system_credentials.4af2c023": "System credentials",
|
||||||
|
"i18n:govoplan-access.tenant_credentials.4af2c024": "Tenant credentials",
|
||||||
|
"i18n:govoplan-access.group_credentials.4af2c025": "Group credentials",
|
||||||
|
"i18n:govoplan-access.user_credentials.4af2c026": "User credentials",
|
||||||
|
"i18n:govoplan-access.instance_credentials_can_be_inherited_and_governed.4af2c027": "Instance credentials can be inherited by tenants and limited to selected modules or servers.",
|
||||||
|
"i18n:govoplan-access.tenant_credentials_can_be_shared_with_permitted_modules.4af2c028": "Tenant credentials can be shared with Mail, Files, Calendar, Addresses, and other permitted modules.",
|
||||||
|
"i18n:govoplan-access.reusable_credentials_owned_by_the_selected_group.4af2c029": "Reusable credentials owned by the selected group.",
|
||||||
|
"i18n:govoplan-access.reusable_credentials_owned_by_the_selected_user.4af2c030": "Reusable credentials owned by the selected user.",
|
||||||
"i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45": "A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.",
|
"i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45": "A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.",
|
||||||
"i18n:govoplan-access.access_updated_for_value.87f22245": "Access updated for {value0}.",
|
"i18n:govoplan-access.access_updated_for_value.87f22245": "Access updated for {value0}.",
|
||||||
"i18n:govoplan-access.access_explanation.75ee7f62": "Access explanation",
|
"i18n:govoplan-access.access_explanation.75ee7f62": "Access explanation",
|
||||||
@@ -256,7 +307,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-access.slug.094da9b9": "Slug",
|
"i18n:govoplan-access.slug.094da9b9": "Slug",
|
||||||
"i18n:govoplan-access.source_module.62b7241c": "Source module",
|
"i18n:govoplan-access.source_module.62b7241c": "Source module",
|
||||||
"i18n:govoplan-access.status.bae7d5be": "Status",
|
"i18n:govoplan-access.status.bae7d5be": "Status",
|
||||||
"i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.",
|
"i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Store it in a secret manager. GovOPlaN retains only the displayed prefix and a verification hash.",
|
||||||
"i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant",
|
"i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant",
|
||||||
"i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.",
|
"i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.",
|
||||||
"i18n:govoplan-access.suspend_value.03a74b32": "Suspend {value0}",
|
"i18n:govoplan-access.suspend_value.03a74b32": "Suspend {value0}",
|
||||||
@@ -352,6 +403,57 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69": "Your current roles do not grant administrative access."
|
"i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69": "Your current roles do not grant administrative access."
|
||||||
},
|
},
|
||||||
"de": {
|
"de": {
|
||||||
|
"i18n:govoplan-access.sessions_and_devices.5e551001": "Sitzungen und Geräte",
|
||||||
|
"i18n:govoplan-access.device_or_client.5e551002": "Gerät oder Client",
|
||||||
|
"i18n:govoplan-access.current_session.5e551003": "Aktuelle Sitzung",
|
||||||
|
"i18n:govoplan-access.other_session.5e551004": "Andere Sitzung",
|
||||||
|
"i18n:govoplan-access.client_details_unavailable.5e551005": "Keine Clientdetails verfügbar",
|
||||||
|
"i18n:govoplan-access.last_seen.5e551006": "Zuletzt aktiv",
|
||||||
|
"i18n:govoplan-access.revoke_session.5e551007": "Sitzung widerrufen",
|
||||||
|
"i18n:govoplan-access.session_revoked.5e551008": "Sitzung wurde widerrufen.",
|
||||||
|
"i18n:govoplan-access.other_sessions_revoked.5e551009": "Alle anderen aktiven Sitzungen wurden widerrufen.",
|
||||||
|
"i18n:govoplan-access.no_other_active_sessions.5e551010": "Es gibt keine anderen aktiven Sitzungen.",
|
||||||
|
"i18n:govoplan-access.browser_session_required.5e551011": "Die Sitzungsverwaltung ist nur in einer interaktiven Browsersitzung verfügbar.",
|
||||||
|
"i18n:govoplan-access.revoke_all_other_sessions.5e551012": "Alle anderen Sitzungen widerrufen",
|
||||||
|
"i18n:govoplan-access.no_active_sessions.5e551013": "Es wurden keine aktiven Sitzungen gefunden.",
|
||||||
|
"i18n:govoplan-access.revoke_session_confirmation.5e551014": "Dieses Gerät oder dieser Client verliert beim nächsten authentifizierten Aufruf den Zugriff. Die aktuelle Sitzung bleibt aktiv.",
|
||||||
|
"i18n:govoplan-access.revoke_other_sessions_confirmation.5e551015": "Alle anderen aktiven Sitzungen dieses Kontos widerrufen? Diese aktuelle Sitzung bleibt aktiv.",
|
||||||
|
"i18n:govoplan-access.session_revocation_permission_required.5e551016": "Zum Widerrufen von Sitzungen ist die Berechtigung zum Ändern von Mitgliedschaften erforderlich.",
|
||||||
|
"i18n:govoplan-access.inspect_sessions_for_value.5e551017": "Sitzungen von {value0} prüfen",
|
||||||
|
"i18n:govoplan-access.user_sessions.5e551018": "Benutzersitzungen",
|
||||||
|
"i18n:govoplan-access.current_password_required.5e551019": "Geben Sie Ihr aktuelles Passwort ein, um fortzufahren.",
|
||||||
|
"i18n:govoplan-access.admin_session_revocation_confirmation.5e551020": "Autorisieren Sie diese administrative Aktion erneut mit Ihrem aktuellen Passwort. Die ausgewählte Sitzung verliert beim nächsten authentifizierten Aufruf den Zugriff.",
|
||||||
|
"i18n:govoplan-access.current_password.5e551021": "Aktuelles Passwort",
|
||||||
|
"i18n:govoplan-access.administration_data_is_loading.4af2c001": "Administrationsdaten werden geladen.",
|
||||||
|
"i18n:govoplan-access.create_permission_is_required.4af2c002": "Für diese Aktion ist die Berechtigung zum Erstellen erforderlich.",
|
||||||
|
"i18n:govoplan-access.update_or_assignment_permission_is_required.4af2c003": "Für diese Aktion ist eine Berechtigung zum Ändern oder Zuweisen erforderlich.",
|
||||||
|
"i18n:govoplan-access.write_permission_is_required.4af2c004": "Für diese Aktion ist eine Schreibberechtigung erforderlich.",
|
||||||
|
"i18n:govoplan-access.complete_the_required_fields_before_saving.4af2c005": "Füllen Sie vor dem Speichern die Pflichtfelder aus.",
|
||||||
|
"i18n:govoplan-access.select_an_account_and_at_least_one_scope.4af2c006": "Wählen Sie ein Konto und mindestens einen zulässigen Geltungsbereich aus.",
|
||||||
|
"i18n:govoplan-access.select_an_assignable_role_before_creating_a_mapping.4af2c007": "Erstellen Sie vor dem Hinzufügen einer Funktionszuordnung eine zuweisbare Rolle.",
|
||||||
|
"i18n:govoplan-access.remove_existing_assignments_before_deleting.4af2c008": "Entfernen Sie bestehende Zuweisungen, bevor Sie diese Definition löschen.",
|
||||||
|
"i18n:govoplan-access.assign_another_operational_owner_before_deactivating.4af2c009": "Weisen Sie einen anderen betriebsfähigen Eigentümer zu, bevor Sie dieses Konto oder diese Mitgliedschaft deaktivieren.",
|
||||||
|
"i18n:govoplan-access.this_definition_is_managed_by_the_system.4af2c010": "Diese geschützte Definition wird vom System verwaltet.",
|
||||||
|
"i18n:govoplan-access.an_access_administration_operation_is_in_progress.4af2c011": "Eine Zugriffsverwaltungsaktion wird gerade ausgeführt.",
|
||||||
|
"i18n:govoplan-access.required_action.4af2c012": "Erforderliche Aktion",
|
||||||
|
"i18n:govoplan-access.who_can_fix_it.4af2c013": "Wer kann das beheben",
|
||||||
|
"i18n:govoplan-access.where_to_go.4af2c014": "Zuständige Stelle",
|
||||||
|
"i18n:govoplan-access.request_an_administrative_role_for_the_required_scope.4af2c015": "Fordern Sie eine administrative Rolle für den benötigten Bereich an.",
|
||||||
|
"i18n:govoplan-access.a_tenant_owner_or_system_access_administrator.4af2c016": "Ein Mandanteneigentümer oder System-Zugriffsadministrator",
|
||||||
|
"i18n:govoplan-access.admin_users_groups_and_roles.4af2c017": "Administration > Benutzer, Gruppen oder Rollen",
|
||||||
|
"i18n:govoplan-access.install_and_enable_the_files_module.4af2c018": "Installieren und aktivieren Sie das Dateimodul.",
|
||||||
|
"i18n:govoplan-access.install_and_enable_the_mail_module.4af2c019": "Installieren und aktivieren Sie das Mailmodul.",
|
||||||
|
"i18n:govoplan-access.a_system_module_administrator.4af2c020": "Ein System-Moduladministrator",
|
||||||
|
"i18n:govoplan-access.admin_modules.4af2c021": "Administration > Module",
|
||||||
|
"i18n:govoplan-access.reusable_credentials.4af2c022": "Wiederverwendbare Zugangsdaten",
|
||||||
|
"i18n:govoplan-access.system_credentials.4af2c023": "System-Zugangsdaten",
|
||||||
|
"i18n:govoplan-access.tenant_credentials.4af2c024": "Mandanten-Zugangsdaten",
|
||||||
|
"i18n:govoplan-access.group_credentials.4af2c025": "Gruppen-Zugangsdaten",
|
||||||
|
"i18n:govoplan-access.user_credentials.4af2c026": "Benutzer-Zugangsdaten",
|
||||||
|
"i18n:govoplan-access.instance_credentials_can_be_inherited_and_governed.4af2c027": "Instanzweite Zugangsdaten können von Mandanten geerbt und auf ausgewählte Module oder Server begrenzt werden.",
|
||||||
|
"i18n:govoplan-access.tenant_credentials_can_be_shared_with_permitted_modules.4af2c028": "Mandanten-Zugangsdaten können mit Mail, Dateien, Kalender, Adressen und anderen erlaubten Modulen geteilt werden.",
|
||||||
|
"i18n:govoplan-access.reusable_credentials_owned_by_the_selected_group.4af2c029": "Wiederverwendbare Zugangsdaten der ausgewählten Gruppe.",
|
||||||
|
"i18n:govoplan-access.reusable_credentials_owned_by_the_selected_user.4af2c030": "Wiederverwendbare Zugangsdaten des ausgewählten Benutzers.",
|
||||||
"i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45": "A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.",
|
"i18n:govoplan-access.a_role_may_contain_only_permissions_held_by_the_.a7ee5e45": "A role may contain only permissions held by the administrator defining it. The protected system:* wildcard is reserved for System owner.",
|
||||||
"i18n:govoplan-access.access_updated_for_value.87f22245": "Access updated for {value0}.",
|
"i18n:govoplan-access.access_updated_for_value.87f22245": "Access updated for {value0}.",
|
||||||
"i18n:govoplan-access.access_explanation.75ee7f62": "Zugriffserklaerung",
|
"i18n:govoplan-access.access_explanation.75ee7f62": "Zugriffserklaerung",
|
||||||
@@ -606,7 +708,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-access.slug.094da9b9": "Slug",
|
"i18n:govoplan-access.slug.094da9b9": "Slug",
|
||||||
"i18n:govoplan-access.source_module.62b7241c": "Quellmodul",
|
"i18n:govoplan-access.source_module.62b7241c": "Quellmodul",
|
||||||
"i18n:govoplan-access.status.bae7d5be": "Status",
|
"i18n:govoplan-access.status.bae7d5be": "Status",
|
||||||
"i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Store it in a secret manager. Only its prefix and hash remain in Multi Seal Mail.",
|
"i18n:govoplan-access.store_it_in_a_secret_manager_only_its_prefix_and.796ac588": "Speichern Sie ihn in einem Secret-Manager. GovOPlaN speichert nur das angezeigte Präfix und einen Prüfhash.",
|
||||||
"i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant",
|
"i18n:govoplan-access.suspend_tenant.151d283a": "Suspend tenant",
|
||||||
"i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.",
|
"i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78": "Suspend {value0}? Existing data remains retained, but its members cannot use the tenant.",
|
||||||
"i18n:govoplan-access.suspend_value.03a74b32": "Suspend {value0}",
|
"i18n:govoplan-access.suspend_value.03a74b32": "Suspend {value0}",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export { default } from "./module";
|
export { default } from "./module";
|
||||||
export * from "./module";
|
export * from "./module";
|
||||||
export * from "./api/admin";
|
export * from "./api/admin";
|
||||||
|
export * from "./api/sessions";
|
||||||
export { default as AdminPage } from "./features/admin/AdminPage";
|
export { default as AdminPage } from "./features/admin/AdminPage";
|
||||||
export { ResourceAccessExplanation } from "@govoplan/core-webui";
|
export { ResourceAccessExplanation } from "@govoplan/core-webui";
|
||||||
export type { ResourceAccessExplanationOptions, ResourceAccessExplanationProps, ResourceAccessExplanationUser } from "@govoplan/core-webui";
|
export type { ResourceAccessExplanationOptions, ResourceAccessExplanationProps, ResourceAccessExplanationUser } from "@govoplan/core-webui";
|
||||||
|
|||||||
+41
-4
@@ -1,15 +1,48 @@
|
|||||||
import { createElement, lazy } from "react";
|
import { createElement, lazy } from "react";
|
||||||
import type { PlatformRouteContext, PlatformWebModule } from "@govoplan/core-webui";
|
import type { ActingContextRuntimeUiCapability, PlatformRouteContext, PlatformWebModule, SettingsSectionsUiCapability } from "@govoplan/core-webui";
|
||||||
import { adminReadScopes } from "@govoplan/core-webui";
|
import { adminReadScopes } from "@govoplan/core-webui";
|
||||||
|
import ActingContextSelector from "./features/acting-context/ActingContextSelector";
|
||||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
|
|
||||||
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
|
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
|
||||||
|
const SessionSettingsPanel = lazy(() => import("./features/sessions/SessionSettingsPanel"));
|
||||||
|
|
||||||
const translations = {
|
const translations = {
|
||||||
en: generatedTranslations.en,
|
en: generatedTranslations.en,
|
||||||
de: generatedTranslations.de
|
de: generatedTranslations.de
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const accessAdminSurfaces = [
|
||||||
|
{ id: "access.admin.system-roles", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.system_roles.a9461aa6", order: 20 },
|
||||||
|
{ id: "access.admin.system-users", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.central_users.91ac1b51", order: 50 },
|
||||||
|
{ id: "access.admin.system-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.system_credentials.4af2c023", order: 80 },
|
||||||
|
{ id: "access.admin.tenant-roles", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.tenant_roles.51aca82d", order: 10 },
|
||||||
|
{ id: "access.admin.tenant-function-mappings", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.function_role_mappings.2b64e9c3", order: 20 },
|
||||||
|
{ id: "access.admin.tenant-groups", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.tenant_groups.47e6cc05", order: 30 },
|
||||||
|
{ id: "access.admin.tenant-users", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.tenant_users.cb800b38", order: 40 },
|
||||||
|
{ id: "access.admin.tenant-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.tenant_credentials.4af2c024", order: 70 },
|
||||||
|
{ id: "access.admin.tenant-api-keys", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.tenant_api_keys.4b1d81f8", order: 80 },
|
||||||
|
{ id: "access.admin.tenant-service-accounts", moduleId: "access", kind: "section" as const, label: "Service accounts", order: 90 },
|
||||||
|
{ id: "access.admin.group-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.group_credentials.4af2c025", order: 30 },
|
||||||
|
{ id: "access.admin.user-credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.user_credentials.4af2c026", order: 30 },
|
||||||
|
{ id: "access.settings.credentials", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.reusable_credentials.4af2c022", order: 30 },
|
||||||
|
{ id: "access.settings.sessions", moduleId: "access", kind: "section" as const, label: "i18n:govoplan-access.sessions_and_devices.5e551001", order: 20 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const accessSettingsSections: SettingsSectionsUiCapability = {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "sessions",
|
||||||
|
surfaceId: "access.settings.sessions",
|
||||||
|
label: "i18n:govoplan-access.sessions_and_devices.5e551001",
|
||||||
|
group: "account",
|
||||||
|
order: 20,
|
||||||
|
allOf: ["access:session:manage_own"],
|
||||||
|
render: ({ settings, auth }) => createElement(SessionSettingsPanel, { settings, auth })
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
function renderAdminRoute({ settings, auth, onAuthChange }: PlatformRouteContext) {
|
function renderAdminRoute({ settings, auth, onAuthChange }: PlatformRouteContext) {
|
||||||
if (!onAuthChange) {
|
if (!onAuthChange) {
|
||||||
throw new Error("i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f");
|
throw new Error("i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f");
|
||||||
@@ -20,14 +53,18 @@ function renderAdminRoute({ settings, auth, onAuthChange }: PlatformRouteContext
|
|||||||
export const accessModule: PlatformWebModule = {
|
export const accessModule: PlatformWebModule = {
|
||||||
id: "access",
|
id: "access",
|
||||||
label: "i18n:govoplan-access.access.2f81a22d",
|
label: "i18n:govoplan-access.access.2f81a22d",
|
||||||
version: "1.0.0",
|
version: "0.1.11",
|
||||||
translations,
|
translations,
|
||||||
|
viewSurfaces: accessAdminSurfaces,
|
||||||
navItems: [
|
navItems: [
|
||||||
{ to: "/admin", label: "i18n:govoplan-access.admin.4e7afebc", iconName: "admin", anyOf: adminReadScopes, order: 900 }],
|
{ to: "/admin", label: "i18n:govoplan-access.admin.4e7afebc", iconName: "admin", anyOf: adminReadScopes, order: 900 }],
|
||||||
|
|
||||||
routes: [
|
routes: [
|
||||||
{ path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }]
|
{ path: "/admin", anyOf: adminReadScopes, order: 900, render: renderAdminRoute }],
|
||||||
|
uiCapabilities: {
|
||||||
|
"access.actingContext": { Selector: ActingContextSelector } satisfies ActingContextRuntimeUiCapability,
|
||||||
|
"settings.sections": accessSettingsSections
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default accessModule;
|
export default accessModule;
|
||||||
Reference in New Issue
Block a user