Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f8072b4ae | ||
|
|
0f8a05f8b9 | ||
|
|
e55434f406 | ||
|
|
a889071b71 | ||
|
|
8a43b9b676 | ||
|
|
206873b62a | ||
|
|
2d1b1e356e | ||
|
|
fa0c85e03a | ||
|
|
94b604a3af | ||
|
|
d3daf42bd9 | ||
|
|
6052dde760 | ||
|
|
566b3b83ad | ||
|
|
e4bae0121d | ||
|
|
5c6f446cd5 | ||
|
|
2be1dbc132 | ||
|
|
38fc22c06b | ||
|
|
3661fdd370 | ||
|
|
3966c7f33c | ||
|
|
9ba69286f7 | ||
|
|
55d87a7812 | ||
|
|
a861338b9a | ||
|
|
0b9e3751c2 | ||
|
|
e04671034f | ||
|
|
44799b15e5 | ||
|
|
367ffc4564 | ||
|
|
a7ed28f6a3 | ||
|
|
794fcf90f8 | ||
|
|
d5db5c2378 | ||
|
|
4653542247 | ||
|
|
668f7cf108 | ||
|
|
e710cf5fb8 | ||
|
|
998d47ae94 | ||
|
|
1409dbf94d | ||
|
|
fe247999e9 | ||
|
|
9776f862f8 | ||
|
|
e58dcbdd7b | ||
|
|
aeda457fb1 | ||
|
|
a70cc375c2 | ||
|
|
e8ea347654 | ||
|
|
a758c8f2da | ||
|
|
6295cfa840 | ||
|
|
b6c2c89adf | ||
|
|
6b0dd8beab | ||
|
|
984a015704 | ||
|
|
3df4fc5bff | ||
|
|
1b57df7753 | ||
|
|
bf1ecc54b3 | ||
|
|
fdfcfbb440 |
@@ -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
|
||||
|
||||
## Documentation Contract
|
||||
|
||||
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
|
||||
- Keep feature content here; `govoplan-docs` projects it without importing Access internals.
|
||||
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||
|
||||
## Scope
|
||||
|
||||
This repository owns the GovOPlaN access platform module seed: identity,
|
||||
|
||||
@@ -63,8 +63,12 @@ This module will own:
|
||||
- compatibility FastAPI auth dependency API at `govoplan_access.auth`
|
||||
- provider-facing auth facade consumed by sibling modules at `govoplan_core.auth`
|
||||
- access administration, tenant provisioning, and governance materializer
|
||||
capabilities
|
||||
capabilities, including the bounded `access.governanceProjection.v1` bulk
|
||||
reconciliation contract used by Admin for idempotent per-assignment outcomes
|
||||
- access-owned migrations
|
||||
- a provider-neutral tenant-erasure contribution that removes tenant-scoped
|
||||
credentials and authorization projections while preserving shared global
|
||||
accounts and identities
|
||||
|
||||
The governance-template routes under `/admin/system/governance-templates` are
|
||||
contributed by `govoplan-admin`; access must not register those routes.
|
||||
@@ -76,13 +80,33 @@ adds tenant administration plus tenant resolver behavior when installed.
|
||||
## Principal Context
|
||||
|
||||
The stable principal DTO is `govoplan_core.core.access.PrincipalRef`. Access
|
||||
resolves sessions, API keys, and 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
|
||||
that DTO, primitive IDs, or the core `govoplan_core.auth` dependency facade
|
||||
instead of importing access ORM models or backend dependency internals.
|
||||
|
||||
The detailed module boundary and serialization fields are documented in
|
||||
[docs/ACCESS_MODULE_BOUNDARY.md](docs/ACCESS_MODULE_BOUNDARY.md).
|
||||
The Access-owned administration surfaces, consequence classes, shared control
|
||||
contract, contextual-help references, and verification evidence are recorded
|
||||
in [docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||
|
||||
For scheduled and event-driven work, Access provides
|
||||
`auth.automationPrincipalProvider`. Automation records store only an owner
|
||||
account/membership reference and an explicit least-privilege scope grant, not
|
||||
a session or API token. At delivery time Access rebuilds the principal from
|
||||
current roles, groups, functions, and delegations and intersects that
|
||||
authorization with the stored grant. Missing, inactive, moved, or
|
||||
under-authorized owners fail closed before module work starts.
|
||||
|
||||
Tenant administrators manage non-login service accounts under
|
||||
`Admin > Tenant > Service accounts`. Each service account has a revisioned
|
||||
scope ceiling and independently revocable API credentials. Credential secrets
|
||||
are shown once; runtime authorization intersects the credential grant with the
|
||||
current ceiling. Rotation creates a replacement and revokes the previous
|
||||
credential atomically, while retirement revokes every active credential. See
|
||||
[docs/SERVICE_ACCOUNTS.md](docs/SERVICE_ACCOUNTS.md) for the API and operational
|
||||
contract.
|
||||
|
||||
## WebUI Package
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ contracts.
|
||||
- tenant owner provisioning and default access bootstrap
|
||||
- materializing governance templates into access-owned groups and roles
|
||||
- access-owned SQLAlchemy metadata and migrations for `access_*` tables
|
||||
- the `tenancy.erasure_provider.access` contribution, which previews and
|
||||
idempotently removes only target-tenant credentials and authorization rows
|
||||
while retaining global accounts and identities shared with other tenants
|
||||
|
||||
The active access tables use the `access_*` namespace while the model classes
|
||||
live in this module: `access_accounts`, `access_users`, `access_groups`,
|
||||
@@ -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
|
||||
`govoplan_tenancy` or require the tenancy package to start.
|
||||
|
||||
## Tenant-Erasure Boundary
|
||||
|
||||
Access implements the Core tenant-erasure provider contract without importing
|
||||
Tenancy. Its preview counts every Access table with a tenant boundary. The
|
||||
first destructive step removes target-tenant sessions and API keys; the second
|
||||
removes service accounts, memberships, groups, tenant roles, organization
|
||||
units, functions, assignments, and delegations in dependency-safe order.
|
||||
Both steps are database-transactional and idempotent, so reconciliation can
|
||||
repeat them after an interrupted response.
|
||||
|
||||
Global accounts, system-role assignments, identities, and identity-account
|
||||
links are intentionally retained: they are installation-wide facts and may be
|
||||
used by another tenant. Provider previews and receipts contain counts and
|
||||
stable references only, never password hashes, session tokens, API-key hashes,
|
||||
email addresses, or other credential material.
|
||||
|
||||
## Core-Only Startup Contract
|
||||
|
||||
A core-only installation must be able to start far enough to expose process
|
||||
@@ -146,8 +165,11 @@ The backend foundation exposes these administration routes:
|
||||
- `/api/v1/admin/function-assignments`
|
||||
- `/api/v1/admin/function-delegations`
|
||||
|
||||
Dedicated WebUI management panels and explicit acting-in-place context
|
||||
selection are still follow-up work on top of these routes.
|
||||
Dedicated WebUI management panels remain follow-up work on top of these routes.
|
||||
Interactive acting-in-place selection is available through
|
||||
`/api/v1/auth/acting-contexts` and `/api/v1/auth/switch-acting-context`; it
|
||||
persists the exact selected assignment and represented account on the session,
|
||||
audits each switch, and fails closed when the assignment is no longer effective.
|
||||
|
||||
## Removed Compatibility Paths
|
||||
|
||||
@@ -177,7 +199,24 @@ admin routers as base routers.
|
||||
|
||||
Governance-template metadata CRUD is not access-owned. It is contributed by
|
||||
`govoplan-admin`; access only materializes those templates into access-owned
|
||||
groups and roles 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
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Authentication cache boundary hardening
|
||||
|
||||
The authentication cache is an optimization, never an additional authentication
|
||||
method or permission source. The September 2026 review found and reproduced
|
||||
three violations of that boundary in isolated SQLite tests.
|
||||
|
||||
| Finding | Consequence | Resolution |
|
||||
| --- | --- | --- |
|
||||
| Service-account credentials passed through ordinary principal-summary refresh | A narrowed service-account ceiling could be replaced by backing membership roles; the service-account identifier and authentication method were lost. | Dedicated service-account resolution retains its provenance and checks the current ceiling/lifecycle on every request. It does not enter the interactive principal-summary cache. |
|
||||
| A warmed API-key summary accepted the same secret through a session cookie | The warm path accepted a cookie-authenticated mutation without the CSRF rule applied to real browser sessions; the cold path rejected the credential. | API keys require an explicit Bearer or X-API-Key header on both paths. Session cookies still require matching CSRF cookie/header/hash for mutations. |
|
||||
| Tenant-key intersection excluded only the historical `system:` spelling | Module-native system permissions and retained module wildcards could survive the tenant-only intersection. | Resolve wildcard grants to concrete registered tenant permissions and exclude system permissions by catalogue and compatibility aliases. |
|
||||
|
||||
No existing secrets, sessions, assignments, or database schema are changed.
|
||||
Normal header-authenticated API keys and concrete tenant aliases remain
|
||||
compatible. Clients relying on API keys in browser cookies, implicit unknown
|
||||
wildcards, or accidental instance-level rights must correct their authentication
|
||||
method or permission configuration; these are not preserved as compatibility
|
||||
exceptions. Stored grants are not rewritten. Service-account access continues
|
||||
to narrow immediately when its ceiling is reduced or its lifecycle blocks use.
|
||||
|
||||
Regression coverage is in `tests/test_auth_cache_security.py` and
|
||||
`tests/test_permission_catalog_contract.py`; the tests exercise the full
|
||||
credential resolver with principal caching enabled, not only the lower-level
|
||||
API-key lookup. They also verify that valid session CSRF and concrete legacy
|
||||
tenant aliases still work.
|
||||
|
||||
## Remaining coordinated password-change workflow
|
||||
|
||||
`Account.password_reset_required` is currently advisory metadata, not an
|
||||
enforced sign-in restriction. The administrator UI states this limitation,
|
||||
but the authentication-fields documentation previously claimed mandatory
|
||||
replacement; its English and German text now reflects the implementation.
|
||||
A generated password is disclosed once but is not a single-use login secret.
|
||||
|
||||
A follow-up must deliver the password-change endpoint, current-password
|
||||
verification and replacement policy, CSRF and attempt limits, session
|
||||
revocation/rotation and cache invalidation, a restricted reset-required
|
||||
principal, and the corresponding accessible UI/recovery path together.
|
||||
Enabling only a rejection gate would lock affected accounts out without any
|
||||
supported way to finish the change. This review does not enable such a gate or
|
||||
change existing passwords.
|
||||
|
||||
## Betriebshinweise
|
||||
|
||||
API-Schlüssel werden ausschließlich über `Authorization: Bearer` oder
|
||||
`X-API-Key` gesendet, nicht über das Sitzungscookie. Ändernde Cookie-Anfragen
|
||||
benötigen weiterhin einen passenden CSRF-Header samt Cookie und serverseitigem
|
||||
Prüfwert. Dienstkonten behalten ihre eigene Herkunft und werden bei jeder
|
||||
Anfrage gegen den aktuellen Berechtigungsrahmen und Lebenszyklus geprüft.
|
||||
Mitgliedschaftsrollen oder zwischengespeicherte interaktive Rechte dürfen diesen
|
||||
Rahmen nicht ersetzen.
|
||||
|
||||
Mandantenschlüssel erhalten keine instanzweiten Rechte, auch nicht unter
|
||||
modulbezogenen Berechtigungsnamen. Platzhalter werden in konkrete registrierte
|
||||
Mandantenrechte aufgelöst. Bestehende konkrete Mandantenrechte und ihre
|
||||
Kompatibilitätsnamen bleiben erhalten; gespeicherte Geheimnisse und
|
||||
Rollenzuweisungen werden nicht geändert.
|
||||
|
||||
Das Kennzeichen `password_reset_required` erzwingt derzeit keinen
|
||||
Passwortwechsel. Ein einmal angezeigtes Anfangspasswort bleibt zur Anmeldung
|
||||
verwendbar. Die Nachfolgeumsetzung muss Passwortänderung, eng begrenzten
|
||||
Zwischenzugriff, Sitzungswechsel beziehungsweise Widerruf und eine bedienbare
|
||||
Wiederherstellung gemeinsam liefern; eine alleinige Zugriffssperre würde
|
||||
betroffene Konten ohne durchführbaren Passwortwechsel aussperren.
|
||||
@@ -0,0 +1,79 @@
|
||||
# External function mapping schema repair
|
||||
|
||||
Access owns `access_external_function_role_assignments`. Some older databases
|
||||
record the Access baseline (`4a5b6c7d8e9f`) without this table. The mapping list
|
||||
and `/api/v1/admin/external-function-role-mappings/delta` then fail with an
|
||||
undefined-table error. This is a schema/history mismatch, not a reason to change
|
||||
user permissions or recreate tenant data.
|
||||
|
||||
Forward repair revision `d8f1b4e7a0c3` follows Access `c7e0a3d6f9b2` on the
|
||||
release track and `b6d9f2a5c8e1` on the disposable-development track. The latter
|
||||
also requires Core's existing scope-table rename `4f2a9c8e7b6d`; this is a Core
|
||||
contract and does not require the optional Tenancy or Organizations modules.
|
||||
|
||||
Before applying deployment migrations, back up and verify the database backup.
|
||||
Use the configured migration track and ordinary deployment migration workflow,
|
||||
including its deployment-wide advisory lock. Inspect the pending revision plan
|
||||
before any targeted repair. Never replay or stamp the baseline, initialize dev
|
||||
data, reset the database, or switch migration tracks to bypass the error.
|
||||
|
||||
The development launcher can run pending migrations when its file watcher
|
||||
reloads the backend. Prepare and test a migration outside the watched source
|
||||
tree, and complete the backup/preflight before placing a new migration file in
|
||||
that tree. Do not assume that waiting to invoke a migration command prevents a
|
||||
running development instance from applying it automatically.
|
||||
|
||||
The repair:
|
||||
|
||||
- Creates the absent mapping table only, with its baseline columns, role/scope
|
||||
cascade foreign keys, primary key, tenant/source/function/role uniqueness,
|
||||
and four lookup indexes.
|
||||
- Does nothing if the table already exists. It does not alter partial tables;
|
||||
any other schema mismatch needs separate inspection.
|
||||
- Never invents mappings or changes roles, memberships, permissions, or other
|
||||
application records. An empty list means no mappings have been configured.
|
||||
- Keeps the table and any stored mappings on downgrade, because the table
|
||||
belongs to the baseline and removing it would delete authorization policy.
|
||||
|
||||
After migration, verify both mapping list endpoints return success for an
|
||||
authorized user in the active tenant, and check the table's constraints and
|
||||
indexes. Existing read scopes and tenant isolation remain enforced. A missing
|
||||
table cannot reveal whether historical mappings were once removed: this repair
|
||||
does not reconstruct lost policy; investigate backups if mappings were expected.
|
||||
|
||||
Regression coverage in `tests/test_external_function_mapping_migration.py`
|
||||
recreates the observed missing-table failure in isolated databases on both
|
||||
tracks. It checks the HTTP list/delta responses, repeated upgrades, no-op
|
||||
upgrades with existing mappings, downgrade/re-upgrade preservation, unchanged
|
||||
parent rows/permissions, constraints, denied unprivileged reads, and tenant
|
||||
isolation.
|
||||
|
||||
## Deutsch
|
||||
|
||||
Bei älteren Datenbanken kann die Access-Basismigration als angewendet vermerkt
|
||||
sein, obwohl `access_external_function_role_assignments` fehlt. Die Liste der
|
||||
Funktions-Rollenzuordnungen und ihre Delta-API melden dann einen internen Fehler.
|
||||
Dies ist ein Widerspruch zwischen Schema und Migrationsstand, kein Anlass zur
|
||||
Erweiterung von Berechtigungen oder zum Neuerstellen von Mandantendaten.
|
||||
|
||||
Vor der regulären, vorwärtsgerichteten Migration `d8f1b4e7a0c3` eine überprüfte
|
||||
Datenbanksicherung erstellen. Den konfigurierten Migrationstrack und den
|
||||
regulären Bereitstellungsablauf mit installationsweiter Migrationssperre nutzen;
|
||||
bei einer gezielten Reparatur zuvor die ausstehenden Revisionen prüfen.
|
||||
Basismigrationen nicht erneut ausführen oder lediglich als angewendet markieren,
|
||||
keine Entwicklungsdaten initialisieren und die Datenbank nicht zurücksetzen.
|
||||
Der Entwicklungsstarter kann ausstehende Migrationen bereits beim automatischen
|
||||
Neuladen des Backends anwenden. Neue Migrationsdateien deshalb außerhalb des
|
||||
überwachten Quellbaums vorbereiten und testen; Sicherung und Vorprüfung vor dem
|
||||
Kopieren in den überwachten Quellbaum abschließen. Das Warten mit einem manuellen
|
||||
Migrationsaufruf verhindert die automatische Anwendung nicht.
|
||||
|
||||
Die Reparatur erstellt nur die fehlende Tabelle einschließlich Fremdschlüsseln,
|
||||
Eindeutigkeitsbedingung und Indizes. Vorhandene Tabellen und Datensätze bleiben
|
||||
unverändert; auch ein Downgrade entfernt keine Zuordnungsdaten. Teilweise
|
||||
vorhandene Tabellen werden nicht umgebaut und erfordern eine gesonderte Prüfung.
|
||||
Es entstehen keine automatischen Zuordnungen oder neuen Rechte. Anschließend
|
||||
beide Listenendpunkte im aktiven Mandanten mit einer berechtigten Person prüfen.
|
||||
Eine leere Liste bedeutet, dass keine Zuordnungen konfiguriert sind. Falls früher
|
||||
Zuordnungen erwartet wurden, Sicherungen prüfen: Verlorene Berechtigungsregeln
|
||||
lassen sich aus einer fehlenden Tabelle nicht rekonstruieren.
|
||||
@@ -161,6 +161,10 @@ Implemented backend foundation:
|
||||
delegation identifiers when those facts exist.
|
||||
- The access manifest registers `access.semanticDirectory` and
|
||||
`access.explanation` capabilities.
|
||||
- Interactive sessions can list `/api/v1/auth/acting-contexts` and explicitly
|
||||
select or clear one with `/api/v1/auth/switch-acting-context`. Every switch is
|
||||
audited. API keys cannot select an acting context, and a stale, expired,
|
||||
revoked, or account-mismatched assignment fails closed.
|
||||
|
||||
Remaining rollout:
|
||||
|
||||
@@ -169,9 +173,6 @@ Remaining rollout:
|
||||
projection until migration is complete.
|
||||
2. Add dedicated WebUI management panels for identities, organization units,
|
||||
functions, assignments, and delegations.
|
||||
3. Add explicit acting-in-place context selection; `act_in_place` delegation
|
||||
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,
|
||||
3. Retrofit postbox, workflow, portal, and audit consumers to use identity,
|
||||
organization, and access explanation capabilities rather than local access
|
||||
assumptions.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Access interface pattern migration
|
||||
|
||||
This document records the Access-owned surfaces covered by the platform
|
||||
interface pattern language. Shared primitives remain owned by Core and
|
||||
optional Mail, Files, Organizations, IDM, and Docs behavior is consumed only
|
||||
through declared capabilities or metadata.
|
||||
|
||||
## Surface inventory
|
||||
|
||||
| Surface | Archetype | Authority and state model |
|
||||
| --- | --- | --- |
|
||||
| `/admin` tree and unavailable state | Tree-navigated administration workspace | The effective principal and active View determine which branches exist. A missing administration grant is an explained blocker, not an empty route. |
|
||||
| System and tenant users | Server-authoritative directory plus list/detail editor | Delta reads refresh accounts and memberships. Create, update, assignment, suspension, and final-owner safeguards remain independent permissions. |
|
||||
| System and tenant roles | Governed definition directory | Built-in and system-managed definitions remain visible but immutable. Assigned roles cannot be deleted. |
|
||||
| Tenant groups | Governed definition and membership editor | Definition, membership, and role-assignment rights remain independent. Required system groups cannot be deactivated. |
|
||||
| Tenant API keys | Immutable-secret lifecycle directory | A key is created once, its secret is shown once, and revocation is consequential and confirmed. |
|
||||
| Function mappings | Governed cross-module mapping editor | Organizations supplies function choices, IDM supplies accepted facts, and Access maps facts to assignable roles. |
|
||||
| Credential scopes | Adaptive configuration panel | Core owns the reusable credential manager. Access supplies system, tenant, group, and user ownership choices. |
|
||||
| Mail and Files scope panels | Optional capability host | Access supplies owner selection; the owning module supplies configuration UI. A missing capability names the required module, actor, and destination. |
|
||||
|
||||
## Consequence classes
|
||||
|
||||
- Reload, inspect, filter, select, and open-help actions are reversible.
|
||||
- User, group, role, mapping, and credential edits are governed mutations and
|
||||
expose permission or validation blockers before submission.
|
||||
- Account or membership deactivation, group deactivation, role deletion,
|
||||
mapping deletion, API-key revocation, and credential deletion are
|
||||
consequential actions and use the shared confirmation contract.
|
||||
- Secret values and temporary passwords are never placed in list rows or
|
||||
persistent notices. One-time values remain inside dedicated dialogs.
|
||||
|
||||
## Interaction evidence
|
||||
|
||||
- `WorkspaceLayout`, headerless `PageLayout`, `AdminPageLayout`, `TreeSubnav`,
|
||||
`DataGrid`, `Dialog`, `ConfirmDialog`,
|
||||
`TableActionGroup`, `PasswordField`, `ActionBlockerHint`, and
|
||||
`DocumentationHelpLink` come from Core.
|
||||
- The administration tree and its contributed panels now share Core-owned pane
|
||||
sizing, scrolling, content inset, responsive collapse, region labels, and
|
||||
contextual-help identity; Access no longer carries a raw workspace or page
|
||||
frame exception.
|
||||
- Dialog focus trapping and restoration, disabled-action tooltips, keyboard
|
||||
ordering, responsive grid overflow, and alert semantics therefore inherit
|
||||
the tested Core behavior.
|
||||
- All Access-owned labels added by this migration are present in the English
|
||||
and German module catalogs.
|
||||
- The WebUI structural test rejects browser-native confirmation calls, private
|
||||
sibling imports, missing contextual-help references, and unexplained
|
||||
optional-module blockers.
|
||||
|
||||
## Documentation contexts
|
||||
|
||||
- `access.workflow.grant-user-access` covers the user, group, and role path.
|
||||
- `access.reference.admin-access-fields` covers accounts, roles, API keys, and
|
||||
their backing administration fields.
|
||||
- `access.workflow.manage-api-keys` owns exact help for accountable ownership,
|
||||
bounded scopes and expiry, one-time secret custody, and immediate revocation.
|
||||
- `access.workflow.manage-service-account-credentials` owns exact help for the
|
||||
account ceiling, activation state, credential rotation/revocation, one-time
|
||||
secret custody, concurrency, and retirement consequences.
|
||||
- `access.reference.external-function-role-mappings` explains the
|
||||
Organizations, IDM, and Access responsibility split.
|
||||
- Files and Mail blockers link to documentation supplied by the owning module.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Service accounts
|
||||
|
||||
Service accounts are tenant-owned, non-login principals for automation. Their
|
||||
backing account and membership cannot use a password or browser session.
|
||||
|
||||
## Authorization model
|
||||
|
||||
The service account defines a revisioned scope ceiling. Every credential has
|
||||
its own narrower scope grant. On every authenticated request, Access checks
|
||||
that the tenant, service account, backing account, membership, and credential
|
||||
are active, then grants only the intersection of the current ceiling and the
|
||||
credential scopes. Reducing the ceiling therefore takes effect without
|
||||
reissuing a credential.
|
||||
|
||||
Administrators may grant only scopes they currently hold. Credential creation
|
||||
also follows the tenant API-key governance switch. Secrets are returned once;
|
||||
the database stores a one-way hash and a non-authenticating prefix.
|
||||
|
||||
## Administration
|
||||
|
||||
Open `Admin > Tenant > Service accounts` to create, edit, deactivate, activate,
|
||||
or retire a principal. The detail dialog lists active, expired, and revoked
|
||||
credentials and exposes create, rotate, and revoke actions.
|
||||
|
||||
Every write includes `expected_revision`. A concurrent change returns `409`
|
||||
and the UI reloads the account before another action. Rotation creates the new
|
||||
credential and revokes the old one in a single transaction. Retirement
|
||||
deactivates the principal and revokes all active credentials.
|
||||
|
||||
## API
|
||||
|
||||
- `GET/POST /api/v1/admin/service-accounts`
|
||||
- `GET/PATCH /api/v1/admin/service-accounts/{service_account_id}`
|
||||
- `POST /api/v1/admin/service-accounts/{service_account_id}/retire`
|
||||
- `GET/POST /api/v1/admin/service-accounts/{service_account_id}/credentials`
|
||||
- `POST /api/v1/admin/service-accounts/{service_account_id}/credentials/{credential_id}/rotate`
|
||||
- `POST /api/v1/admin/service-accounts/{service_account_id}/credentials/{credential_id}/revoke`
|
||||
|
||||
Credential list responses never contain a secret. Create and rotate responses
|
||||
contain it once. Audit records include identifiers, prefixes, scopes, and the
|
||||
new service-account revision, but never the secret or its hash.
|
||||
|
||||
## Contextual help
|
||||
|
||||
F1 on the service-account page, its editors, scope controls, one-time secret,
|
||||
rotation and revocation actions, activation state, or retirement confirmation
|
||||
resolves to the Access-owned `access.workflow.manage-service-account-credentials`
|
||||
topic. The German reference content distinguishes reversible deactivation from
|
||||
retirement, explains immediate client impact, and states that secrets cannot be
|
||||
recovered. Tenant API-key controls resolve separately to
|
||||
`access.workflow.manage-api-keys`, because their effective authorization also
|
||||
depends on the accountable human owner's current permissions.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Session And Device Management
|
||||
|
||||
Authenticated users can inspect their active browser sessions under **Settings
|
||||
> Sessions and devices**. Each row exposes only a stable session identifier,
|
||||
current-session marker, bounded user-agent label, creation time, last activity,
|
||||
expiry, and lifecycle state. Session tokens, token and CSRF hashes, cookies, IP
|
||||
addresses, and unrelated request metadata are never returned.
|
||||
|
||||
Users may revoke one other session or all other active sessions. The current
|
||||
session is deliberately protected by these operations; use normal logout to end
|
||||
it. Repeating a revocation is safe. Revoked sessions fail authentication on the
|
||||
next request, including when a principal summary was previously cached.
|
||||
|
||||
The shared WebUI clears reusable API response data on explicit authentication,
|
||||
account, tenant, and permission transitions, changed session/CSRF cookies, and
|
||||
authentication-expiry responses. Late reads cannot repopulate caches after those
|
||||
transitions or after a write finishes. `no-store` responses are not retained;
|
||||
`no-cache` responses require server revalidation, with ETags retained only where
|
||||
storage is allowed. Reload bypasses older cached responses. These safeguards do
|
||||
not erase content already displayed by a page: reload that page to reflect
|
||||
remote changes. The server remains authoritative for every permission check.
|
||||
|
||||
Successful interactive sign-in, including re-login, and local sign-out clear
|
||||
the saved automation API key. It must not shadow the newly established cookie
|
||||
session with a different principal. Explicitly applying an API key in connection
|
||||
settings still selects that credential's identity and triggers a new shell
|
||||
authentication check. Ordinary profile updates in API-key mode retain the key.
|
||||
|
||||
Tenant administrators may list sessions only for a membership in their governed
|
||||
tenant and may revoke only a session belonging to that membership and tenant.
|
||||
The mutation requires both the central membership-update permission and an
|
||||
interactive-session password re-authorization. API-key administration and
|
||||
cross-tenant session disclosure fail closed.
|
||||
|
||||
Audit events retain the actor, target session or account, action, and revoked
|
||||
count where applicable. They do not copy client labels, network addresses, or
|
||||
credentials. Expired and revoked sessions are retained according to Access data
|
||||
retention and are omitted from the active-session list.
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -18,11 +18,11 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.11",
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-access"
|
||||
version = "0.1.11"
|
||||
version = "0.1.25"
|
||||
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.11",
|
||||
"govoplan-core>=0.1.45",
|
||||
"redis>=5,<6",
|
||||
"SQLAlchemy>=2,<3",
|
||||
]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN access platform module."""
|
||||
|
||||
__version__ = "0.1.11"
|
||||
__version__ = "0.1.25"
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import case, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, Group, Role, User
|
||||
@@ -12,14 +12,99 @@ from govoplan_core.core.access import AccessAdministration
|
||||
class SqlAccessAdministration(AccessAdministration):
|
||||
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
|
||||
db = _session(session)
|
||||
users, active_users = (
|
||||
db.query(
|
||||
func.count(User.id),
|
||||
func.coalesce(
|
||||
func.sum(case((User.is_active.is_(True), 1), else_=0)),
|
||||
0,
|
||||
),
|
||||
)
|
||||
.filter(User.tenant_id == tenant_id)
|
||||
.one()
|
||||
)
|
||||
api_keys, active_api_keys = (
|
||||
db.query(
|
||||
func.count(ApiKey.id),
|
||||
func.coalesce(
|
||||
func.sum(case((ApiKey.revoked_at.is_(None), 1), else_=0)),
|
||||
0,
|
||||
),
|
||||
)
|
||||
.filter(ApiKey.tenant_id == tenant_id)
|
||||
.one()
|
||||
)
|
||||
return {
|
||||
"users": db.query(User).filter(User.tenant_id == tenant_id).count(),
|
||||
"active_users": db.query(User).filter(User.tenant_id == tenant_id, User.is_active.is_(True)).count(),
|
||||
"users": int(users),
|
||||
"active_users": int(active_users),
|
||||
"groups": db.query(Group).filter(Group.tenant_id == tenant_id).count(),
|
||||
"api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id).count(),
|
||||
"active_api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(),
|
||||
"api_keys": int(api_keys),
|
||||
"active_api_keys": int(active_api_keys),
|
||||
}
|
||||
|
||||
def tenant_counts_many(
|
||||
self,
|
||||
session: object,
|
||||
tenant_ids: Sequence[str],
|
||||
) -> Mapping[str, Mapping[str, int]]:
|
||||
ids = tuple(dict.fromkeys(str(tenant_id) for tenant_id in tenant_ids if tenant_id))
|
||||
if not ids:
|
||||
return {}
|
||||
db = _session(session)
|
||||
counts: dict[str, dict[str, int]] = {
|
||||
tenant_id: {
|
||||
"users": 0,
|
||||
"active_users": 0,
|
||||
"groups": 0,
|
||||
"api_keys": 0,
|
||||
"active_api_keys": 0,
|
||||
}
|
||||
for tenant_id in ids
|
||||
}
|
||||
user_rows = (
|
||||
db.query(
|
||||
User.tenant_id,
|
||||
func.count(User.id),
|
||||
func.coalesce(
|
||||
func.sum(case((User.is_active.is_(True), 1), else_=0)),
|
||||
0,
|
||||
),
|
||||
)
|
||||
.filter(User.tenant_id.in_(ids))
|
||||
.group_by(User.tenant_id)
|
||||
.all()
|
||||
)
|
||||
for tenant_id, users, active_users in user_rows:
|
||||
counts[tenant_id]["users"] = int(users)
|
||||
counts[tenant_id]["active_users"] = int(active_users)
|
||||
|
||||
group_rows = (
|
||||
db.query(Group.tenant_id, func.count(Group.id))
|
||||
.filter(Group.tenant_id.in_(ids))
|
||||
.group_by(Group.tenant_id)
|
||||
.all()
|
||||
)
|
||||
for tenant_id, groups in group_rows:
|
||||
counts[tenant_id]["groups"] = int(groups)
|
||||
|
||||
api_key_rows = (
|
||||
db.query(
|
||||
ApiKey.tenant_id,
|
||||
func.count(ApiKey.id),
|
||||
func.coalesce(
|
||||
func.sum(case((ApiKey.revoked_at.is_(None), 1), else_=0)),
|
||||
0,
|
||||
),
|
||||
)
|
||||
.filter(ApiKey.tenant_id.in_(ids))
|
||||
.group_by(ApiKey.tenant_id)
|
||||
.all()
|
||||
)
|
||||
for tenant_id, api_keys, active_api_keys in api_key_rows:
|
||||
counts[tenant_id]["api_keys"] = int(api_keys)
|
||||
counts[tenant_id]["active_api_keys"] = int(active_api_keys)
|
||||
return counts
|
||||
|
||||
def system_account_count(self, session: object) -> int:
|
||||
db = _session(session)
|
||||
return db.query(Account).count()
|
||||
|
||||
@@ -24,7 +24,6 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
)
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
collect_direct_user_roles,
|
||||
collect_system_roles,
|
||||
collect_user_groups,
|
||||
collect_user_scopes,
|
||||
)
|
||||
@@ -48,7 +47,11 @@ from govoplan_access.backend.db.models import (
|
||||
)
|
||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import OrganizationDirectory
|
||||
from govoplan_access.backend.permissions.catalog import effective_permission_count, expand_scopes
|
||||
from govoplan_access.backend.permissions.catalog import (
|
||||
effective_permission_count,
|
||||
expand_scopes,
|
||||
scopes_grant,
|
||||
)
|
||||
|
||||
|
||||
def _http_admin_error(exc: Exception) -> HTTPException:
|
||||
@@ -358,44 +361,249 @@ def _user_item(
|
||||
)
|
||||
|
||||
|
||||
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
|
||||
memberships = (
|
||||
def _system_membership_rows(
|
||||
session: Session,
|
||||
account_ids: list[str],
|
||||
) -> list[tuple[User, Tenant]]:
|
||||
return (
|
||||
session.query(User, Tenant)
|
||||
.join(Tenant, Tenant.id == User.tenant_id)
|
||||
.filter(User.account_id == account.id)
|
||||
.order_by(Tenant.name.asc())
|
||||
.filter(User.account_id.in_(account_ids))
|
||||
.order_by(User.account_id.asc(), Tenant.name.asc(), User.id.asc())
|
||||
.all()
|
||||
)
|
||||
owner_ids_by_tenant = {
|
||||
tenant.id: tenant_owner_user_ids(session, tenant.id)
|
||||
for _, tenant in memberships
|
||||
|
||||
|
||||
def _memberships_by_account(
|
||||
membership_rows: list[tuple[User, Tenant]],
|
||||
) -> dict[str, list[tuple[User, Tenant]]]:
|
||||
memberships_by_account: dict[str, list[tuple[User, Tenant]]] = defaultdict(list)
|
||||
for user, tenant in membership_rows:
|
||||
memberships_by_account[user.account_id].append((user, tenant))
|
||||
return memberships_by_account
|
||||
|
||||
|
||||
def _system_direct_roles_by_user(
|
||||
session: Session,
|
||||
user_ids: list[str],
|
||||
) -> dict[str, list[Role]]:
|
||||
roles_by_user: dict[str, list[Role]] = defaultdict(list)
|
||||
if not user_ids:
|
||||
return roles_by_user
|
||||
rows = (
|
||||
session.query(UserRoleAssignment.user_id, Role)
|
||||
.join(Role, Role.id == UserRoleAssignment.role_id)
|
||||
.filter(UserRoleAssignment.user_id.in_(user_ids))
|
||||
.order_by(UserRoleAssignment.user_id.asc(), Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
for user_id, role in rows:
|
||||
roles_by_user[user_id].append(role)
|
||||
return roles_by_user
|
||||
|
||||
|
||||
def _system_groups_by_user(
|
||||
session: Session,
|
||||
user_ids: list[str],
|
||||
) -> dict[str, list[Group]]:
|
||||
groups_by_user: dict[str, list[Group]] = defaultdict(list)
|
||||
if not user_ids:
|
||||
return groups_by_user
|
||||
rows = (
|
||||
session.query(UserGroupMembership.user_id, Group)
|
||||
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||
.filter(
|
||||
UserGroupMembership.user_id.in_(user_ids),
|
||||
Group.is_active.is_(True),
|
||||
)
|
||||
.order_by(UserGroupMembership.user_id.asc(), Group.name.asc())
|
||||
.all()
|
||||
)
|
||||
for user_id, group in rows:
|
||||
groups_by_user[user_id].append(group)
|
||||
return groups_by_user
|
||||
|
||||
|
||||
def _system_group_roles_by_user(
|
||||
session: Session,
|
||||
user_ids: list[str],
|
||||
) -> dict[str, list[Role]]:
|
||||
group_roles_by_user: dict[str, list[Role]] = defaultdict(list)
|
||||
if not user_ids:
|
||||
return group_roles_by_user
|
||||
rows = (
|
||||
session.query(UserGroupMembership.user_id, Role)
|
||||
.join(
|
||||
GroupRoleAssignment,
|
||||
GroupRoleAssignment.group_id == UserGroupMembership.group_id,
|
||||
)
|
||||
.join(Role, Role.id == GroupRoleAssignment.role_id)
|
||||
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||
.filter(
|
||||
UserGroupMembership.user_id.in_(user_ids),
|
||||
Group.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for user_id, role in rows:
|
||||
group_roles_by_user[user_id].append(role)
|
||||
return group_roles_by_user
|
||||
|
||||
|
||||
def _system_owner_ids_by_tenant(
|
||||
membership_rows: list[tuple[User, Tenant]],
|
||||
*,
|
||||
accounts_by_id: dict[str, Account],
|
||||
direct_roles_by_user: dict[str, list[Role]],
|
||||
group_roles_by_user: dict[str, list[Role]],
|
||||
) -> dict[str, set[str]]:
|
||||
owner_ids_by_tenant: dict[str, set[str]] = defaultdict(set)
|
||||
for user, tenant in membership_rows:
|
||||
account = accounts_by_id[user.account_id]
|
||||
if not user.is_active or not account.is_active:
|
||||
continue
|
||||
effective_permissions = [
|
||||
permission
|
||||
for role in direct_roles_by_user[user.id] + group_roles_by_user[user.id]
|
||||
for permission in (role.permissions or [])
|
||||
]
|
||||
if (
|
||||
scopes_grant(effective_permissions, "admin:roles:write")
|
||||
and scopes_grant(effective_permissions, "campaign:send")
|
||||
):
|
||||
owner_ids_by_tenant[tenant.id].add(user.id)
|
||||
return owner_ids_by_tenant
|
||||
|
||||
|
||||
def _system_roles_for_accounts(
|
||||
session: Session,
|
||||
account_ids: list[str],
|
||||
) -> tuple[dict[str, list[Role]], dict[str, int]]:
|
||||
system_roles_by_account: dict[str, list[Role]] = defaultdict(list)
|
||||
system_role_ids: set[str] = set()
|
||||
rows = (
|
||||
session.query(SystemRoleAssignment.account_id, Role)
|
||||
.join(Role, Role.id == SystemRoleAssignment.role_id)
|
||||
.filter(
|
||||
SystemRoleAssignment.account_id.in_(account_ids),
|
||||
Role.tenant_id.is_(None),
|
||||
)
|
||||
.order_by(SystemRoleAssignment.account_id.asc(), Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
for account_id, role in rows:
|
||||
system_roles_by_account[account_id].append(role)
|
||||
system_role_ids.add(role.id)
|
||||
return (
|
||||
system_roles_by_account,
|
||||
_system_role_assignment_counts(session, sorted(system_role_ids)),
|
||||
)
|
||||
|
||||
|
||||
def _system_membership_item(
|
||||
user: User,
|
||||
tenant: Tenant,
|
||||
*,
|
||||
roles_by_user: dict[str, list[Role]],
|
||||
groups_by_user: dict[str, list[Group]],
|
||||
owner_ids_by_tenant: dict[str, set[str]],
|
||||
) -> dict[str, object]:
|
||||
tenant_owner_ids = owner_ids_by_tenant[tenant.id]
|
||||
return {
|
||||
"tenant_id": tenant.id,
|
||||
"tenant_name": tenant.name,
|
||||
"user_id": user.id,
|
||||
"is_active": user.is_active and tenant.is_active,
|
||||
"role_ids": [role.id for role in roles_by_user[user.id]],
|
||||
"group_ids": [group.id for group in groups_by_user[user.id]],
|
||||
"is_owner": user.id in tenant_owner_ids,
|
||||
"is_last_active_owner": (
|
||||
user.id in tenant_owner_ids and len(tenant_owner_ids) == 1
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _system_account_response_item(
|
||||
session: Session,
|
||||
account: Account,
|
||||
*,
|
||||
memberships: list[tuple[User, Tenant]],
|
||||
roles_by_user: dict[str, list[Role]],
|
||||
groups_by_user: dict[str, list[Group]],
|
||||
owner_ids_by_tenant: dict[str, set[str]],
|
||||
system_roles: list[Role],
|
||||
system_role_counts: dict[str, int],
|
||||
) -> SystemAccountItem:
|
||||
return SystemAccountItem(
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
is_active=account.is_active,
|
||||
memberships=[
|
||||
{
|
||||
"tenant_id": tenant.id,
|
||||
"tenant_name": tenant.name,
|
||||
"user_id": user.id,
|
||||
"is_active": user.is_active and tenant.is_active,
|
||||
"role_ids": [role.id for role in collect_direct_user_roles(session, user)],
|
||||
"group_ids": [group.id for group in collect_user_groups(session, user)],
|
||||
"is_owner": user.id in owner_ids_by_tenant[tenant.id],
|
||||
"is_last_active_owner": (
|
||||
user.id in owner_ids_by_tenant[tenant.id]
|
||||
and len(owner_ids_by_tenant[tenant.id]) == 1
|
||||
),
|
||||
}
|
||||
_system_membership_item(
|
||||
user,
|
||||
tenant,
|
||||
roles_by_user=roles_by_user,
|
||||
groups_by_user=groups_by_user,
|
||||
owner_ids_by_tenant=owner_ids_by_tenant,
|
||||
)
|
||||
for user, tenant in memberships
|
||||
],
|
||||
roles=[_role_summary(session, role) for role in collect_system_roles(session, account)],
|
||||
roles=[
|
||||
_role_summary(
|
||||
session,
|
||||
role,
|
||||
system_role_assignment_counts=system_role_counts,
|
||||
)
|
||||
for role in system_roles
|
||||
],
|
||||
last_login_at=account.last_login_at,
|
||||
)
|
||||
|
||||
|
||||
def _system_account_items(
|
||||
session: Session,
|
||||
accounts: list[Account],
|
||||
) -> list[SystemAccountItem]:
|
||||
if not accounts:
|
||||
return []
|
||||
account_ids = [account.id for account in accounts]
|
||||
accounts_by_id = {account.id: account for account in accounts}
|
||||
membership_rows = _system_membership_rows(session, account_ids)
|
||||
memberships_by_account = _memberships_by_account(membership_rows)
|
||||
user_ids = [user.id for user, _tenant in membership_rows]
|
||||
roles_by_user = _system_direct_roles_by_user(session, user_ids)
|
||||
groups_by_user = _system_groups_by_user(session, user_ids)
|
||||
group_roles_by_user = _system_group_roles_by_user(session, user_ids)
|
||||
owner_ids_by_tenant = _system_owner_ids_by_tenant(
|
||||
membership_rows,
|
||||
accounts_by_id=accounts_by_id,
|
||||
direct_roles_by_user=roles_by_user,
|
||||
group_roles_by_user=group_roles_by_user,
|
||||
)
|
||||
system_roles_by_account, system_role_counts = _system_roles_for_accounts(
|
||||
session,
|
||||
account_ids,
|
||||
)
|
||||
return [
|
||||
_system_account_response_item(
|
||||
session,
|
||||
account,
|
||||
memberships=memberships_by_account[account.id],
|
||||
roles_by_user=roles_by_user,
|
||||
groups_by_user=groups_by_user,
|
||||
owner_ids_by_tenant=owner_ids_by_tenant,
|
||||
system_roles=system_roles_by_account[account.id],
|
||||
system_role_counts=system_role_counts,
|
||||
)
|
||||
for account in accounts
|
||||
]
|
||||
|
||||
|
||||
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
|
||||
return _system_account_items(session, [account])[0]
|
||||
|
||||
|
||||
def _api_key_item(session: Session, item: ApiKey, *, accounts_by_user_id: dict[str, Account] | None = None) -> ApiKeyAdminItem:
|
||||
if accounts_by_user_id is not None:
|
||||
account = accounts_by_user_id.get(item.user_id)
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, Literal
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
from govoplan_core.i18n import REFERENCE_LANGUAGE_CODE
|
||||
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem
|
||||
|
||||
|
||||
@@ -36,12 +37,43 @@ class AdminOverviewResponse(BaseModel):
|
||||
capabilities: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AdminSessionItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
current: bool
|
||||
status: Literal["active", "expired", "revoked"]
|
||||
created_at: datetime
|
||||
last_seen_at: datetime | None = None
|
||||
expires_at: datetime
|
||||
revoked_at: datetime | None = None
|
||||
client: str | None = None
|
||||
|
||||
|
||||
class AdminSessionListResponse(BaseModel):
|
||||
sessions: list[AdminSessionItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AdminSessionRevocationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
current_password: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class AdminSessionRevocationResponse(BaseModel):
|
||||
session: AdminSessionItem
|
||||
revoked: bool
|
||||
|
||||
|
||||
class TenantAdminItem(BaseModel):
|
||||
id: str
|
||||
slug: str = Field(min_length=1, max_length=100)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
default_locale: str = Field(default="en", min_length=1, max_length=20)
|
||||
default_locale: str = Field(
|
||||
default=REFERENCE_LANGUAGE_CODE,
|
||||
min_length=1,
|
||||
max_length=20,
|
||||
)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
allow_custom_groups: bool | None = None
|
||||
allow_custom_roles: bool | None = None
|
||||
@@ -81,7 +113,7 @@ class TenantCreateRequest(BaseModel):
|
||||
name: str
|
||||
owner_account_id: str | None = None
|
||||
description: str | None = None
|
||||
default_locale: str = "en"
|
||||
default_locale: str = REFERENCE_LANGUAGE_CODE
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
allow_custom_groups: bool | None = None
|
||||
allow_custom_roles: bool | None = None
|
||||
@@ -105,7 +137,11 @@ class TenantSettingsItem(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
default_locale: str = Field(default="en", min_length=1, max_length=20)
|
||||
default_locale: str = Field(
|
||||
default=REFERENCE_LANGUAGE_CODE,
|
||||
min_length=1,
|
||||
max_length=20,
|
||||
)
|
||||
available_languages: list[dict[str, Any]] = Field(default_factory=list)
|
||||
system_enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
@@ -193,7 +229,7 @@ class OrganizationUnitItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OrganizationUnitListResponse(BaseModel):
|
||||
class OrganizationUnitListResponse(PagedListResponse):
|
||||
organization_units: list[OrganizationUnitItem]
|
||||
|
||||
|
||||
@@ -232,7 +268,7 @@ class FunctionAdminItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionListResponse(BaseModel):
|
||||
class FunctionListResponse(PagedListResponse):
|
||||
functions: list[FunctionAdminItem]
|
||||
|
||||
|
||||
@@ -247,7 +283,7 @@ class ExternalFunctionRoleMappingItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingListResponse(BaseModel):
|
||||
class ExternalFunctionRoleMappingListResponse(PagedListResponse):
|
||||
mappings: list[ExternalFunctionRoleMappingItem]
|
||||
|
||||
|
||||
@@ -318,7 +354,7 @@ class FunctionAssignmentAdminItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionAssignmentListResponse(BaseModel):
|
||||
class FunctionAssignmentListResponse(PagedListResponse):
|
||||
assignments: list[FunctionAssignmentAdminItem]
|
||||
|
||||
|
||||
@@ -368,7 +404,7 @@ class FunctionDelegationAdminItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionDelegationListResponse(BaseModel):
|
||||
class FunctionDelegationListResponse(PagedListResponse):
|
||||
delegations: list[FunctionDelegationAdminItem]
|
||||
|
||||
|
||||
@@ -505,6 +541,21 @@ class ResourceAccessExplanationResponse(BaseModel):
|
||||
provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ResourceAccessExplanationSubjectItem(BaseModel):
|
||||
id: str
|
||||
email: str | None = None
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
class ResourceAccessExplanationSubjectsResponse(BaseModel):
|
||||
mode: Literal["current_user", "cross_user"]
|
||||
can_select_other_users: bool
|
||||
reason: str
|
||||
source: str
|
||||
required_scope: str | None = None
|
||||
users: list[ResourceAccessExplanationSubjectItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserListResponse(PagedListResponse):
|
||||
users: list[UserAdminItem]
|
||||
|
||||
@@ -799,6 +850,7 @@ class ConfigurationPackageApplyResponse(BaseModel):
|
||||
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||
created_refs: dict[str, str] = Field(default_factory=dict)
|
||||
updated_refs: dict[str, str] = Field(default_factory=dict)
|
||||
rollback: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ConfigurationPackageExportRequest(BaseModel):
|
||||
@@ -814,10 +866,11 @@ class ConfigurationPackageExportResponse(BaseModel):
|
||||
fragments: list[dict[str, Any]] = Field(default_factory=list)
|
||||
data_requirements: list[dict[str, Any]] = Field(default_factory=list)
|
||||
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||
provenance: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SystemSettingsItem(BaseModel):
|
||||
default_locale: str = "en"
|
||||
default_locale: str = REFERENCE_LANGUAGE_CODE
|
||||
allow_tenant_custom_groups: bool = True
|
||||
allow_tenant_custom_roles: bool = True
|
||||
allow_tenant_api_keys: bool = True
|
||||
@@ -876,6 +929,113 @@ class AdminApiKeyCreateResponse(ApiKeyAdminItem):
|
||||
secret: str
|
||||
|
||||
|
||||
class ServiceAccountItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
scope_ceiling: list[str] = Field(default_factory=list)
|
||||
is_active: bool
|
||||
revision: int
|
||||
created_by_account_id: str | None = None
|
||||
updated_by_account_id: str | None = None
|
||||
retired_at: datetime | None = None
|
||||
credential_count: int = 0
|
||||
active_credential_count: int = 0
|
||||
last_credential_used_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ServiceAccountListResponse(BaseModel):
|
||||
items: list[ServiceAccountItem]
|
||||
|
||||
|
||||
class ServiceAccountCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=4000)
|
||||
scope_ceiling: list[str] = Field(
|
||||
default_factory=list,
|
||||
max_length=200,
|
||||
)
|
||||
|
||||
|
||||
class ServiceAccountUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=4000)
|
||||
scope_ceiling: list[str] | None = Field(
|
||||
default=None,
|
||||
max_length=200,
|
||||
)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class ServiceAccountRetireRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class ServiceAccountCredentialItem(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
prefix: str
|
||||
scopes: list[str] = Field(default_factory=list)
|
||||
expires_at: datetime | None = None
|
||||
last_used_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ServiceAccountCredentialListResponse(BaseModel):
|
||||
service_account_revision: int
|
||||
items: list[ServiceAccountCredentialItem]
|
||||
|
||||
|
||||
class ServiceAccountCredentialCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
scopes: list[str] = Field(min_length=1, max_length=200)
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class ServiceAccountCredentialRotateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
scopes: list[str] | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=200,
|
||||
)
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class ServiceAccountCredentialRevokeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class ServiceAccountCredentialMutationResponse(BaseModel):
|
||||
service_account_revision: int
|
||||
credential: ServiceAccountCredentialItem
|
||||
|
||||
|
||||
class ServiceAccountCredentialSecretResponse(
|
||||
ServiceAccountCredentialMutationResponse
|
||||
):
|
||||
secret: str
|
||||
|
||||
|
||||
class AuditAdminItem(BaseModel):
|
||||
id: str
|
||||
scope: Literal["tenant", "system"] = "tenant"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.api.v1.schemas import (
|
||||
@@ -13,6 +16,7 @@ from govoplan_core.api.v1.schemas import (
|
||||
AuthShellResponse,
|
||||
AuthSessionResponse,
|
||||
AuthSessionUserInfo,
|
||||
EffectiveAppearanceInfo,
|
||||
GroupInfo,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
@@ -21,15 +25,23 @@ from govoplan_core.api.v1.schemas import (
|
||||
ProfileUpdateRequest,
|
||||
RoleInfo,
|
||||
SwitchTenantRequest,
|
||||
SwitchActingContextRequest,
|
||||
TenantInfo,
|
||||
TenantMembershipInfo,
|
||||
UserInfo,
|
||||
UserUiPreferences,
|
||||
)
|
||||
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.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.audit.logging import audit_event
|
||||
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,
|
||||
build_login_throttle,
|
||||
)
|
||||
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
authenticate_session_token,
|
||||
collect_user_authorization_context,
|
||||
@@ -68,10 +81,121 @@ from govoplan_access.backend.security.sessions import (
|
||||
create_auth_session,
|
||||
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"])
|
||||
|
||||
|
||||
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)
|
||||
class AuthContext:
|
||||
account: Account
|
||||
@@ -133,12 +257,23 @@ def _user_ui_preferences(settings_payload: object) -> 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(
|
||||
user: User,
|
||||
account: Account,
|
||||
*,
|
||||
preferred_language: str | None = None,
|
||||
enabled_language_codes: list[str] | None = None,
|
||||
appearance: EffectiveAppearanceInfo | None = None,
|
||||
) -> UserInfo:
|
||||
return UserInfo(
|
||||
id=user.id,
|
||||
@@ -151,6 +286,7 @@ def _user_info(
|
||||
preferred_language=preferred_language,
|
||||
enabled_language_codes=enabled_language_codes or [],
|
||||
ui_preferences=_user_ui_preferences(user.settings),
|
||||
appearance=appearance or EffectiveAppearanceInfo(),
|
||||
)
|
||||
|
||||
|
||||
@@ -471,7 +607,7 @@ def _shell_response(
|
||||
]
|
||||
)
|
||||
return AuthShellResponse(
|
||||
user=_user_info(user, account),
|
||||
user=_user_info(user, account, appearance=_effective_appearance_info(session, tenant=tenant, user=user)),
|
||||
tenant=active_tenant,
|
||||
active_tenant=active_tenant,
|
||||
tenants=memberships,
|
||||
@@ -484,6 +620,12 @@ def _shell_response(
|
||||
auth_method=auth_method, # type: ignore[arg-type]
|
||||
api_key_id=api_key.id if api_key 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,
|
||||
display_name=account.display_name or user.display_name,
|
||||
),
|
||||
@@ -541,6 +683,7 @@ def _profile_response(session: Session, context: AuthContext) -> AuthProfileResp
|
||||
context.account,
|
||||
preferred_language=preferred_language,
|
||||
enabled_language_codes=user_enabled,
|
||||
appearance=_effective_appearance_info(session, tenant=context.tenant, user=context.user),
|
||||
),
|
||||
tenant=active_tenant,
|
||||
active_tenant=active_tenant,
|
||||
@@ -603,6 +746,8 @@ def _me_response(
|
||||
api_key_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
service_account_id: str | None = None,
|
||||
acting_assignment_id: str | None = None,
|
||||
acting_for_account_id: str | None = None,
|
||||
include_system: bool = True,
|
||||
include_all_memberships: bool = True,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
@@ -653,7 +798,13 @@ def _me_response(
|
||||
)
|
||||
]
|
||||
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,
|
||||
active_tenant=active_tenant,
|
||||
tenants=memberships,
|
||||
@@ -675,6 +826,8 @@ def _me_response(
|
||||
api_key_id=api_key_id,
|
||||
session_id=session_id,
|
||||
service_account_id=service_account_id,
|
||||
acting_assignment_id=acting_assignment_id,
|
||||
acting_for_account_id=acting_for_account_id,
|
||||
email=account.email,
|
||||
display_name=account.display_name or user.display_name,
|
||||
).to_dict()
|
||||
@@ -794,6 +947,8 @@ def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session =
|
||||
api_key_id=principal.api_key_id,
|
||||
session_id=principal.session_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_all_memberships=principal.auth_session is not None,
|
||||
identity_id=principal.principal.identity_id,
|
||||
@@ -857,6 +1012,37 @@ def update_profile(
|
||||
if payload.ui_preferences is None:
|
||||
next_settings["ui"] = UserUiPreferences().model_dump()
|
||||
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.update(payload.ui_preferences.model_dump(exclude_unset=True))
|
||||
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")
|
||||
def logout(
|
||||
response: Response,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.admin.governance import (
|
||||
@@ -57,10 +59,15 @@ from govoplan_access.backend.api.v1.admin_common import (
|
||||
_set_system_memberships,
|
||||
_system_role_assignment_counts,
|
||||
_system_account_item,
|
||||
_system_account_items,
|
||||
_tenant_role_assignment_counts,
|
||||
_user_item,
|
||||
)
|
||||
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
AdminSessionItem,
|
||||
AdminSessionListResponse,
|
||||
AdminSessionRevocationRequest,
|
||||
AdminSessionRevocationResponse,
|
||||
AdminApiKeyCreateRequest,
|
||||
AdminApiKeyCreateResponse,
|
||||
ApiKeyAdminItem,
|
||||
@@ -119,6 +126,8 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
RoleSummary,
|
||||
RoleUpdateRequest,
|
||||
ResourceAccessExplanationResponse,
|
||||
ResourceAccessExplanationSubjectItem,
|
||||
ResourceAccessExplanationSubjectsResponse,
|
||||
SystemAccountCreateRequest,
|
||||
SystemAccountCreateResponse,
|
||||
SystemAccountItem,
|
||||
@@ -136,6 +145,14 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
UserUpdateRequest,
|
||||
)
|
||||
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_core.audit.logging import audit_event, audit_from_principal
|
||||
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,
|
||||
ConfigurationExportSelection,
|
||||
ConfigurationPreflightContext,
|
||||
ConfigurationProvider,
|
||||
apply_configuration_package,
|
||||
dry_run_configuration_package,
|
||||
export_configuration_package,
|
||||
validate_configuration_package_catalog,
|
||||
)
|
||||
from govoplan_core.core.infrastructure_capabilities import (
|
||||
InfrastructureCapabilityReceiptError,
|
||||
load_infrastructure_capability_receipt,
|
||||
)
|
||||
from govoplan_core.core.configuration_control import (
|
||||
CONFIGURATION_CHANGE_RECORD_RESOURCE,
|
||||
CONFIGURATION_CHANGE_REQUEST_RESOURCE,
|
||||
@@ -163,7 +185,19 @@ from govoplan_core.core.configuration_control import (
|
||||
record_configuration_change_applied,
|
||||
)
|
||||
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.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory, OrganizationFunctionAssignmentRef
|
||||
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_watermark_is_expired,
|
||||
)
|
||||
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
@@ -357,6 +392,16 @@ def _record_access_change(
|
||||
actor_id=principal.user.id,
|
||||
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:
|
||||
@@ -484,14 +529,42 @@ def _validate_parent_organization_unit(
|
||||
raise AdminValidationError("Parent organization unit does not belong to the tenant.")
|
||||
|
||||
|
||||
def _function_item(session: Session, item: Function) -> FunctionAdminItem:
|
||||
role_ids = [
|
||||
row[0]
|
||||
for row in session.query(FunctionRoleAssignment.role_id)
|
||||
.filter(FunctionRoleAssignment.function_id == item.id)
|
||||
.order_by(FunctionRoleAssignment.created_at.asc())
|
||||
def _function_role_ids_by_function_id(
|
||||
session: Session,
|
||||
function_ids: Iterable[str],
|
||||
) -> dict[str, list[str]]:
|
||||
requested = tuple(dict.fromkeys(function_ids))
|
||||
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()
|
||||
]
|
||||
)
|
||||
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(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
@@ -499,7 +572,7 @@ def _function_item(session: Session, item: Function) -> FunctionAdminItem:
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
role_ids=role_ids,
|
||||
role_ids=role_ids_by_function_id.get(item.id, []),
|
||||
delegable=item.delegable,
|
||||
act_in_place_allowed=item.act_in_place_allowed,
|
||||
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(
|
||||
idm_directory: IdmDirectory | None,
|
||||
user: User,
|
||||
@@ -875,9 +982,10 @@ def configuration_package_catalog_validation(
|
||||
@router.post("/configuration-packages/dry-run", response_model=ConfigurationPackageDryRunResponse)
|
||||
def configuration_package_dry_run_endpoint(
|
||||
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")),
|
||||
):
|
||||
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(
|
||||
diagnostics=[item.to_dict() for item in result.diagnostics],
|
||||
required_data=[item.to_dict() for item in result.required_data],
|
||||
@@ -903,7 +1011,7 @@ def configuration_package_apply_endpoint(
|
||||
)
|
||||
except ConfigurationControlError as 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(
|
||||
session,
|
||||
key="configuration_packages.apply",
|
||||
@@ -928,12 +1036,14 @@ def configuration_package_apply_endpoint(
|
||||
diagnostics=[item.to_dict() for item in result.diagnostics],
|
||||
created_refs=dict(result.created_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)
|
||||
def configuration_package_export_endpoint(
|
||||
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")),
|
||||
):
|
||||
selection = ConfigurationExportSelection(
|
||||
@@ -942,38 +1052,80 @@ def configuration_package_export_endpoint(
|
||||
module_ids=tuple(payload.module_ids),
|
||||
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(
|
||||
fragments=[item.to_dict() for item in result.fragments],
|
||||
data_requirements=[item.to_dict() for item in result.data_requirements],
|
||||
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, ...]:
|
||||
registry = get_registry()
|
||||
if registry is not None and hasattr(registry, "has_capability") and registry.has_capability(ACCESS_CONFIGURATION_CAPABILITY):
|
||||
capability = registry.capability(ACCESS_CONFIGURATION_CAPABILITY)
|
||||
if capability is not None:
|
||||
return (capability,)
|
||||
return (SqlAccessConfigurationProvider(),)
|
||||
providers: dict[str, ConfigurationProvider] = {}
|
||||
if registry is not None and hasattr(registry, "capability_names"):
|
||||
for capability_name in registry.capability_names():
|
||||
if not capability_name.endswith(".configuration"):
|
||||
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()
|
||||
installed_modules: dict[str, str] = {"access": "0.1.6"}
|
||||
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"):
|
||||
manifests = registry.manifests()
|
||||
installed_modules = {manifest.id: manifest.version for manifest in manifests}
|
||||
if hasattr(registry, "has_capability") and registry.has_capability(ACCESS_CONFIGURATION_CAPABILITY):
|
||||
capabilities.add(ACCESS_CONFIGURATION_CAPABILITY)
|
||||
if hasattr(registry, "capability_names"):
|
||||
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(
|
||||
tenant_id=tenant_id or principal.tenant_id,
|
||||
operator_user_id=principal.user.id,
|
||||
operator_scopes=frozenset(getattr(principal, "scopes", ())),
|
||||
supplied_data=supplied_data or {},
|
||||
installed_modules=installed_modules,
|
||||
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)
|
||||
def list_organization_units(
|
||||
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),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
items = (
|
||||
query = (
|
||||
session.query(OrganizationUnit)
|
||||
.filter(OrganizationUnit.tenant_id == tenant.id)
|
||||
.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)
|
||||
@@ -1394,6 +1555,8 @@ def deactivate_organization_unit(
|
||||
def list_functions(
|
||||
tenant_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),
|
||||
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)
|
||||
if organization_unit_id:
|
||||
query = query.filter(Function.organization_unit_id == organization_unit_id)
|
||||
functions = query.order_by(Function.name.asc()).all()
|
||||
return FunctionListResponse(functions=[_function_item(session, item) for item in functions])
|
||||
functions, pagination = _page_query(
|
||||
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)
|
||||
@@ -1535,6 +1716,8 @@ def list_external_function_role_mappings(
|
||||
tenant_id: str | None = None,
|
||||
source_module: 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),
|
||||
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())
|
||||
if function_id:
|
||||
query = query.filter(ExternalFunctionRoleAssignment.function_id == function_id.strip())
|
||||
items = query.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc()).all()
|
||||
return ExternalFunctionRoleMappingListResponse(mappings=[_external_function_role_mapping_item(item) for item in items])
|
||||
items, pagination = _page_query(
|
||||
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:
|
||||
items = (
|
||||
def _full_external_function_role_mappings_delta_response(
|
||||
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)
|
||||
.filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id)
|
||||
.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(
|
||||
mappings=[_external_function_role_mapping_item(item) for item in items],
|
||||
deleted=[],
|
||||
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,)),
|
||||
has_more=False,
|
||||
watermark=watermark,
|
||||
has_more=has_more,
|
||||
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")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
if since is None:
|
||||
return _full_external_function_role_mappings_delta_response(session, tenant)
|
||||
full_cursor = _decode_full_delta_cursor(
|
||||
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)
|
||||
|
||||
|
||||
@@ -1735,6 +1961,8 @@ def list_function_assignments(
|
||||
tenant_id: str | None = None,
|
||||
account_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),
|
||||
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)
|
||||
if function_id:
|
||||
query = query.filter(FunctionAssignment.function_id == function_id)
|
||||
assignments = query.order_by(FunctionAssignment.created_at.desc()).all()
|
||||
return FunctionAssignmentListResponse(assignments=[_function_assignment_item(item) for item in assignments])
|
||||
assignments, pagination = _page_query(
|
||||
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)
|
||||
@@ -1888,6 +2123,8 @@ def deactivate_function_assignment(
|
||||
def list_function_delegations(
|
||||
tenant_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),
|
||||
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)
|
||||
if 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()
|
||||
return FunctionDelegationListResponse(delegations=[_function_delegation_item(item) for item in delegations])
|
||||
delegations, pagination = _page_query(
|
||||
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)
|
||||
@@ -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)
|
||||
def get_resource_access_explanation(
|
||||
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")),
|
||||
):
|
||||
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()
|
||||
if user is None:
|
||||
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,
|
||||
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(
|
||||
user=_user_item_for_response(session, user, idm_directory=idm_directory, organization_directory=organization_directory),
|
||||
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]
|
||||
|
||||
|
||||
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:
|
||||
ensure_default_roles(session, None)
|
||||
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:
|
||||
ensure_default_roles(session, None)
|
||||
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)
|
||||
page = cursor[0] if cursor is not None else 1
|
||||
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)
|
||||
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),
|
||||
deleted=[],
|
||||
watermark=watermark,
|
||||
@@ -3223,7 +3693,7 @@ def _system_accounts_delta_response(session: Session, *, since: str, limit: int)
|
||||
)
|
||||
]
|
||||
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())),
|
||||
deleted=deleted,
|
||||
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)
|
||||
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())
|
||||
accounts, pagination = _page_query(query, page=page, page_size=page_size)
|
||||
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),
|
||||
**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:
|
||||
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:
|
||||
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,))
|
||||
@@ -3617,7 +4094,14 @@ def _api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoke
|
||||
if entries is None:
|
||||
return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked, limit=limit)
|
||||
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:
|
||||
query = query.filter(ApiKey.revoked_at.is_(None))
|
||||
visible = {
|
||||
@@ -3667,7 +4151,14 @@ def list_api_keys(
|
||||
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")),
|
||||
):
|
||||
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:
|
||||
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)
|
||||
@@ -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()
|
||||
if user is None:
|
||||
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(
|
||||
session,
|
||||
user,
|
||||
@@ -3740,7 +4239,16 @@ def revoke_api_key(
|
||||
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:revoke")),
|
||||
):
|
||||
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:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
|
||||
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 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 sqlalchemy.orm import Session
|
||||
@@ -15,15 +17,32 @@ from govoplan_core.core.access import (
|
||||
PrincipalRef,
|
||||
PrincipalResolver,
|
||||
)
|
||||
from govoplan_core.core.automation import (
|
||||
AutomationPrincipalRequest,
|
||||
AutomationPrincipalResolution,
|
||||
)
|
||||
from govoplan_core.core.identity import IdentityDirectory
|
||||
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
|
||||
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.registry import PlatformRegistry
|
||||
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_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.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.sessions import (
|
||||
authenticate_session_token,
|
||||
@@ -32,6 +51,7 @@ from govoplan_access.backend.security.sessions import (
|
||||
verify_auth_session_csrf,
|
||||
)
|
||||
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_core.settings import settings
|
||||
|
||||
@@ -90,6 +110,10 @@ def _build_principal_ref(
|
||||
role_ids = [role.id for role in authorization_context.tenant_roles]
|
||||
if include_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(
|
||||
account_id=account.id,
|
||||
membership_id=user.id,
|
||||
@@ -103,6 +127,10 @@ def _build_principal_ref(
|
||||
auth_method=auth_method, # type: ignore[arg-type]
|
||||
api_key_id=api_key.id if api_key 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,
|
||||
display_name=account.display_name or user.display_name,
|
||||
)
|
||||
@@ -191,6 +219,15 @@ def _resolve_legacy_principal_context(
|
||||
if not 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":
|
||||
context = _resolve_api_key_principal_context(
|
||||
session,
|
||||
@@ -200,7 +237,14 @@ def _resolve_legacy_principal_context(
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
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(
|
||||
request,
|
||||
@@ -212,11 +256,240 @@ def _resolve_legacy_principal_context(
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
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")
|
||||
|
||||
|
||||
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:
|
||||
# A cache hit must retain the same credential-source rules as a cold read.
|
||||
# API keys are explicit-header credentials, never browser session cookies.
|
||||
if principal.auth_method not in {"session", "api_key"} or (
|
||||
principal.auth_method == "api_key" and source == "cookie"
|
||||
):
|
||||
return 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":
|
||||
# Service accounts have a separate current scope ceiling and lifecycle.
|
||||
# Do not reuse an ordinary API-key summary for their backing identities.
|
||||
if account.auth_provider == "service_account" or user.auth_provider == "service_account":
|
||||
return None
|
||||
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 or context.principal.auth_method not in {"session", "api_key"}:
|
||||
# In particular, service-account credentials must keep their dedicated
|
||||
# provenance and be intersected with the current service-account ceiling
|
||||
# on every request, not recomputed from interactive membership roles.
|
||||
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(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -245,11 +518,19 @@ def _resolve_api_key_principal_context(
|
||||
) -> ResolvedPrincipalContext | None:
|
||||
# API keys remain supported for CLI/automation. Their permissions are the
|
||||
# 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:
|
||||
return None
|
||||
user = session.get(User, api_key.user_id)
|
||||
account = session.get(Account, user.account_id) if user else None
|
||||
activity_touch_pending = session.is_modified(
|
||||
api_key,
|
||||
include_collections=False,
|
||||
)
|
||||
user = api_key.user
|
||||
account = user.account if user else None
|
||||
tenant = session.get(Tenant, api_key.tenant_id)
|
||||
if (
|
||||
not user or not account or not tenant
|
||||
@@ -257,6 +538,18 @@ def _resolve_api_key_principal_context(
|
||||
or user.tenant_id != api_key.tenant_id
|
||||
):
|
||||
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(
|
||||
session,
|
||||
user=user,
|
||||
@@ -264,6 +557,7 @@ def _resolve_api_key_principal_context(
|
||||
tenant_id=api_key.tenant_id,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
auth_session=None,
|
||||
)
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
@@ -273,7 +567,6 @@ def _resolve_api_key_principal_context(
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
effective_scopes = intersect_api_key_scopes(authorization_context.scopes, api_key.scopes or [])
|
||||
session.commit()
|
||||
principal = _build_principal_ref(
|
||||
session,
|
||||
api_key=api_key,
|
||||
@@ -287,9 +580,66 @@ def _resolve_api_key_principal_context(
|
||||
extra_roles=idm_roles,
|
||||
authorization_context=authorization_context,
|
||||
)
|
||||
if activity_touch_pending:
|
||||
session.commit()
|
||||
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(
|
||||
request: Request,
|
||||
session: Session,
|
||||
@@ -322,11 +672,19 @@ def _resolve_session_principal_context(
|
||||
identity_directory: IdentityDirectory | None,
|
||||
organization_directory: OrganizationDirectory | 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:
|
||||
return None
|
||||
user = session.get(User, auth_session.user_id)
|
||||
account = session.get(Account, auth_session.account_id)
|
||||
activity_touch_pending = session.is_modified(
|
||||
auth_session,
|
||||
include_collections=False,
|
||||
)
|
||||
user = auth_session.user
|
||||
account = auth_session.account
|
||||
tenant = session.get(Tenant, auth_session.tenant_id)
|
||||
if (
|
||||
not user or not account or not tenant
|
||||
@@ -344,6 +702,7 @@ def _resolve_session_principal_context(
|
||||
tenant_id=user.tenant_id,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
auth_session=auth_session,
|
||||
)
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
@@ -353,7 +712,6 @@ def _resolve_session_principal_context(
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
scopes = authorization_context.scopes
|
||||
session.commit()
|
||||
principal = _build_principal_ref(
|
||||
session,
|
||||
auth_session=auth_session,
|
||||
@@ -368,6 +726,8 @@ def _resolve_session_principal_context(
|
||||
authorization_context=authorization_context,
|
||||
include_system_roles=True,
|
||||
)
|
||||
if activity_touch_pending:
|
||||
session.commit()
|
||||
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, auth_session=auth_session)
|
||||
|
||||
|
||||
@@ -388,8 +748,26 @@ def _principal_idm_context(
|
||||
tenant_id: str,
|
||||
idm_directory: IdmDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
auth_session: AuthSession | None = None,
|
||||
) -> 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))
|
||||
return idm_assignments, idm_roles
|
||||
|
||||
@@ -558,6 +936,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(
|
||||
request: Request,
|
||||
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 typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, JSON, text
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase, TimestampMixin
|
||||
@@ -110,6 +121,91 @@ class User(AccessBase, TimestampMixin):
|
||||
auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ServiceAccount(AccessBase, TimestampMixin):
|
||||
"""Managed non-login principal for current-authority automation."""
|
||||
|
||||
__tablename__ = "access_service_accounts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"normalized_name",
|
||||
name="uq_access_service_accounts_tenant_name",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"account_id",
|
||||
name="uq_access_service_accounts_account",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"membership_id",
|
||||
name="uq_access_service_accounts_membership",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
default=new_uuid,
|
||||
)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
account_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("access_accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
membership_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
normalized_name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
scope_ceiling: Mapped[list[str]] = mapped_column(
|
||||
JSON,
|
||||
default=list,
|
||||
nullable=False,
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
revision: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=1,
|
||||
nullable=False,
|
||||
)
|
||||
created_by_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_accounts.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
updated_by_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_accounts.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
retired_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class Group(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_groups"
|
||||
__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)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
acting_assignment_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
acting_for_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
|
||||
csrf_token_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
@@ -358,6 +456,7 @@ __all__ = [
|
||||
"IdentityAccountLink",
|
||||
"OrganizationUnit",
|
||||
"Role",
|
||||
"ServiceAccount",
|
||||
"SystemRoleAssignment",
|
||||
"Tenant",
|
||||
"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 collections import defaultdict
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Group, GroupRoleAssignment, Role, UserGroupMembership, UserRoleAssignment
|
||||
from govoplan_access.backend.db.models import (
|
||||
Group,
|
||||
GroupRoleAssignment,
|
||||
Role,
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
new_uuid,
|
||||
)
|
||||
from govoplan_core.admin.common import AdminConflictError
|
||||
from govoplan_core.core.access import AccessGovernanceMaterializer, GovernanceTemplateMaterialization
|
||||
from govoplan_core.core.access import (
|
||||
AccessGovernanceMaterializer,
|
||||
AccessGovernanceProjectionV1,
|
||||
GovernanceProjectionBatch,
|
||||
GovernanceProjectionCommand,
|
||||
GovernanceProjectionOutcome,
|
||||
GovernanceProjectionResult,
|
||||
GovernanceTemplateMaterialization,
|
||||
)
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
|
||||
|
||||
class SqlAccessGovernanceMaterializer(AccessGovernanceMaterializer):
|
||||
def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
||||
class SqlAccessGovernanceMaterializer(
|
||||
AccessGovernanceMaterializer,
|
||||
AccessGovernanceProjectionV1,
|
||||
):
|
||||
"""Reconcile governance projections with a constant number of bulk reads."""
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
session: object,
|
||||
batch: GovernanceProjectionBatch,
|
||||
) -> GovernanceProjectionResult:
|
||||
db = _session(session)
|
||||
if template.kind == "group":
|
||||
group = (
|
||||
db.query(Group)
|
||||
.filter(Group.tenant_id == template.tenant_id, Group.system_template_id == template.template_id)
|
||||
.first()
|
||||
commands = tuple(batch.commands)
|
||||
group_commands = tuple(item for item in commands if item.template.kind == "group")
|
||||
role_commands = tuple(item for item in commands if item.template.kind == "role")
|
||||
|
||||
groups, duplicate_group_keys = _managed_groups(db, group_commands)
|
||||
roles, duplicate_role_keys = _managed_roles(db, role_commands)
|
||||
used_group_slugs = _used_slugs(db, Group, group_commands)
|
||||
used_role_slugs = _used_slugs(db, Role, role_commands)
|
||||
|
||||
group_ids = {item.id for item in groups.values()}
|
||||
role_ids = {item.id for item in roles.values()}
|
||||
group_memberships = _assignment_counts(db, UserGroupMembership, UserGroupMembership.group_id, group_ids)
|
||||
group_role_links = _assignment_counts(db, GroupRoleAssignment, GroupRoleAssignment.group_id, group_ids)
|
||||
role_user_links = _assignment_counts(db, UserRoleAssignment, UserRoleAssignment.role_id, role_ids)
|
||||
role_group_links = _assignment_counts(db, GroupRoleAssignment, GroupRoleAssignment.role_id, role_ids)
|
||||
|
||||
outcomes: list[GovernanceProjectionOutcome] = []
|
||||
for command in commands:
|
||||
key = (command.template.tenant_id, command.template.template_id)
|
||||
if command.template.kind == "group":
|
||||
outcome = self._reconcile_group(
|
||||
db,
|
||||
command,
|
||||
groups,
|
||||
duplicate_group_keys,
|
||||
used_group_slugs,
|
||||
group_memberships,
|
||||
group_role_links,
|
||||
dry_run=batch.dry_run,
|
||||
)
|
||||
else:
|
||||
outcome = self._reconcile_role(
|
||||
db,
|
||||
command,
|
||||
roles,
|
||||
duplicate_role_keys,
|
||||
used_role_slugs,
|
||||
role_user_links,
|
||||
role_group_links,
|
||||
dry_run=batch.dry_run,
|
||||
)
|
||||
outcomes.append(outcome)
|
||||
if outcome.status in {"removed", "absent"}:
|
||||
groups.pop(key, None)
|
||||
roles.pop(key, None)
|
||||
|
||||
if not batch.dry_run:
|
||||
db.flush()
|
||||
return GovernanceProjectionResult(
|
||||
operation_id=batch.operation_id,
|
||||
outcomes=tuple(outcomes),
|
||||
dry_run=batch.dry_run,
|
||||
)
|
||||
|
||||
def sync_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
||||
self._legacy_reconcile(session, template, operation="upsert")
|
||||
|
||||
def remove_template(self, session: object, template: GovernanceTemplateMaterialization) -> None:
|
||||
self._legacy_reconcile(session, template, operation="remove")
|
||||
|
||||
def _legacy_reconcile(
|
||||
self,
|
||||
session: object,
|
||||
template: GovernanceTemplateMaterialization,
|
||||
*,
|
||||
operation: str,
|
||||
) -> None:
|
||||
command = GovernanceProjectionCommand(
|
||||
assignment_id=f"legacy:{template.kind}:{template.template_id}:{template.tenant_id}",
|
||||
operation=operation, # type: ignore[arg-type]
|
||||
template=template,
|
||||
provenance={"contract": "access.governanceMaterializer"},
|
||||
)
|
||||
result = self.reconcile(
|
||||
session,
|
||||
GovernanceProjectionBatch(
|
||||
operation_id=command.assignment_id,
|
||||
commands=(command,),
|
||||
),
|
||||
)
|
||||
if result.blocked:
|
||||
raise AdminConflictError(result.blocked[0].message or "Governance projection was blocked.")
|
||||
|
||||
def _reconcile_group(
|
||||
self,
|
||||
db: Session,
|
||||
command: GovernanceProjectionCommand,
|
||||
existing: dict[tuple[str, str], Group],
|
||||
duplicate_keys: set[tuple[str, str]],
|
||||
used_slugs: dict[str, set[str]],
|
||||
membership_counts: dict[str, int],
|
||||
role_counts: dict[str, int],
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> GovernanceProjectionOutcome:
|
||||
template = command.template
|
||||
key = (template.tenant_id, template.template_id)
|
||||
group = existing.get(key)
|
||||
if key in duplicate_keys:
|
||||
return _outcome(
|
||||
command,
|
||||
status="failed",
|
||||
blocker_codes=("duplicate_managed_projection",),
|
||||
message="Multiple managed groups exist for this template and tenant.",
|
||||
)
|
||||
if command.operation == "remove":
|
||||
if group is None:
|
||||
return _outcome(command, status="absent")
|
||||
blockers: list[str] = []
|
||||
if membership_counts.get(group.id, 0):
|
||||
blockers.append("group_has_members")
|
||||
if role_counts.get(group.id, 0):
|
||||
blockers.append("group_has_roles")
|
||||
if blockers:
|
||||
return _outcome(
|
||||
command,
|
||||
status="blocked",
|
||||
resource_id=group.id,
|
||||
blocker_codes=tuple(blockers),
|
||||
message=f"Cannot remove {template.name!r} while its managed group has members or roles.",
|
||||
)
|
||||
if not dry_run:
|
||||
try:
|
||||
_run_delete_vetoes(db, "group", template.tenant_id, group.id)
|
||||
except AdminConflictError as exc:
|
||||
return _outcome(
|
||||
command,
|
||||
status="blocked",
|
||||
resource_id=group.id,
|
||||
blocker_codes=("module_delete_veto",),
|
||||
message=str(exc),
|
||||
)
|
||||
db.delete(group)
|
||||
return _outcome(command, status="removed", resource_id=group.id)
|
||||
|
||||
if group is None:
|
||||
resource_id = new_uuid()
|
||||
slug = _available_slug(used_slugs[template.tenant_id], template.slug)
|
||||
if not dry_run:
|
||||
group = Group(
|
||||
id=resource_id,
|
||||
tenant_id=template.tenant_id,
|
||||
slug=_available_slug(db, Group, template.tenant_id, template.slug),
|
||||
slug=slug,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
is_active=template.is_active,
|
||||
@@ -28,24 +188,72 @@ class SqlAccessGovernanceMaterializer(AccessGovernanceMaterializer):
|
||||
system_required=template.required,
|
||||
)
|
||||
db.add(group)
|
||||
else:
|
||||
group.name = template.name
|
||||
group.description = template.description
|
||||
group.system_required = template.required
|
||||
if template.required:
|
||||
group.is_active = template.is_active
|
||||
db.flush()
|
||||
return
|
||||
existing[key] = group
|
||||
return _outcome(command, status="created", resource_id=resource_id)
|
||||
|
||||
role = (
|
||||
db.query(Role)
|
||||
.filter(Role.tenant_id == template.tenant_id, Role.system_template_id == template.template_id)
|
||||
.first()
|
||||
changes = {
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
"system_required": template.required,
|
||||
}
|
||||
if template.required:
|
||||
changes["is_active"] = template.is_active
|
||||
changed = any(getattr(group, field) != value for field, value in changes.items())
|
||||
if changed and not dry_run:
|
||||
for field, value in changes.items():
|
||||
setattr(group, field, value)
|
||||
return _outcome(command, status="updated" if changed else "unchanged", resource_id=group.id)
|
||||
|
||||
def _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:
|
||||
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(
|
||||
id=resource_id,
|
||||
tenant_id=template.tenant_id,
|
||||
slug=_available_slug(db, Role, template.tenant_id, template.slug),
|
||||
slug=slug,
|
||||
name=template.name,
|
||||
description=template.description,
|
||||
permissions=list(template.permissions),
|
||||
@@ -55,51 +263,124 @@ class SqlAccessGovernanceMaterializer(AccessGovernanceMaterializer):
|
||||
system_required=template.required,
|
||||
)
|
||||
db.add(role)
|
||||
else:
|
||||
role.name = template.name
|
||||
role.description = template.description
|
||||
role.permissions = list(template.permissions)
|
||||
role.system_required = template.required
|
||||
existing[key] = role
|
||||
return _outcome(command, status="created", resource_id=resource_id)
|
||||
|
||||
changes: dict[str, object] = {
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
"permissions": list(template.permissions),
|
||||
"system_required": template.required,
|
||||
}
|
||||
if template.required:
|
||||
role.is_assignable = template.is_active
|
||||
db.flush()
|
||||
changes["is_assignable"] = template.is_active
|
||||
changed = any(getattr(role, field) != value for field, value in changes.items())
|
||||
if changed and not dry_run:
|
||||
for field, value in changes.items():
|
||||
setattr(role, field, value)
|
||||
return _outcome(command, status="updated" if changed else "unchanged", resource_id=role.id)
|
||||
|
||||
def 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 = (
|
||||
db.query(Role)
|
||||
.filter(Role.tenant_id == template.tenant_id, Role.system_template_id == template.template_id)
|
||||
.first()
|
||||
def _managed_groups(
|
||||
session: Session,
|
||||
commands: tuple[GovernanceProjectionCommand, ...],
|
||||
) -> tuple[dict[tuple[str, str], Group], set[tuple[str, str]]]:
|
||||
if not commands:
|
||||
return {}, set()
|
||||
tenants = {item.template.tenant_id for item in commands}
|
||||
templates = {item.template.template_id for item in commands}
|
||||
rows = session.query(Group).filter(
|
||||
Group.tenant_id.in_(tenants),
|
||||
Group.system_template_id.in_(templates),
|
||||
).all()
|
||||
return _indexed_managed(rows)
|
||||
|
||||
|
||||
def _managed_roles(
|
||||
session: Session,
|
||||
commands: tuple[GovernanceProjectionCommand, ...],
|
||||
) -> tuple[dict[tuple[str, str], Role], set[tuple[str, str]]]:
|
||||
if not commands:
|
||||
return {}, set()
|
||||
tenants = {item.template.tenant_id for item in commands}
|
||||
templates = {item.template.template_id for item in commands}
|
||||
rows = session.query(Role).filter(
|
||||
Role.tenant_id.in_(tenants),
|
||||
Role.system_template_id.in_(templates),
|
||||
).all()
|
||||
return _indexed_managed(rows)
|
||||
|
||||
|
||||
def _indexed_managed(rows):
|
||||
indexed = {}
|
||||
duplicates = set()
|
||||
for row in rows:
|
||||
key = (row.tenant_id, row.system_template_id)
|
||||
if key in indexed:
|
||||
duplicates.add(key)
|
||||
else:
|
||||
indexed[key] = row
|
||||
return indexed, duplicates
|
||||
|
||||
|
||||
def _used_slugs(
|
||||
session: Session,
|
||||
model: type[Group] | type[Role],
|
||||
commands: tuple[GovernanceProjectionCommand, ...],
|
||||
) -> dict[str, set[str]]:
|
||||
used: dict[str, set[str]] = defaultdict(set)
|
||||
tenants = {item.template.tenant_id for item in commands}
|
||||
if tenants:
|
||||
for tenant_id, slug in session.query(model.tenant_id, model.slug).filter(model.tenant_id.in_(tenants)).all():
|
||||
used[str(tenant_id)].add(slug)
|
||||
for tenant_id in tenants:
|
||||
used[tenant_id]
|
||||
return used
|
||||
|
||||
|
||||
def _assignment_counts(session: Session, model, column, resource_ids: set[str]) -> dict[str, int]:
|
||||
if not resource_ids:
|
||||
return {}
|
||||
return {
|
||||
resource_id: count
|
||||
for resource_id, count in session.query(column, func.count(model.id))
|
||||
.filter(column.in_(resource_ids))
|
||||
.group_by(column)
|
||||
.all()
|
||||
}
|
||||
|
||||
|
||||
def _available_slug(used: set[str], base: str) -> str:
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while candidate in used:
|
||||
candidate = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
used.add(candidate)
|
||||
return candidate
|
||||
|
||||
|
||||
def _outcome(
|
||||
command: GovernanceProjectionCommand,
|
||||
*,
|
||||
status: str,
|
||||
resource_id: str | None = None,
|
||||
blocker_codes: tuple[str, ...] = (),
|
||||
message: str | None = None,
|
||||
) -> GovernanceProjectionOutcome:
|
||||
template = command.template
|
||||
return GovernanceProjectionOutcome(
|
||||
assignment_id=command.assignment_id,
|
||||
template_id=template.template_id,
|
||||
tenant_id=template.tenant_id,
|
||||
kind=template.kind,
|
||||
operation=command.operation,
|
||||
status=status, # type: ignore[arg-type]
|
||||
resource_id=resource_id,
|
||||
blocker_codes=blocker_codes,
|
||||
message=message,
|
||||
provenance=dict(command.provenance),
|
||||
)
|
||||
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:
|
||||
@@ -119,12 +400,3 @@ def _run_delete_vetoes(session: Session, resource_type: str, tenant_id: str, res
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise AdminConflictError(str(exc)) from exc
|
||||
|
||||
|
||||
def _available_slug(session: Session, model: type[Group] | type[Role], tenant_id: str, base: str) -> str:
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while session.query(model).filter(model.tenant_id == tenant_id, model.slug == candidate).first():
|
||||
candidate = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
"""managed automation service accounts
|
||||
|
||||
Revision ID: b6d9f2a5c8e1
|
||||
Revises: 4a5b6c7d8e9f
|
||||
Create Date: 2026-07-29 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "b6d9f2a5c8e1"
|
||||
down_revision = "4a5b6c7d8e9f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if (
|
||||
"access_service_accounts"
|
||||
in sa.inspect(op.get_bind()).get_table_names()
|
||||
):
|
||||
return
|
||||
op.create_table(
|
||||
"access_service_accounts",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("membership_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("normalized_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("scope_ceiling", sa.JSON(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"created_by_account_id",
|
||||
sa.String(length=36),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_by_account_id",
|
||||
sa.String(length=36),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_account_id_access_accounts"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["created_by_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_created_by_account_id_"
|
||||
"access_accounts"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["membership_id"],
|
||||
["access_users.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_membership_id_access_users"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_tenant_id_core_scopes"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["updated_by_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_updated_by_account_id_"
|
||||
"access_accounts"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_access_service_accounts"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"account_id",
|
||||
name="uq_access_service_accounts_account",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"membership_id",
|
||||
name="uq_access_service_accounts_membership",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"normalized_name",
|
||||
name="uq_access_service_accounts_tenant_name",
|
||||
),
|
||||
)
|
||||
for name, columns in (
|
||||
(
|
||||
"ix_access_service_accounts_account_id",
|
||||
["account_id"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_is_active",
|
||||
["is_active"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_membership_id",
|
||||
["membership_id"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_retired_at",
|
||||
["retired_at"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_tenant_id",
|
||||
["tenant_id"],
|
||||
),
|
||||
):
|
||||
op.create_index(name, "access_service_accounts", columns)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if (
|
||||
"access_service_accounts"
|
||||
in sa.inspect(op.get_bind()).get_table_names()
|
||||
):
|
||||
op.drop_table("access_service_accounts")
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
"""Repair missing external function role mappings without replaying the baseline.
|
||||
|
||||
Revision ID: d8f1b4e7a0c3
|
||||
Revises: b6d9f2a5c8e1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d8f1b4e7a0c3"
|
||||
down_revision = "b6d9f2a5c8e1"
|
||||
branch_labels = None
|
||||
depends_on = "4f2a9c8e7b6d"
|
||||
|
||||
TABLE_NAME = "access_external_function_role_assignments"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Some older installations record the Access baseline without this table.
|
||||
# Never recreate an existing mapping table or derive permission grants.
|
||||
if sa.inspect(op.get_bind()).has_table(TABLE_NAME):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
TABLE_NAME,
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_module", sa.String(length=50), nullable=False),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("role_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["role_id"],
|
||||
["access_roles.id"],
|
||||
name=op.f("fk_access_external_function_role_assignments_role_id_access_roles"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f("fk_access_external_function_role_assignments_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_external_function_role_assignments")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "source_module", "function_id", "role_id",
|
||||
name="uq_external_function_role_assignments",
|
||||
),
|
||||
)
|
||||
for column in ("function_id", "role_id", "source_module", "tenant_id"):
|
||||
op.create_index(op.f(f"ix_{TABLE_NAME}_{column}"), TABLE_NAME, [column], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The table belongs to the baseline, not this repair. Keep mappings created
|
||||
# before or after repair; dropping it would silently remove permission policy.
|
||||
pass
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
"""managed automation service accounts
|
||||
|
||||
Revision ID: b6d9f2a5c8e1
|
||||
Revises: 4a5b6c7d8e9f
|
||||
Create Date: 2026-07-29 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "b6d9f2a5c8e1"
|
||||
down_revision = "4a5b6c7d8e9f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if (
|
||||
"access_service_accounts"
|
||||
in sa.inspect(op.get_bind()).get_table_names()
|
||||
):
|
||||
return
|
||||
op.create_table(
|
||||
"access_service_accounts",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("membership_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("normalized_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("scope_ceiling", sa.JSON(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"created_by_account_id",
|
||||
sa.String(length=36),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_by_account_id",
|
||||
sa.String(length=36),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_account_id_access_accounts"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["created_by_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_created_by_account_id_"
|
||||
"access_accounts"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["membership_id"],
|
||||
["access_users.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_membership_id_access_users"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_tenant_id_core_scopes"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["updated_by_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_updated_by_account_id_"
|
||||
"access_accounts"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_access_service_accounts"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"account_id",
|
||||
name="uq_access_service_accounts_account",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"membership_id",
|
||||
name="uq_access_service_accounts_membership",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"normalized_name",
|
||||
name="uq_access_service_accounts_tenant_name",
|
||||
),
|
||||
)
|
||||
for name, columns in (
|
||||
(
|
||||
"ix_access_service_accounts_account_id",
|
||||
["account_id"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_is_active",
|
||||
["is_active"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_membership_id",
|
||||
["membership_id"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_retired_at",
|
||||
["retired_at"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_tenant_id",
|
||||
["tenant_id"],
|
||||
),
|
||||
):
|
||||
op.create_index(name, "access_service_accounts", columns)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if (
|
||||
"access_service_accounts"
|
||||
in sa.inspect(op.get_bind()).get_table_names()
|
||||
):
|
||||
op.drop_table("access_service_accounts")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Persist explicit interactive acting-in-place context.
|
||||
|
||||
Revision ID: c7e0a3d6f9b2
|
||||
Revises: b6d9f2a5c8e1
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c7e0a3d6f9b2"
|
||||
down_revision = "b6d9f2a5c8e1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"access_auth_sessions",
|
||||
sa.Column("acting_assignment_id", sa.String(length=36), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"access_auth_sessions",
|
||||
sa.Column("acting_for_account_id", sa.String(length=36), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_access_auth_sessions_acting_assignment_id"),
|
||||
"access_auth_sessions",
|
||||
["acting_assignment_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_access_auth_sessions_acting_assignment_id"),
|
||||
table_name="access_auth_sessions",
|
||||
)
|
||||
op.drop_column("access_auth_sessions", "acting_for_account_id")
|
||||
op.drop_column("access_auth_sessions", "acting_assignment_id")
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
"""Repair missing external function role mappings without replaying the baseline.
|
||||
|
||||
Revision ID: d8f1b4e7a0c3
|
||||
Revises: c7e0a3d6f9b2
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d8f1b4e7a0c3"
|
||||
down_revision = "c7e0a3d6f9b2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TABLE_NAME = "access_external_function_role_assignments"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Some older installations record the Access baseline without this table.
|
||||
# Never recreate an existing mapping table or derive permission grants.
|
||||
if sa.inspect(op.get_bind()).has_table(TABLE_NAME):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
TABLE_NAME,
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_module", sa.String(length=50), nullable=False),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("role_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["role_id"],
|
||||
["access_roles.id"],
|
||||
name=op.f("fk_access_external_function_role_assignments_role_id_access_roles"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f("fk_access_external_function_role_assignments_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_external_function_role_assignments")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "source_module", "function_id", "role_id",
|
||||
name="uq_external_function_role_assignments",
|
||||
),
|
||||
)
|
||||
for column in ("function_id", "role_id", "source_module", "tenant_id"):
|
||||
op.create_index(op.f(f"ix_{TABLE_NAME}_{column}"), TABLE_NAME, [column], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The table belongs to the baseline, not this repair. Keep mappings created
|
||||
# before or after repair; dropping it would silently remove permission policy.
|
||||
pass
|
||||
@@ -212,11 +212,29 @@ def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[st
|
||||
allowed.update(
|
||||
scope
|
||||
for scope in user_raw.intersection(key_raw)
|
||||
if not scope.startswith("system:") and scope not in {"*", "tenant:*"}
|
||||
if _is_concrete_tenant_credential_scope(scope, catalog)
|
||||
)
|
||||
return sorted(allowed)
|
||||
|
||||
|
||||
def _is_concrete_tenant_credential_scope(
|
||||
scope: str,
|
||||
catalog: Mapping[str, PermissionDefinition],
|
||||
) -> bool:
|
||||
# Wildcards are expanded against the tenant catalogue above. Returning the
|
||||
# wildcard itself could grant system permissions sharing the module prefix,
|
||||
# or permissions outside the currently known tenant catalogue.
|
||||
if scope == "*" or scope.endswith(":*"):
|
||||
return False
|
||||
# System permissions can use module-native names (e.g. access:tenant:create),
|
||||
# so excluding only the historical system: prefix is not sufficient.
|
||||
return all(
|
||||
not alias.startswith("system:")
|
||||
and (alias not in catalog or catalog[alias].level == "tenant")
|
||||
for alias in compatible_required_scopes(scope)
|
||||
)
|
||||
|
||||
|
||||
def _active_permission_definitions() -> tuple[PermissionDefinition, ...]:
|
||||
registry = _registry()
|
||||
if registry is not None and hasattr(registry, "permissions"):
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_core.core.references import (
|
||||
ReferenceOption,
|
||||
ReferenceSearchPage,
|
||||
ReferenceSearchRequest,
|
||||
)
|
||||
|
||||
|
||||
class SqlAccessReferenceOptionProvider:
|
||||
"""Principal-aware, bounded Access directory search."""
|
||||
|
||||
def search_reference_options(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ReferenceSearchRequest,
|
||||
) -> ReferenceSearchPage:
|
||||
db = _session(session)
|
||||
tenant_id = str(request.tenant_id or "").strip()
|
||||
if not tenant_id:
|
||||
return ReferenceSearchPage()
|
||||
limit = max(1, min(int(request.limit), 200))
|
||||
offset = _cursor_offset(request.cursor)
|
||||
selected = tuple(
|
||||
dict.fromkeys(
|
||||
str(value).strip()
|
||||
for value in request.selected_values
|
||||
if str(value).strip()
|
||||
)
|
||||
)[:200]
|
||||
administrative = request.context.get("administrative") is True
|
||||
query = str(request.query or "").strip().casefold()
|
||||
if request.kind in {"user", "membership"}:
|
||||
return _search_users(
|
||||
db,
|
||||
principal,
|
||||
tenant_id=tenant_id,
|
||||
kind=request.kind,
|
||||
query=query,
|
||||
selected=selected,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
administrative=administrative,
|
||||
)
|
||||
if request.kind == "group":
|
||||
return _search_groups(
|
||||
db,
|
||||
principal,
|
||||
tenant_id=tenant_id,
|
||||
query=query,
|
||||
selected=selected,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
administrative=administrative,
|
||||
)
|
||||
raise ValueError(f"Unsupported Access reference kind: {request.kind}")
|
||||
|
||||
|
||||
def _search_users(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
kind: str,
|
||||
query: str,
|
||||
selected: Sequence[str],
|
||||
limit: int,
|
||||
offset: int,
|
||||
administrative: bool,
|
||||
) -> ReferenceSearchPage:
|
||||
value_column = User.id if kind == "membership" else User.account_id
|
||||
base = (
|
||||
session.query(User, Account)
|
||||
.join(Account, Account.id == User.account_id)
|
||||
.filter(User.tenant_id == tenant_id)
|
||||
)
|
||||
if not administrative:
|
||||
account_id = str(getattr(principal, "account_id", "") or "")
|
||||
if not account_id:
|
||||
return ReferenceSearchPage()
|
||||
base = base.filter(User.account_id == account_id)
|
||||
|
||||
selected_rows = (
|
||||
base.filter(value_column.in_(selected)).all()
|
||||
if selected
|
||||
else []
|
||||
)
|
||||
search_query = base
|
||||
if selected:
|
||||
search_query = search_query.filter(value_column.notin_(selected))
|
||||
if query:
|
||||
search_query = search_query.filter(
|
||||
or_(
|
||||
func.lower(func.coalesce(User.display_name, "")).contains(
|
||||
query,
|
||||
autoescape=True,
|
||||
),
|
||||
func.lower(User.email).contains(query, autoescape=True),
|
||||
func.lower(Account.email).contains(query, autoescape=True),
|
||||
func.lower(value_column).contains(query, autoescape=True),
|
||||
)
|
||||
)
|
||||
rows = (
|
||||
search_query.order_by(
|
||||
func.lower(func.coalesce(User.display_name, User.email)).asc(),
|
||||
value_column.asc(),
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
options = [
|
||||
_user_option(user, account, kind=kind)
|
||||
for user, account in rows[:limit]
|
||||
]
|
||||
selected_by_value = {
|
||||
_user_value(user, kind=kind): _user_option(user, account, kind=kind)
|
||||
for user, account in selected_rows
|
||||
}
|
||||
options.extend(
|
||||
selected_by_value[value]
|
||||
for value in selected
|
||||
if value in selected_by_value
|
||||
)
|
||||
return ReferenceSearchPage(
|
||||
options=tuple(options),
|
||||
next_cursor=f"offset:{offset + limit}" if has_more else None,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
def _search_groups(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
query: str,
|
||||
selected: Sequence[str],
|
||||
limit: int,
|
||||
offset: int,
|
||||
administrative: bool,
|
||||
) -> ReferenceSearchPage:
|
||||
base = session.query(Group).filter(Group.tenant_id == tenant_id)
|
||||
if not administrative:
|
||||
permitted = tuple(
|
||||
dict.fromkeys(
|
||||
str(group_id)
|
||||
for group_id in getattr(principal, "group_ids", ())
|
||||
if str(group_id)
|
||||
)
|
||||
)
|
||||
if not permitted:
|
||||
return ReferenceSearchPage()
|
||||
base = base.filter(Group.id.in_(permitted))
|
||||
|
||||
selected_rows = base.filter(Group.id.in_(selected)).all() if selected else []
|
||||
search_query = base
|
||||
if selected:
|
||||
search_query = search_query.filter(Group.id.notin_(selected))
|
||||
if query:
|
||||
search_query = search_query.filter(
|
||||
or_(
|
||||
func.lower(Group.name).contains(query, autoescape=True),
|
||||
func.lower(Group.slug).contains(query, autoescape=True),
|
||||
func.lower(Group.id).contains(query, autoescape=True),
|
||||
)
|
||||
)
|
||||
rows = (
|
||||
search_query.order_by(func.lower(Group.name).asc(), Group.id.asc())
|
||||
.offset(offset)
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
options = [_group_option(group) for group in rows[:limit]]
|
||||
selected_by_value = {group.id: _group_option(group) for group in selected_rows}
|
||||
options.extend(
|
||||
selected_by_value[value]
|
||||
for value in selected
|
||||
if value in selected_by_value
|
||||
)
|
||||
return ReferenceSearchPage(
|
||||
options=tuple(options),
|
||||
next_cursor=f"offset:{offset + limit}" if has_more else None,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
def _user_value(user: User, *, kind: str) -> str:
|
||||
return user.id if kind == "membership" else user.account_id
|
||||
|
||||
|
||||
def _user_option(user: User, account: Account, *, kind: str) -> ReferenceOption:
|
||||
inactive = not user.is_active or not account.is_active
|
||||
value = _user_value(user, kind=kind)
|
||||
description_parts = [
|
||||
user.email,
|
||||
"Inactive" if inactive else None,
|
||||
]
|
||||
return ReferenceOption(
|
||||
value=value,
|
||||
label=user.display_name or user.email or value,
|
||||
description=" · ".join(
|
||||
part for part in description_parts if part
|
||||
) or None,
|
||||
kind=kind,
|
||||
availability="inactive" if inactive else "available",
|
||||
disabled=inactive,
|
||||
source_module="access",
|
||||
provenance={
|
||||
"tenant_id": user.tenant_id,
|
||||
"membership_id": user.id,
|
||||
"account_id": user.account_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _group_option(group: Group) -> ReferenceOption:
|
||||
inactive = not group.is_active
|
||||
return ReferenceOption(
|
||||
value=group.id,
|
||||
label=group.name or group.id,
|
||||
description="Inactive" if inactive else None,
|
||||
kind="group",
|
||||
availability="inactive" if inactive else "available",
|
||||
disabled=inactive,
|
||||
source_module="access",
|
||||
provenance={"tenant_id": group.tenant_id, "group_id": group.id},
|
||||
)
|
||||
|
||||
|
||||
def _cursor_offset(cursor: str | None) -> int:
|
||||
if cursor is None:
|
||||
return 0
|
||||
prefix = "offset:"
|
||||
if not cursor.startswith(prefix):
|
||||
raise ValueError("Invalid reference search cursor.")
|
||||
try:
|
||||
offset = int(cursor[len(prefix):])
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid reference search cursor.") from exc
|
||||
if offset < 0:
|
||||
raise ValueError("Invalid reference search cursor.")
|
||||
return offset
|
||||
|
||||
|
||||
def _session(session: object) -> Session:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("Access reference search requires a SQLAlchemy Session")
|
||||
return session
|
||||
|
||||
|
||||
__all__ = ["SqlAccessReferenceOptionProvider"]
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
|
||||
from govoplan_access.backend.db.models import ApiKey, User
|
||||
@@ -11,6 +11,7 @@ from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
|
||||
API_KEY_PREFIX_LENGTH = 12
|
||||
API_KEY_RANDOM_BYTES = 32
|
||||
API_KEY_SECRET_PREFIX = "gpn_"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -28,7 +29,7 @@ def verify_api_key(secret: str, expected_hash: str) -> bool:
|
||||
|
||||
|
||||
def generate_api_key_secret() -> str:
|
||||
return generate_secret("mm_", random_bytes=API_KEY_RANDOM_BYTES)
|
||||
return generate_secret(API_KEY_SECRET_PREFIX, random_bytes=API_KEY_RANDOM_BYTES)
|
||||
|
||||
|
||||
def api_key_prefix(secret: str) -> str:
|
||||
@@ -60,15 +61,34 @@ def create_api_key(
|
||||
return CreatedApiKey(model=model, secret=secret)
|
||||
|
||||
|
||||
def authenticate_api_key(session: Session, secret: str) -> ApiKey | None:
|
||||
def authenticate_api_key(
|
||||
session: Session,
|
||||
secret: str,
|
||||
*,
|
||||
touch_interval_seconds: int = 5 * 60,
|
||||
) -> ApiKey | None:
|
||||
prefix = api_key_prefix(secret)
|
||||
candidates = session.query(ApiKey).filter(ApiKey.prefix == prefix, ApiKey.revoked_at.is_(None)).all()
|
||||
candidates = (
|
||||
session.query(ApiKey)
|
||||
.options(joinedload(ApiKey.user).joinedload(User.account))
|
||||
.filter(
|
||||
ApiKey.prefix == prefix,
|
||||
ApiKey.revoked_at.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
now = utc_now()
|
||||
for candidate in candidates:
|
||||
expires_at = ensure_aware_utc(candidate.expires_at)
|
||||
if expires_at and expires_at < now:
|
||||
continue
|
||||
if verify_api_key(secret, candidate.key_hash):
|
||||
last_used_at = ensure_aware_utc(candidate.last_used_at)
|
||||
if (
|
||||
touch_interval_seconds <= 0
|
||||
or last_used_at is None
|
||||
or now - last_used_at >= timedelta(seconds=touch_interval_seconds)
|
||||
):
|
||||
candidate.last_used_at = now
|
||||
session.add(candidate)
|
||||
return candidate
|
||||
@@ -78,4 +98,3 @@ def authenticate_api_key(session: Session, secret: str) -> ApiKey | None:
|
||||
def has_scope(api_key: ApiKey, required_scope: str) -> bool:
|
||||
scopes = set(api_key.scopes or [])
|
||||
return "*" in scopes or required_scope in scopes
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
|
||||
from govoplan_access.backend.db.models import (
|
||||
@@ -104,15 +104,37 @@ def create_auth_session(
|
||||
return CreatedSession(model=model, token=token, csrf_token=csrf_token)
|
||||
|
||||
|
||||
def authenticate_session_token(session: Session, token: str) -> AuthSession | None:
|
||||
def authenticate_session_token(
|
||||
session: Session,
|
||||
token: str,
|
||||
*,
|
||||
touch_interval_seconds: int = 5 * 60,
|
||||
) -> AuthSession | None:
|
||||
token_hash = hash_session_token(token)
|
||||
model = session.query(AuthSession).filter(AuthSession.token_hash == token_hash, AuthSession.revoked_at.is_(None)).one_or_none()
|
||||
model = (
|
||||
session.query(AuthSession)
|
||||
.options(
|
||||
joinedload(AuthSession.user).joinedload(User.account),
|
||||
joinedload(AuthSession.account),
|
||||
)
|
||||
.filter(
|
||||
AuthSession.token_hash == token_hash,
|
||||
AuthSession.revoked_at.is_(None),
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if not model:
|
||||
return None
|
||||
now = utc_now()
|
||||
expires_at = ensure_aware_utc(model.expires_at)
|
||||
if expires_at is None or expires_at < now:
|
||||
return None
|
||||
last_seen_at = ensure_aware_utc(model.last_seen_at)
|
||||
if (
|
||||
touch_interval_seconds <= 0
|
||||
or last_seen_at is None
|
||||
or now - last_seen_at >= timedelta(seconds=touch_interval_seconds)
|
||||
):
|
||||
model.last_seen_at = now
|
||||
session.add(model)
|
||||
return model
|
||||
@@ -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.")
|
||||
auth_session.tenant_id = membership.tenant_id
|
||||
auth_session.user_id = membership.id
|
||||
auth_session.acting_assignment_id = None
|
||||
auth_session.acting_for_account_id = None
|
||||
auth_session.last_seen_at = utc_now()
|
||||
session.add(auth_session)
|
||||
session.flush()
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.base import utcnow
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
ServiceAccount,
|
||||
Tenant,
|
||||
User,
|
||||
new_uuid,
|
||||
)
|
||||
from govoplan_access.backend.permissions.catalog import scopes_grant
|
||||
from govoplan_access.backend.security.api_keys import (
|
||||
CreatedApiKey,
|
||||
create_api_key,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
|
||||
|
||||
class ServiceAccountError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceAccountNotFoundError(ServiceAccountError):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceAccountConflictError(ServiceAccountError):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceAccountCredentialNotFoundError(ServiceAccountError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ServiceAccountCredentialSummary:
|
||||
credential_count: int = 0
|
||||
active_credential_count: int = 0
|
||||
last_credential_used_at: datetime | None = None
|
||||
|
||||
|
||||
def list_service_accounts(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> list[ServiceAccount]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(ServiceAccount)
|
||||
.where(ServiceAccount.tenant_id == tenant_id)
|
||||
.order_by(
|
||||
ServiceAccount.normalized_name,
|
||||
ServiceAccount.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def service_account_credential_summaries(
|
||||
session: Session,
|
||||
*,
|
||||
service_accounts: Iterable[ServiceAccount],
|
||||
) -> dict[str, ServiceAccountCredentialSummary]:
|
||||
items = tuple(service_accounts)
|
||||
by_membership = {item.membership_id: item.id for item in items}
|
||||
usable_accounts = {
|
||||
item.id
|
||||
for item in items
|
||||
if item.is_active and item.retired_at is None
|
||||
}
|
||||
summaries = {
|
||||
item.id: ServiceAccountCredentialSummary()
|
||||
for item in items
|
||||
}
|
||||
if not by_membership:
|
||||
return summaries
|
||||
now = utc_now()
|
||||
totals: dict[str, int] = {}
|
||||
active: dict[str, int] = {}
|
||||
last_used: dict[str, datetime | None] = {}
|
||||
credentials = session.scalars(
|
||||
select(ApiKey).where(ApiKey.user_id.in_(by_membership))
|
||||
)
|
||||
for credential in credentials:
|
||||
service_account_id = by_membership[credential.user_id]
|
||||
totals[service_account_id] = totals.get(service_account_id, 0) + 1
|
||||
expires_at = ensure_aware_utc(credential.expires_at)
|
||||
if (
|
||||
service_account_id in usable_accounts
|
||||
and
|
||||
credential.revoked_at is None
|
||||
and (expires_at is None or expires_at > now)
|
||||
):
|
||||
active[service_account_id] = (
|
||||
active.get(service_account_id, 0) + 1
|
||||
)
|
||||
used_at = ensure_aware_utc(credential.last_used_at)
|
||||
if used_at is not None and (
|
||||
last_used.get(service_account_id) is None
|
||||
or used_at > last_used[service_account_id]
|
||||
):
|
||||
last_used[service_account_id] = used_at
|
||||
return {
|
||||
item.id: ServiceAccountCredentialSummary(
|
||||
credential_count=totals.get(item.id, 0),
|
||||
active_credential_count=active.get(item.id, 0),
|
||||
last_credential_used_at=last_used.get(item.id),
|
||||
)
|
||||
for item in items
|
||||
}
|
||||
|
||||
|
||||
def list_service_account_credentials(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
include_revoked: bool = True,
|
||||
) -> tuple[ServiceAccount, list[ApiKey]]:
|
||||
item = get_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
)
|
||||
query = select(ApiKey).where(
|
||||
ApiKey.tenant_id == tenant_id,
|
||||
ApiKey.user_id == item.membership_id,
|
||||
)
|
||||
if not include_revoked:
|
||||
query = query.where(ApiKey.revoked_at.is_(None))
|
||||
credentials = list(
|
||||
session.scalars(
|
||||
query.order_by(ApiKey.created_at.desc(), ApiKey.id)
|
||||
)
|
||||
)
|
||||
return item, credentials
|
||||
|
||||
|
||||
def create_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
name: str,
|
||||
scopes: Iterable[str],
|
||||
expires_at: datetime | None,
|
||||
) -> tuple[ServiceAccount, CreatedApiKey]:
|
||||
item = _locked_service_account_for_credential_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
user = _active_service_account_membership(session, item)
|
||||
credential_scopes = _service_account_credential_scopes(
|
||||
principal,
|
||||
item,
|
||||
scopes,
|
||||
)
|
||||
created = create_api_key(
|
||||
session,
|
||||
user=user,
|
||||
name=_credential_name(name),
|
||||
scopes=list(credential_scopes),
|
||||
expires_at=_future_expiry(expires_at),
|
||||
)
|
||||
_touch_service_account(item, principal)
|
||||
session.flush()
|
||||
return item, created
|
||||
|
||||
|
||||
def rotate_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
name: str | None,
|
||||
scopes: Iterable[str] | None,
|
||||
expires_at: datetime | None,
|
||||
) -> tuple[ServiceAccount, ApiKey, CreatedApiKey]:
|
||||
item = _locked_service_account_for_credential_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
user = _active_service_account_membership(session, item)
|
||||
previous = _locked_service_account_credential(
|
||||
session,
|
||||
item=item,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
if previous.revoked_at is not None:
|
||||
raise ServiceAccountConflictError(
|
||||
"The credential is already revoked; reload before rotating"
|
||||
)
|
||||
requested_scopes = previous.scopes if scopes is None else scopes
|
||||
credential_scopes = _service_account_credential_scopes(
|
||||
principal,
|
||||
item,
|
||||
requested_scopes,
|
||||
)
|
||||
created = create_api_key(
|
||||
session,
|
||||
user=user,
|
||||
name=_credential_name(name or previous.name),
|
||||
scopes=list(credential_scopes),
|
||||
expires_at=_future_expiry(expires_at),
|
||||
)
|
||||
previous.revoked_at = utc_now()
|
||||
_touch_service_account(item, principal)
|
||||
session.flush()
|
||||
return item, previous, created
|
||||
|
||||
|
||||
def revoke_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
) -> tuple[ServiceAccount, ApiKey]:
|
||||
item = _locked_service_account_for_credential_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
credential = _locked_service_account_credential(
|
||||
session,
|
||||
item=item,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
if credential.revoked_at is None:
|
||||
credential.revoked_at = utc_now()
|
||||
_touch_service_account(item, principal)
|
||||
session.flush()
|
||||
return item, credential
|
||||
|
||||
|
||||
def get_service_account(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
lock: bool = False,
|
||||
) -> ServiceAccount:
|
||||
query = select(ServiceAccount).where(
|
||||
ServiceAccount.id == service_account_id,
|
||||
ServiceAccount.tenant_id == tenant_id,
|
||||
)
|
||||
if lock:
|
||||
query = query.with_for_update()
|
||||
item = session.scalar(query)
|
||||
if item is None:
|
||||
raise ServiceAccountNotFoundError(
|
||||
"Service account was not found"
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
def create_service_account(
|
||||
session: Session,
|
||||
*,
|
||||
tenant: Tenant,
|
||||
principal: ApiPrincipal,
|
||||
name: str,
|
||||
description: str | None,
|
||||
scope_ceiling: Iterable[str],
|
||||
) -> ServiceAccount:
|
||||
clean_name = _service_account_name(name)
|
||||
scopes = _service_account_scopes(
|
||||
principal,
|
||||
scope_ceiling,
|
||||
)
|
||||
service_account_id = new_uuid()
|
||||
internal_email = (
|
||||
f"service-account-{service_account_id}@govoplan.invalid"
|
||||
)
|
||||
account = Account(
|
||||
id=new_uuid(),
|
||||
email=internal_email,
|
||||
normalized_email=internal_email,
|
||||
display_name=clean_name,
|
||||
is_active=True,
|
||||
auth_provider="service_account",
|
||||
password_hash=None,
|
||||
password_reset_required=False,
|
||||
)
|
||||
membership = User(
|
||||
id=new_uuid(),
|
||||
tenant_id=tenant.id,
|
||||
account=account,
|
||||
email=internal_email,
|
||||
display_name=clean_name,
|
||||
is_active=True,
|
||||
is_tenant_admin=False,
|
||||
auth_provider="service_account",
|
||||
password_hash=None,
|
||||
settings={"managed_service_account": service_account_id},
|
||||
)
|
||||
item = ServiceAccount(
|
||||
id=service_account_id,
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
membership_id=membership.id,
|
||||
name=clean_name,
|
||||
normalized_name=_normalized_name(clean_name),
|
||||
description=_optional_text(description),
|
||||
scope_ceiling=list(scopes),
|
||||
is_active=True,
|
||||
revision=1,
|
||||
created_by_account_id=principal.account_id,
|
||||
updated_by_account_id=principal.account_id,
|
||||
settings={},
|
||||
)
|
||||
session.add_all((account, membership, item))
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError as exc:
|
||||
raise ServiceAccountConflictError(
|
||||
"A service account with this name already exists"
|
||||
) from exc
|
||||
return item
|
||||
|
||||
|
||||
def update_service_account(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
changes: Mapping[str, object],
|
||||
) -> ServiceAccount:
|
||||
item = get_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
lock=True,
|
||||
)
|
||||
if item.revision != expected_revision:
|
||||
raise ServiceAccountConflictError(
|
||||
"Service account changed on the server; reload before saving"
|
||||
)
|
||||
account = session.get(Account, item.account_id)
|
||||
membership = session.get(User, item.membership_id)
|
||||
if account is None or membership is None:
|
||||
raise ServiceAccountConflictError(
|
||||
"Service account backing identity is missing"
|
||||
)
|
||||
if "name" in changes:
|
||||
clean_name = _service_account_name(str(changes["name"]))
|
||||
item.name = clean_name
|
||||
item.normalized_name = _normalized_name(clean_name)
|
||||
account.display_name = clean_name
|
||||
membership.display_name = clean_name
|
||||
if "description" in changes:
|
||||
value = changes["description"]
|
||||
item.description = _optional_text(
|
||||
str(value) if value is not None else None
|
||||
)
|
||||
if "scope_ceiling" in changes:
|
||||
raw_scopes = changes["scope_ceiling"]
|
||||
if not isinstance(raw_scopes, Iterable) or isinstance(
|
||||
raw_scopes,
|
||||
(str, bytes),
|
||||
):
|
||||
raise ServiceAccountError(
|
||||
"Service account scope ceiling is invalid"
|
||||
)
|
||||
item.scope_ceiling = list(
|
||||
_service_account_scopes(
|
||||
principal,
|
||||
(str(scope) for scope in raw_scopes),
|
||||
)
|
||||
)
|
||||
if "is_active" in changes:
|
||||
active = bool(changes["is_active"])
|
||||
item.is_active = active
|
||||
account.is_active = active
|
||||
membership.is_active = active
|
||||
item.retired_at = None if active else utcnow()
|
||||
item.revision += 1
|
||||
item.updated_by_account_id = principal.account_id
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError as exc:
|
||||
raise ServiceAccountConflictError(
|
||||
"A service account with this name already exists"
|
||||
) from exc
|
||||
return item
|
||||
|
||||
|
||||
def retire_service_account(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
) -> ServiceAccount:
|
||||
item = update_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
principal=principal,
|
||||
expected_revision=expected_revision,
|
||||
changes={"is_active": False},
|
||||
)
|
||||
now = utc_now()
|
||||
credentials = session.scalars(
|
||||
select(ApiKey).where(
|
||||
ApiKey.tenant_id == tenant_id,
|
||||
ApiKey.user_id == item.membership_id,
|
||||
ApiKey.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
for credential in credentials:
|
||||
credential.revoked_at = now
|
||||
session.flush()
|
||||
return item
|
||||
|
||||
|
||||
def _locked_service_account_for_credential_change(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
expected_revision: int,
|
||||
) -> ServiceAccount:
|
||||
item = get_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
lock=True,
|
||||
)
|
||||
if item.revision != expected_revision:
|
||||
raise ServiceAccountConflictError(
|
||||
"Service account changed on the server; reload before changing credentials"
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
def _locked_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
item: ServiceAccount,
|
||||
credential_id: str,
|
||||
) -> ApiKey:
|
||||
credential = session.scalar(
|
||||
select(ApiKey)
|
||||
.where(
|
||||
ApiKey.id == credential_id,
|
||||
ApiKey.tenant_id == item.tenant_id,
|
||||
ApiKey.user_id == item.membership_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if credential is None:
|
||||
raise ServiceAccountCredentialNotFoundError(
|
||||
"Service-account credential was not found"
|
||||
)
|
||||
return credential
|
||||
|
||||
|
||||
def _active_service_account_membership(
|
||||
session: Session,
|
||||
item: ServiceAccount,
|
||||
) -> User:
|
||||
user = session.get(User, item.membership_id)
|
||||
account = session.get(Account, item.account_id)
|
||||
if (
|
||||
not item.is_active
|
||||
or item.retired_at is not None
|
||||
or user is None
|
||||
or account is None
|
||||
or not user.is_active
|
||||
or not account.is_active
|
||||
):
|
||||
raise ServiceAccountConflictError(
|
||||
"Activate the service account before creating or rotating credentials"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def _service_account_credential_scopes(
|
||||
principal: ApiPrincipal,
|
||||
item: ServiceAccount,
|
||||
values: Iterable[str],
|
||||
) -> tuple[str, ...]:
|
||||
scopes = tuple(
|
||||
sorted(
|
||||
{
|
||||
str(value).strip()
|
||||
for value in values
|
||||
if str(value).strip()
|
||||
}
|
||||
)
|
||||
)
|
||||
if not scopes:
|
||||
raise ServiceAccountError(
|
||||
"A service-account credential requires at least one scope"
|
||||
)
|
||||
if len(scopes) > 200:
|
||||
raise ServiceAccountError(
|
||||
"Service-account credentials support at most 200 scopes"
|
||||
)
|
||||
denied_by_ceiling = tuple(
|
||||
scope
|
||||
for scope in scopes
|
||||
if not scopes_grant(item.scope_ceiling, scope)
|
||||
)
|
||||
if denied_by_ceiling:
|
||||
raise PermissionError(
|
||||
"Credential scopes exceed the service-account scope ceiling: "
|
||||
+ ", ".join(denied_by_ceiling)
|
||||
)
|
||||
denied_by_actor = tuple(
|
||||
scope for scope in scopes if not principal.has(scope)
|
||||
)
|
||||
if denied_by_actor:
|
||||
raise PermissionError(
|
||||
"Credential scopes exceed the current administrator authority: "
|
||||
+ ", ".join(denied_by_actor)
|
||||
)
|
||||
return scopes
|
||||
|
||||
|
||||
def _credential_name(value: str) -> str:
|
||||
clean = " ".join(value.split())
|
||||
if not 1 <= len(clean) <= 255:
|
||||
raise ServiceAccountError(
|
||||
"Credential name must contain between 1 and 255 characters"
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
def _future_expiry(value: datetime | None) -> datetime | None:
|
||||
expires_at = ensure_aware_utc(value)
|
||||
if expires_at is not None and expires_at <= utc_now():
|
||||
raise ServiceAccountError(
|
||||
"Credential expiry must be in the future"
|
||||
)
|
||||
return expires_at
|
||||
|
||||
|
||||
def _touch_service_account(
|
||||
item: ServiceAccount,
|
||||
principal: ApiPrincipal,
|
||||
) -> None:
|
||||
item.revision += 1
|
||||
item.updated_by_account_id = principal.account_id
|
||||
|
||||
|
||||
def _service_account_name(value: str) -> str:
|
||||
clean = " ".join(value.split())
|
||||
if not 1 <= len(clean) <= 255:
|
||||
raise ServiceAccountError(
|
||||
"Service account name must contain between 1 and 255 characters"
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
def _normalized_name(value: str) -> str:
|
||||
return value.casefold()
|
||||
|
||||
|
||||
def _optional_text(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
clean = value.strip()
|
||||
if len(clean) > 4000:
|
||||
raise ServiceAccountError(
|
||||
"Service account description is too long"
|
||||
)
|
||||
return clean or None
|
||||
|
||||
|
||||
def _service_account_scopes(
|
||||
principal: ApiPrincipal,
|
||||
values: Iterable[str],
|
||||
) -> tuple[str, ...]:
|
||||
scopes = tuple(
|
||||
sorted(
|
||||
{
|
||||
str(value).strip()
|
||||
for value in values
|
||||
if str(value).strip()
|
||||
}
|
||||
)
|
||||
)
|
||||
if len(scopes) > 200:
|
||||
raise ServiceAccountError(
|
||||
"Service accounts support at most 200 scope grants"
|
||||
)
|
||||
denied = tuple(
|
||||
scope for scope in scopes
|
||||
if not principal.has(scope)
|
||||
)
|
||||
if denied:
|
||||
raise PermissionError(
|
||||
"Cannot grant service-account scopes outside the current "
|
||||
f"administrator authority: {', '.join(denied)}"
|
||||
)
|
||||
return scopes
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ServiceAccountConflictError",
|
||||
"ServiceAccountCredentialNotFoundError",
|
||||
"ServiceAccountCredentialSummary",
|
||||
"ServiceAccountError",
|
||||
"ServiceAccountNotFoundError",
|
||||
"create_service_account",
|
||||
"create_service_account_credential",
|
||||
"get_service_account",
|
||||
"list_service_accounts",
|
||||
"list_service_account_credentials",
|
||||
"revoke_service_account_credential",
|
||||
"retire_service_account",
|
||||
"rotate_service_account_credential",
|
||||
"service_account_credential_summaries",
|
||||
"update_service_account",
|
||||
]
|
||||
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import AuthSession
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
|
||||
|
||||
MAX_SESSION_LIST_ITEMS = 100
|
||||
MAX_CLIENT_LABEL_LENGTH = 160
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionSummary:
|
||||
id: str
|
||||
tenant_id: str
|
||||
current: bool
|
||||
status: str
|
||||
created_at: datetime
|
||||
last_seen_at: datetime | None
|
||||
expires_at: datetime
|
||||
revoked_at: datetime | None
|
||||
client: str | None
|
||||
|
||||
|
||||
def session_summary(
|
||||
item: AuthSession,
|
||||
*,
|
||||
current_session_id: str | None,
|
||||
now: datetime | None = None,
|
||||
) -> SessionSummary:
|
||||
effective_at = now or utc_now()
|
||||
expires_at = ensure_aware_utc(item.expires_at)
|
||||
revoked_at = ensure_aware_utc(item.revoked_at)
|
||||
if revoked_at is not None:
|
||||
status = "revoked"
|
||||
elif expires_at is None or expires_at <= effective_at:
|
||||
status = "expired"
|
||||
else:
|
||||
status = "active"
|
||||
return SessionSummary(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
current=item.id == current_session_id,
|
||||
status=status,
|
||||
created_at=item.created_at,
|
||||
last_seen_at=item.last_seen_at,
|
||||
expires_at=item.expires_at,
|
||||
revoked_at=item.revoked_at,
|
||||
client=_bounded_client(item.user_agent),
|
||||
)
|
||||
|
||||
|
||||
def list_account_sessions(
|
||||
session: Session,
|
||||
*,
|
||||
account_id: str,
|
||||
current_session_id: str | None,
|
||||
tenant_id: str | None = None,
|
||||
include_inactive: bool = False,
|
||||
limit: int = MAX_SESSION_LIST_ITEMS,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[SessionSummary, ...]:
|
||||
effective_at = now or utc_now()
|
||||
query = session.query(AuthSession).filter(AuthSession.account_id == account_id)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(AuthSession.tenant_id == tenant_id)
|
||||
if not include_inactive:
|
||||
query = query.filter(
|
||||
AuthSession.revoked_at.is_(None),
|
||||
AuthSession.expires_at > effective_at,
|
||||
)
|
||||
rows = (
|
||||
query.order_by(AuthSession.created_at.desc(), AuthSession.id.asc())
|
||||
.limit(max(1, min(limit, MAX_SESSION_LIST_ITEMS)))
|
||||
.all()
|
||||
)
|
||||
return tuple(
|
||||
session_summary(
|
||||
item,
|
||||
current_session_id=current_session_id,
|
||||
now=effective_at,
|
||||
)
|
||||
for item in rows
|
||||
)
|
||||
|
||||
|
||||
def revoke_account_session(
|
||||
session: Session,
|
||||
*,
|
||||
account_id: str,
|
||||
session_id: str,
|
||||
tenant_id: str | None = None,
|
||||
protected_session_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[AuthSession | None, bool]:
|
||||
query = session.query(AuthSession).filter(
|
||||
AuthSession.id == session_id,
|
||||
AuthSession.account_id == account_id,
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(AuthSession.tenant_id == tenant_id)
|
||||
item = query.one_or_none()
|
||||
if item is None:
|
||||
return None, False
|
||||
if protected_session_id is not None and item.id == protected_session_id:
|
||||
raise ValueError("The current session cannot be revoked through session management.")
|
||||
if item.revoked_at is not None:
|
||||
return item, False
|
||||
item.revoked_at = now or utc_now()
|
||||
session.add(item)
|
||||
return item, True
|
||||
|
||||
|
||||
def revoke_other_account_sessions(
|
||||
session: Session,
|
||||
*,
|
||||
account_id: str,
|
||||
current_session_id: str,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
effective_at = now or utc_now()
|
||||
rows = (
|
||||
session.query(AuthSession)
|
||||
.filter(
|
||||
AuthSession.account_id == account_id,
|
||||
AuthSession.id != current_session_id,
|
||||
AuthSession.revoked_at.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
revoked: list[str] = []
|
||||
for item in rows:
|
||||
expires_at = ensure_aware_utc(item.expires_at)
|
||||
if expires_at is None or expires_at <= effective_at:
|
||||
continue
|
||||
item.revoked_at = effective_at
|
||||
session.add(item)
|
||||
revoked.append(item.id)
|
||||
return tuple(sorted(revoked))
|
||||
|
||||
|
||||
def _bounded_client(value: str | None) -> str | None:
|
||||
normalized = " ".join(str(value or "").split())
|
||||
if not normalized:
|
||||
return None
|
||||
return normalized[:MAX_CLIENT_LABEL_LENGTH]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_CLIENT_LABEL_LENGTH",
|
||||
"MAX_SESSION_LIST_ITEMS",
|
||||
"SessionSummary",
|
||||
"list_account_sessions",
|
||||
"revoke_account_session",
|
||||
"revoke_other_account_sessions",
|
||||
"session_summary",
|
||||
]
|
||||
@@ -5,11 +5,21 @@ from collections.abc import Mapping, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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.passwords import hash_password
|
||||
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):
|
||||
@@ -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:
|
||||
if not isinstance(session, 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
|
||||
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.administration import SqlAccessAdministration
|
||||
from govoplan_access.backend.api.v1.admin_common import (
|
||||
_accounts_by_user_id,
|
||||
_group_member_ids_by_group_id,
|
||||
@@ -13,7 +15,7 @@ from govoplan_access.backend.api.v1.admin_common import (
|
||||
_roles_by_user_id,
|
||||
_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
|
||||
|
||||
|
||||
@@ -56,6 +58,92 @@ class AdminBatchHelperTests(unittest.TestCase):
|
||||
self.assertEqual([item.id for item in roles_by_user[user.id]], [role.id])
|
||||
self.assertEqual(role_counts, {role.id: (1, 1)})
|
||||
|
||||
def test_tenant_counts_many_uses_three_grouped_queries(self) -> None:
|
||||
accounts = [
|
||||
Account(
|
||||
id=f"account-{index}",
|
||||
email=f"user-{index}@example.test",
|
||||
normalized_email=f"user-{index}@example.test",
|
||||
)
|
||||
for index in range(3)
|
||||
]
|
||||
users = [
|
||||
User(
|
||||
id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id=accounts[0].id,
|
||||
email=accounts[0].email,
|
||||
),
|
||||
User(
|
||||
id="user-2",
|
||||
tenant_id="tenant-1",
|
||||
account_id=accounts[1].id,
|
||||
email=accounts[1].email,
|
||||
is_active=False,
|
||||
),
|
||||
User(
|
||||
id="user-3",
|
||||
tenant_id="tenant-2",
|
||||
account_id=accounts[2].id,
|
||||
email=accounts[2].email,
|
||||
),
|
||||
]
|
||||
self.session.add_all(
|
||||
[
|
||||
*accounts,
|
||||
*users,
|
||||
Group(id="group-1", tenant_id="tenant-1", slug="one", name="One"),
|
||||
Group(id="group-2", tenant_id="tenant-2", slug="two", name="Two"),
|
||||
ApiKey(
|
||||
id="key-1",
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
name="Active",
|
||||
prefix="active",
|
||||
key_hash="hash-1",
|
||||
),
|
||||
ApiKey(
|
||||
id="key-2",
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-2",
|
||||
name="Revoked",
|
||||
prefix="revoked",
|
||||
key_hash="hash-2",
|
||||
revoked_at=datetime.now(UTC),
|
||||
),
|
||||
]
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
query_count = 0
|
||||
|
||||
def count_query(*_args: object) -> None:
|
||||
nonlocal query_count
|
||||
query_count += 1
|
||||
|
||||
event.listen(self.engine, "before_cursor_execute", count_query)
|
||||
try:
|
||||
counts = SqlAccessAdministration().tenant_counts_many(
|
||||
self.session,
|
||||
["tenant-1", "tenant-2", "tenant-empty"],
|
||||
)
|
||||
finally:
|
||||
event.remove(self.engine, "before_cursor_execute", count_query)
|
||||
|
||||
self.assertEqual(3, query_count)
|
||||
self.assertEqual(
|
||||
{
|
||||
"users": 2,
|
||||
"active_users": 1,
|
||||
"groups": 1,
|
||||
"api_keys": 2,
|
||||
"active_api_keys": 1,
|
||||
},
|
||||
counts["tenant-1"],
|
||||
)
|
||||
self.assertEqual(1, counts["tenant-2"]["users"])
|
||||
self.assertEqual(0, counts["tenant-empty"]["users"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest import TestCase
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||
from govoplan_access.backend.security.sessions import authenticate_session_token
|
||||
|
||||
|
||||
class AuthenticationActivityTouchTests(TestCase):
|
||||
def test_session_activity_is_touched_only_after_the_interval(self) -> None:
|
||||
now = datetime(2026, 7, 29, 10, 0, tzinfo=timezone.utc)
|
||||
for age_seconds, should_touch in ((60, False), (301, True)):
|
||||
with self.subTest(age_seconds=age_seconds):
|
||||
model = SimpleNamespace(
|
||||
expires_at=now + timedelta(hours=1),
|
||||
last_seen_at=now - timedelta(seconds=age_seconds),
|
||||
)
|
||||
session = MagicMock()
|
||||
(
|
||||
session.query.return_value.options.return_value
|
||||
.filter.return_value.one_or_none
|
||||
).return_value = model
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_access.backend.security.sessions.hash_session_token",
|
||||
return_value="hashed",
|
||||
),
|
||||
patch(
|
||||
"govoplan_access.backend.security.sessions.utc_now",
|
||||
return_value=now,
|
||||
),
|
||||
):
|
||||
result = authenticate_session_token(
|
||||
session,
|
||||
"token",
|
||||
touch_interval_seconds=300,
|
||||
)
|
||||
|
||||
self.assertIs(model, result)
|
||||
if should_touch:
|
||||
self.assertEqual(now, model.last_seen_at)
|
||||
session.add.assert_called_once_with(model)
|
||||
else:
|
||||
self.assertEqual(
|
||||
now - timedelta(seconds=age_seconds),
|
||||
model.last_seen_at,
|
||||
)
|
||||
session.add.assert_not_called()
|
||||
|
||||
def test_api_key_activity_is_touched_only_after_the_interval(self) -> None:
|
||||
now = datetime(2026, 7, 29, 10, 0, tzinfo=timezone.utc)
|
||||
for age_seconds, should_touch in ((60, False), (301, True)):
|
||||
with self.subTest(age_seconds=age_seconds):
|
||||
model = SimpleNamespace(
|
||||
expires_at=None,
|
||||
last_used_at=now - timedelta(seconds=age_seconds),
|
||||
key_hash="hashed",
|
||||
)
|
||||
session = MagicMock()
|
||||
(
|
||||
session.query.return_value.options.return_value
|
||||
.filter.return_value.all
|
||||
).return_value = [model]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_access.backend.security.api_keys.verify_api_key",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"govoplan_access.backend.security.api_keys.utc_now",
|
||||
return_value=now,
|
||||
),
|
||||
):
|
||||
result = authenticate_api_key(
|
||||
session,
|
||||
"mm_test-token",
|
||||
touch_interval_seconds=300,
|
||||
)
|
||||
|
||||
self.assertIs(model, result)
|
||||
if should_touch:
|
||||
self.assertEqual(now, model.last_used_at)
|
||||
session.add.assert_called_once_with(model)
|
||||
else:
|
||||
self.assertEqual(
|
||||
now - timedelta(seconds=age_seconds),
|
||||
model.last_used_at,
|
||||
)
|
||||
session.add.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import unittest
|
||||
|
||||
unittest.main()
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from starlette.requests import Request
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import _resolve_legacy_principal_context
|
||||
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||
from govoplan_access.backend.auth.tokens import hash_secret
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import Account, AuthSession, Role, ServiceAccount, User, UserRoleAssignment
|
||||
from govoplan_access.backend.security.api_keys import create_api_key
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||
|
||||
|
||||
class AuthCacheSecurityTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
principal_summary_cache.clear()
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
AccessBase.metadata.create_all(self.engine)
|
||||
self.revision_tables = [ChangeSequenceEntry.__table__, ChangeSequenceRetentionFloor.__table__]
|
||||
Base.metadata.create_all(self.engine, tables=self.revision_tables)
|
||||
self.session = sessionmaker(bind=self.engine)()
|
||||
self.cache_setting = patch.object(settings, "auth_principal_cache_enabled", True)
|
||||
self.cache_setting.start()
|
||||
self.tenant = Tenant(id="cache-tenant", slug="cache-tenant", name="Cache tenant")
|
||||
self.session.add(self.tenant)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.cache_setting.stop()
|
||||
principal_summary_cache.clear()
|
||||
self.session.close()
|
||||
AccessBase.metadata.drop_all(self.engine)
|
||||
scope_registry.metadata.drop_all(self.engine)
|
||||
Base.metadata.drop_all(self.engine, tables=self.revision_tables)
|
||||
self.engine.dispose()
|
||||
|
||||
def identity(self, *, service: bool = False) -> tuple[Account, User]:
|
||||
account = Account(id="cache-account", email="cache@example.test", normalized_email="cache@example.test", auth_provider="service_account" if service else "local")
|
||||
user = User(id="cache-user", tenant_id=self.tenant.id, account_id=account.id, email=account.email, auth_provider=account.auth_provider)
|
||||
role = Role(id="cache-role", tenant_id=self.tenant.id, slug="reader", name="Reader", permissions=["files:file:read"])
|
||||
assignment = UserRoleAssignment(tenant_id=self.tenant.id, user_id=user.id, role_id=role.id)
|
||||
self.session.add_all([account, user, role, assignment])
|
||||
self.session.commit()
|
||||
return account, user
|
||||
|
||||
def resolve(self, token: str, *, cookie: bool = False, csrf: str | None = None):
|
||||
headers = []
|
||||
if cookie:
|
||||
cookies = f"{settings.auth_session_cookie_name}={token}"
|
||||
if csrf is not None:
|
||||
cookies += f"; {settings.auth_csrf_cookie_name}={csrf}"
|
||||
headers.append((b"x-csrf-token", csrf.encode()))
|
||||
headers.append((b"cookie", cookies.encode()))
|
||||
request = Request({"type": "http", "method": "POST", "path": "/protected", "headers": headers})
|
||||
return _resolve_legacy_principal_context(request, self.session, authorization=None if cookie else f"Bearer {token}", x_api_key=None)
|
||||
|
||||
def test_warmed_api_key_is_never_accepted_as_a_session_cookie(self) -> None:
|
||||
_, user = self.identity()
|
||||
key = create_api_key(self.session, user=user, name="Test", scopes=["files:file:read"])
|
||||
self.session.commit()
|
||||
with self.assertRaises(HTTPException) as cold:
|
||||
self.resolve(key.secret, cookie=True)
|
||||
self.assertEqual(401, cold.exception.status_code)
|
||||
self.assertEqual("api_key", self.resolve(key.secret).principal.auth_method)
|
||||
with self.assertRaises(HTTPException) as warm:
|
||||
self.resolve(key.secret, cookie=True)
|
||||
self.assertEqual(401, warm.exception.status_code)
|
||||
self.assertEqual("api_key", self.resolve(key.secret).principal.auth_method)
|
||||
|
||||
def test_service_account_keeps_current_ceiling_and_provenance_with_cache_enabled(self) -> None:
|
||||
account, user = self.identity(service=True)
|
||||
item = ServiceAccount(id="cache-service", tenant_id=self.tenant.id, account_id=account.id, membership_id=user.id, name="Cache worker", normalized_name="cache worker", scope_ceiling=["dataflow:pipeline:run"])
|
||||
# A credential issued before a ceiling reduction can retain wider stored
|
||||
# scopes. Ordinary membership roles must not override the current ceiling.
|
||||
key = create_api_key(self.session, user=user, name="Worker", scopes=["dataflow:pipeline:run", "files:file:read"])
|
||||
self.session.add(item)
|
||||
self.session.commit()
|
||||
for _ in range(2):
|
||||
context = self.resolve(key.secret)
|
||||
self.assertEqual(frozenset({"dataflow:pipeline:run"}), context.principal.scopes)
|
||||
self.assertEqual("service_account", context.principal.auth_method)
|
||||
self.assertEqual(item.id, context.principal.service_account_id)
|
||||
self.assertFalse(context.principal.role_ids)
|
||||
item.scope_ceiling = []
|
||||
self.session.commit()
|
||||
self.assertEqual(frozenset(), self.resolve(key.secret).principal.scopes)
|
||||
item.is_active = False
|
||||
self.session.commit()
|
||||
with self.assertRaises(HTTPException) as inactive:
|
||||
self.resolve(key.secret)
|
||||
self.assertEqual(401, inactive.exception.status_code)
|
||||
|
||||
def test_warmed_session_cookie_still_requires_matching_csrf(self) -> None:
|
||||
account, user = self.identity()
|
||||
token, csrf = "ms_cache-session", "cache-csrf"
|
||||
auth_session = AuthSession(id="cache-session", tenant_id=self.tenant.id, user_id=user.id, account_id=account.id, token_hash=hash_secret(token), csrf_token_hash=hash_secret(csrf), expires_at=utc_now() + timedelta(hours=1))
|
||||
self.session.add(auth_session)
|
||||
self.session.commit()
|
||||
self.resolve(token)
|
||||
for supplied in (None, "incorrect"):
|
||||
with self.subTest(csrf=supplied), self.assertRaises(HTTPException) as denied:
|
||||
self.resolve(token, cookie=True, csrf=supplied)
|
||||
self.assertEqual(403, denied.exception.status_code)
|
||||
self.assertEqual("session", self.resolve(token, cookie=True, csrf=csrf).principal.auth_method)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,13 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from typing import Iterable
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from starlette.requests import Request
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import _extract_token, _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.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||
|
||||
|
||||
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):
|
||||
def tearDown(self) -> None:
|
||||
principal_summary_cache.clear()
|
||||
|
||||
def test_extract_token_prefers_explicit_api_key(self) -> None:
|
||||
request = request_for(headers=[("authorization", "Bearer session-token")])
|
||||
|
||||
@@ -44,6 +66,200 @@ class AuthDependencyTests(unittest.TestCase):
|
||||
self.assertEqual(raised.exception.status_code, 401)
|
||||
self.assertEqual(raised.exception.detail, "Missing API key or session token")
|
||||
|
||||
def test_permission_revision_invalidates_cached_principal(self) -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(engine)
|
||||
AccessBase.metadata.create_all(bind=engine)
|
||||
Base.metadata.create_all(
|
||||
bind=engine,
|
||||
tables=[
|
||||
ChangeSequenceEntry.__table__,
|
||||
ChangeSequenceRetentionFloor.__table__,
|
||||
],
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
try:
|
||||
with SessionLocal() as session:
|
||||
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||
account = Account(
|
||||
id="account-1",
|
||||
email="owner@example.test",
|
||||
normalized_email="owner@example.test",
|
||||
)
|
||||
user = User(
|
||||
id="user-1",
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
)
|
||||
role = Role(
|
||||
id="role-1",
|
||||
tenant_id=tenant.id,
|
||||
slug="reader",
|
||||
name="Reader",
|
||||
permissions=["files:file:read"],
|
||||
)
|
||||
assignment = UserRoleAssignment(
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
)
|
||||
token = "ms_test-session-token"
|
||||
auth_session = AuthSession(
|
||||
id="session-1",
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
token_hash=hash_secret(token),
|
||||
expires_at=utc_now() + timedelta(hours=1),
|
||||
)
|
||||
session.add_all(
|
||||
[tenant, account, user, role, assignment, auth_session]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
request = request_for()
|
||||
first = _resolve_legacy_principal_context(
|
||||
request,
|
||||
session,
|
||||
authorization=f"Bearer {token}",
|
||||
x_api_key=None,
|
||||
)
|
||||
self.assertIn("files:file:read", first.principal.scopes)
|
||||
|
||||
role.permissions = ["files:file:write"]
|
||||
session.add(role)
|
||||
invalidate_auth_principals(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
source_module="access",
|
||||
resource_type="role",
|
||||
resource_id=role.id,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
second = _resolve_legacy_principal_context(
|
||||
request,
|
||||
session,
|
||||
authorization=f"Bearer {token}",
|
||||
x_api_key=None,
|
||||
)
|
||||
self.assertNotIn("files:file:read", second.principal.scopes)
|
||||
self.assertIn("files:file:write", second.principal.scopes)
|
||||
finally:
|
||||
AccessBase.metadata.drop_all(bind=engine)
|
||||
scope_registry.metadata.drop_all(bind=engine)
|
||||
Base.metadata.drop_all(
|
||||
bind=engine,
|
||||
tables=[
|
||||
ChangeSequenceEntry.__table__,
|
||||
ChangeSequenceRetentionFloor.__table__,
|
||||
],
|
||||
)
|
||||
engine.dispose()
|
||||
|
||||
def test_acting_assignment_requires_exact_session_selection(self) -> None:
|
||||
class Directory:
|
||||
def organization_function_assignments_for_account(
|
||||
self,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at=None,
|
||||
):
|
||||
del account_id, effective_at
|
||||
return (
|
||||
OrganizationFunctionAssignmentRef(
|
||||
id="direct-1",
|
||||
tenant_id=str(tenant_id),
|
||||
identity_id="identity-1",
|
||||
account_id="account-1",
|
||||
function_id="function-direct",
|
||||
organization_unit_id="unit-1",
|
||||
),
|
||||
OrganizationFunctionAssignmentRef(
|
||||
id="acting-1",
|
||||
tenant_id=str(tenant_id),
|
||||
identity_id="identity-1",
|
||||
account_id="account-1",
|
||||
function_id="function-acting",
|
||||
organization_unit_id="unit-1",
|
||||
source="acting_for",
|
||||
acting_for_account_id="represented-1",
|
||||
),
|
||||
)
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(engine)
|
||||
AccessBase.metadata.create_all(bind=engine)
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
try:
|
||||
with SessionLocal() as session:
|
||||
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||
account = Account(
|
||||
id="account-1",
|
||||
email="actor@example.test",
|
||||
normalized_email="actor@example.test",
|
||||
)
|
||||
user = User(
|
||||
id="user-1",
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
)
|
||||
auth_session = AuthSession(
|
||||
id="session-1",
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
token_hash="token-hash",
|
||||
expires_at=utc_now() + timedelta(hours=1),
|
||||
)
|
||||
session.add_all((tenant, account, user, auth_session))
|
||||
session.flush()
|
||||
|
||||
ordinary, _ = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=tenant.id,
|
||||
idm_directory=Directory(), # type: ignore[arg-type]
|
||||
organization_directory=None,
|
||||
)
|
||||
self.assertEqual([item.id for item in ordinary], ["direct-1"])
|
||||
|
||||
auth_session.acting_assignment_id = "acting-1"
|
||||
auth_session.acting_for_account_id = "represented-1"
|
||||
selected, _ = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=tenant.id,
|
||||
idm_directory=Directory(), # type: ignore[arg-type]
|
||||
organization_directory=None,
|
||||
auth_session=auth_session,
|
||||
)
|
||||
self.assertEqual(
|
||||
[item.id for item in selected],
|
||||
["direct-1", "acting-1"],
|
||||
)
|
||||
|
||||
auth_session.acting_for_account_id = "wrong-account"
|
||||
mismatched, _ = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=tenant.id,
|
||||
idm_directory=Directory(), # type: ignore[arg-type]
|
||||
organization_directory=None,
|
||||
auth_session=auth_session,
|
||||
)
|
||||
self.assertEqual([item.id for item in mismatched], ["direct-1"])
|
||||
finally:
|
||||
AccessBase.metadata.drop_all(bind=engine)
|
||||
scope_registry.metadata.drop_all(bind=engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import (
|
||||
AccessAutomationPrincipalProvider,
|
||||
)
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ServiceAccount,
|
||||
User,
|
||||
)
|
||||
from govoplan_access.backend.manifest import manifest
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
UserAuthorizationContext,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
)
|
||||
from govoplan_core.core.automation import AutomationPrincipalRequest
|
||||
from govoplan_core.tenancy.scope import (
|
||||
Tenant,
|
||||
create_scope_tables,
|
||||
scope_registry,
|
||||
)
|
||||
|
||||
|
||||
class AutomationPrincipalTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
AccessBase.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.account = Account(
|
||||
id="account-1",
|
||||
email="owner@example.test",
|
||||
normalized_email="owner@example.test",
|
||||
)
|
||||
self.tenant = Tenant(
|
||||
id="tenant-1",
|
||||
slug="tenant-1",
|
||||
name="Tenant 1",
|
||||
)
|
||||
self.user = User(
|
||||
id="user-1",
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=self.account.id,
|
||||
email=self.account.email,
|
||||
)
|
||||
self.session.add_all([self.tenant, self.account, self.user])
|
||||
self.session.commit()
|
||||
self.provider = AccessAutomationPrincipalProvider()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
AccessBase.metadata.drop_all(bind=self.engine)
|
||||
scope_registry.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def _request(self) -> AutomationPrincipalRequest:
|
||||
return AutomationPrincipalRequest(
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=self.account.id,
|
||||
membership_id=self.user.id,
|
||||
authorization_ref="dataflow-trigger:1",
|
||||
grant_scopes=(
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
),
|
||||
context={
|
||||
"trigger_ref": "dataflow-trigger:1",
|
||||
"delivery_ref": "dataflow-delivery:1",
|
||||
"event_actor": {
|
||||
"type": "user",
|
||||
"id": "event-user-1",
|
||||
},
|
||||
"operator_override": {
|
||||
"type": "user",
|
||||
"id": "operator-1",
|
||||
"reason": "approved replay",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def test_resolution_intersects_trigger_grant_with_current_scopes(self) -> None:
|
||||
context = UserAuthorizationContext(
|
||||
tenant_roles=[],
|
||||
system_roles=[],
|
||||
groups=[],
|
||||
function_assignment_ids=(),
|
||||
function_delegation_ids=(),
|
||||
scopes=[
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
"system:settings:write",
|
||||
],
|
||||
)
|
||||
with patch(
|
||||
"govoplan_access.backend.auth.dependencies."
|
||||
"collect_user_authorization_context",
|
||||
return_value=context,
|
||||
):
|
||||
result = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=self._request(),
|
||||
)
|
||||
|
||||
self.assertTrue(result.allowed)
|
||||
self.assertIsInstance(result.principal, ApiPrincipal)
|
||||
self.assertEqual(
|
||||
frozenset(
|
||||
{
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
}
|
||||
),
|
||||
result.principal.scopes,
|
||||
)
|
||||
self.assertIsNone(
|
||||
result.principal.principal.service_account_id
|
||||
)
|
||||
self.assertEqual(
|
||||
self.account.id,
|
||||
result.principal.principal.acting_for_account_id,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"system:settings:write",
|
||||
result.principal.scopes,
|
||||
)
|
||||
self.assertEqual(
|
||||
"delegated_user",
|
||||
result.provenance["trigger_owner"]["kind"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"event-user-1",
|
||||
result.provenance["event_actor"]["id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"operator-1",
|
||||
result.provenance["operator_override"]["id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.account.id,
|
||||
result.provenance[
|
||||
"current_automation_principal"
|
||||
]["account_id"],
|
||||
)
|
||||
|
||||
def test_revoked_scope_and_suspended_owner_fail_closed(self) -> None:
|
||||
context = UserAuthorizationContext(
|
||||
tenant_roles=[],
|
||||
system_roles=[],
|
||||
groups=[],
|
||||
function_assignment_ids=(),
|
||||
function_delegation_ids=(),
|
||||
scopes=["dataflow:pipeline:run"],
|
||||
)
|
||||
with patch(
|
||||
"govoplan_access.backend.auth.dependencies."
|
||||
"collect_user_authorization_context",
|
||||
return_value=context,
|
||||
):
|
||||
result = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=self._request(),
|
||||
)
|
||||
self.assertFalse(result.allowed)
|
||||
self.assertEqual(
|
||||
("datasources:catalogue:read",),
|
||||
result.missing_scopes,
|
||||
)
|
||||
|
||||
self.account.is_active = False
|
||||
self.session.flush()
|
||||
suspended = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=self._request(),
|
||||
)
|
||||
self.assertFalse(suspended.allowed)
|
||||
self.assertEqual(
|
||||
"inactive_or_inconsistent",
|
||||
suspended.provenance["status"],
|
||||
)
|
||||
|
||||
def test_service_account_resolution_uses_current_scope_ceiling(self) -> None:
|
||||
account = Account(
|
||||
id="service-account-backing",
|
||||
email="service@example.invalid",
|
||||
normalized_email="service@example.invalid",
|
||||
display_name="Import worker",
|
||||
auth_provider="service_account",
|
||||
)
|
||||
membership = User(
|
||||
id="service-membership",
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
auth_provider="service_account",
|
||||
)
|
||||
service_account = ServiceAccount(
|
||||
id="service-1",
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=account.id,
|
||||
membership_id=membership.id,
|
||||
name="Import worker",
|
||||
normalized_name="import worker",
|
||||
scope_ceiling=[
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
"system:settings:write",
|
||||
],
|
||||
is_active=True,
|
||||
revision=1,
|
||||
settings={},
|
||||
)
|
||||
self.session.add_all(
|
||||
(account, membership, service_account)
|
||||
)
|
||||
self.session.flush()
|
||||
request = AutomationPrincipalRequest.service_account(
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=service_account.id,
|
||||
authorization_ref="dataflow-trigger:service",
|
||||
grant_scopes=(
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
),
|
||||
)
|
||||
|
||||
result = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=request,
|
||||
)
|
||||
|
||||
self.assertTrue(result.allowed)
|
||||
self.assertEqual(
|
||||
service_account.id,
|
||||
result.principal.principal.service_account_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
frozenset(request.grant_scopes),
|
||||
result.principal.scopes,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"system:settings:write",
|
||||
result.principal.scopes,
|
||||
)
|
||||
self.assertEqual(
|
||||
"service_account",
|
||||
result.provenance["trigger_owner"]["kind"],
|
||||
)
|
||||
|
||||
service_account.scope_ceiling = [
|
||||
"dataflow:pipeline:run"
|
||||
]
|
||||
self.session.flush()
|
||||
reduced = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=request,
|
||||
)
|
||||
self.assertFalse(reduced.allowed)
|
||||
self.assertEqual(
|
||||
("datasources:catalogue:read",),
|
||||
reduced.missing_scopes,
|
||||
)
|
||||
|
||||
service_account.is_active = False
|
||||
self.session.flush()
|
||||
inactive = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=request,
|
||||
)
|
||||
self.assertFalse(inactive.allowed)
|
||||
self.assertEqual(
|
||||
"inactive_or_inconsistent",
|
||||
inactive.provenance["status"],
|
||||
)
|
||||
|
||||
def test_manifest_registers_automation_resolution(self) -> None:
|
||||
self.assertIn(
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
manifest.capability_factories,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
ConfigurationPackageApplyResponse,
|
||||
ConfigurationPackageExportResponse,
|
||||
)
|
||||
from govoplan_access.backend.api.v1.routes import _configuration_context
|
||||
from govoplan_core.core.infrastructure_capabilities import (
|
||||
InfrastructureCapabilityReceiptError,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ExternalProviderRuntimeState,
|
||||
ExternalProviderStateProviderRegistration,
|
||||
)
|
||||
|
||||
|
||||
class ConfigurationPackageContextTests(unittest.TestCase):
|
||||
def test_api_responses_preserve_rollback_and_redacted_export_provenance(self) -> None:
|
||||
applied = ConfigurationPackageApplyResponse(
|
||||
rollback={
|
||||
"status": "database_restore_required",
|
||||
"summary": "Snapshot is the generic rollback boundary.",
|
||||
"recovery_action": "Retain the snapshot.",
|
||||
}
|
||||
)
|
||||
exported = ConfigurationPackageExportResponse(
|
||||
provenance={
|
||||
"exported_at": "2026-08-22T12:00:00+00:00",
|
||||
"source_core_version": "0.1.35",
|
||||
"module_versions": {"forms": "0.1.20"},
|
||||
"tenant_id": "tenant-1",
|
||||
"exporter_id": "user-1",
|
||||
"selection": {
|
||||
"scopes": ["tenant"],
|
||||
"module_ids": ["forms"],
|
||||
"object_refs": [],
|
||||
},
|
||||
"redacted_secret_keys": ["credential_ref"],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
"database_restore_required",
|
||||
applied.model_dump()["rollback"]["status"],
|
||||
)
|
||||
self.assertEqual(
|
||||
["credential_ref"],
|
||||
exported.model_dump()["provenance"]["redacted_secret_keys"],
|
||||
)
|
||||
|
||||
def test_context_carries_operator_scopes_and_validated_infrastructure_receipt(self) -> None:
|
||||
receipt = SimpleNamespace(installation_id="deployment-1")
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
scopes=frozenset({"system:settings:write"}),
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"govoplan_access.backend.api.v1.routes.load_infrastructure_capability_receipt",
|
||||
return_value=receipt,
|
||||
),
|
||||
):
|
||||
context = _configuration_context(principal)
|
||||
|
||||
self.assertIs(receipt, context.infrastructure_receipt)
|
||||
self.assertEqual(
|
||||
frozenset({"system:settings:write"}),
|
||||
context.operator_scopes,
|
||||
)
|
||||
|
||||
def test_context_preserves_invalid_receipt_as_fail_closed_provider_state(self) -> None:
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
scopes=frozenset(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"govoplan_access.backend.api.v1.routes.load_infrastructure_capability_receipt",
|
||||
side_effect=InfrastructureCapabilityReceiptError("invalid receipt"),
|
||||
),
|
||||
):
|
||||
context = _configuration_context(principal)
|
||||
|
||||
self.assertIsNone(context.infrastructure_receipt)
|
||||
self.assertEqual("invalid receipt", context.infrastructure_receipt_error)
|
||||
|
||||
def test_context_projects_installed_external_provider_declarations(self) -> None:
|
||||
declaration = SimpleNamespace(
|
||||
id="connectors.example",
|
||||
to_dict=lambda: {
|
||||
"id": "connectors.example",
|
||||
"maturity": "read",
|
||||
"authority_modes": ["external_mirror"],
|
||||
},
|
||||
)
|
||||
registry = SimpleNamespace(
|
||||
manifests=lambda: (
|
||||
SimpleNamespace(id="access", version="0.1.14"),
|
||||
SimpleNamespace(id="connectors", version="0.1.14"),
|
||||
),
|
||||
capability_names=lambda: ("connectors.profiles",),
|
||||
external_provider_declarations=lambda: (declaration,),
|
||||
)
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||
return_value=registry,
|
||||
):
|
||||
context = _configuration_context(principal)
|
||||
|
||||
self.assertEqual("0.1.14", context.installed_modules["connectors"])
|
||||
self.assertIn("connectors.profiles", context.capabilities)
|
||||
self.assertEqual(
|
||||
"external_mirror",
|
||||
context.external_provider_declarations["connectors.example"][
|
||||
"authority_modes"
|
||||
][0],
|
||||
)
|
||||
|
||||
def test_context_projects_tenant_runtime_provider_state(self) -> None:
|
||||
declaration = SimpleNamespace(
|
||||
id="calendar.caldav_sync",
|
||||
to_dict=lambda: {
|
||||
"id": "calendar.caldav_sync",
|
||||
"maturity": "synchronize",
|
||||
"authority_modes": ["governed_sync"],
|
||||
},
|
||||
)
|
||||
registration = ExternalProviderStateProviderRegistration(
|
||||
module_id="calendar",
|
||||
provider_id="calendar.caldav_sync",
|
||||
provider=lambda context: (
|
||||
ExternalProviderRuntimeState(
|
||||
provider_id="calendar.caldav_sync",
|
||||
binding_ref="calendar:sync-source:one",
|
||||
authority_mode="governed_sync",
|
||||
observed_at=datetime(2026, 8, 1, 12, 0, tzinfo=UTC),
|
||||
configured=True,
|
||||
active=True,
|
||||
health="healthy",
|
||||
freshness="current",
|
||||
conflict="clear",
|
||||
recovery="ready",
|
||||
metrics={"tenant_matches": context.tenant_id == "tenant-1"},
|
||||
),
|
||||
),
|
||||
)
|
||||
registry = SimpleNamespace(
|
||||
manifests=lambda: (SimpleNamespace(id="calendar", version="0.1.8"),),
|
||||
capability_names=lambda: (),
|
||||
external_provider_declarations=lambda: (declaration,),
|
||||
external_provider_state_providers=lambda: (registration,),
|
||||
)
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||
return_value=registry,
|
||||
):
|
||||
context = _configuration_context(principal, session=object())
|
||||
|
||||
state = context.external_provider_states["calendar.caldav_sync"]
|
||||
self.assertEqual("healthy", state["health"])
|
||||
self.assertEqual("calendar:sync-source:one", state["binding_ref"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, User
|
||||
from govoplan_access.backend.dsar_provider import AccessDsarProvider
|
||||
from govoplan_core.core.dsar import DsarSubjectRef
|
||||
|
||||
|
||||
class AccessDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine(
|
||||
"sqlite+pysqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
AccessBase.metadata.create_all(bind=self.engine)
|
||||
self.session = sessionmaker(bind=self.engine, expire_on_commit=False)()
|
||||
self.account = Account(
|
||||
id="account-1",
|
||||
email="ada@example.test",
|
||||
normalized_email="ada@example.test",
|
||||
display_name="Ada Example",
|
||||
password_hash="secret-hash",
|
||||
)
|
||||
self.user = User(
|
||||
id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id=self.account.id,
|
||||
email="ada@example.test",
|
||||
display_name="Ada Example",
|
||||
password_hash="tenant-secret-hash",
|
||||
settings={"locale": "de"},
|
||||
mail_profile_policy={"profile": "one"},
|
||||
)
|
||||
self.key = ApiKey(
|
||||
id="key-1",
|
||||
tenant_id="tenant-1",
|
||||
user_id=self.user.id,
|
||||
name="Automation",
|
||||
prefix="gpn_example",
|
||||
key_hash="do-not-export",
|
||||
scopes=["files:read"],
|
||||
)
|
||||
self.auth_session = AuthSession(
|
||||
id="session-1",
|
||||
tenant_id="tenant-1",
|
||||
user_id=self.user.id,
|
||||
account_id=self.account.id,
|
||||
token_hash="do-not-export",
|
||||
csrf_token_hash="do-not-export",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
user_agent="Browser fingerprint",
|
||||
ip_address="192.0.2.10",
|
||||
)
|
||||
self.session.add_all([self.account, self.user, self.key, self.auth_session])
|
||||
self.session.commit()
|
||||
self.provider = AccessDsarProvider()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
AccessBase.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_search_omits_secret_and_client_fingerprint_material(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(email="ADA@example.test"),
|
||||
)
|
||||
serialized = repr([record.to_dict() for record in records])
|
||||
self.assertIn("membership-1", serialized)
|
||||
self.assertNotIn("do-not-export", serialized)
|
||||
self.assertNotIn("Browser fingerprint", serialized)
|
||||
self.assertNotIn("192.0.2.10", serialized)
|
||||
|
||||
def test_multiple_subject_selectors_must_identify_the_same_membership(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
membership_id=self.user.id,
|
||||
email="different@example.test",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual((), records)
|
||||
|
||||
def test_plan_and_execution_anonymize_membership_and_revoke_credentials(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id=self.account.id),
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id=self.account.id),
|
||||
records=records,
|
||||
)
|
||||
executable = tuple(action for action in actions if action.executable)
|
||||
self.assertEqual(3, len(executable))
|
||||
self.assertTrue(any(action.kind == "manual_review" for action in actions))
|
||||
|
||||
results = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id=self.account.id),
|
||||
actions=executable,
|
||||
request_id="dsar-1",
|
||||
)
|
||||
self.assertEqual({"executed"}, {result.status for result in results})
|
||||
self.assertTrue(self.user.email.endswith("@invalid.govoplan"))
|
||||
self.assertFalse(self.user.is_active)
|
||||
self.assertEqual({}, self.user.settings)
|
||||
self.assertIsNotNone(self.key.revoked_at)
|
||||
self.assertIsNotNone(self.auth_session.revoked_at)
|
||||
self.assertIsNone(self.auth_session.user_agent)
|
||||
self.assertIsNone(self.auth_session.ip_address)
|
||||
self.assertEqual("ada@example.test", self.account.email)
|
||||
|
||||
repeated = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id=self.account.id),
|
||||
actions=executable,
|
||||
request_id="dsar-1",
|
||||
)
|
||||
self.assertEqual({"unchanged"}, {result.status for result in repeated})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, event, inspect, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.api.v1.routes import router
|
||||
from govoplan_access.backend.auth.dependencies import get_api_principal
|
||||
from govoplan_access.backend.db.models import ExternalFunctionRoleAssignment, Role
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.db.migrations import alembic_config
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
|
||||
TABLE_NAME = "access_external_function_role_assignments"
|
||||
REPAIR_REVISION = "d8f1b4e7a0c3"
|
||||
|
||||
|
||||
class ExternalFunctionMappingMigrationTests(unittest.TestCase):
|
||||
def test_release_missing_table_repair(self) -> None:
|
||||
self._verify_upgrade("release", missing=True)
|
||||
|
||||
def test_release_existing_mappings_preserved(self) -> None:
|
||||
self._verify_upgrade("release", missing=False)
|
||||
|
||||
def test_dev_missing_table_repair(self) -> None:
|
||||
self._verify_upgrade("dev", missing=True)
|
||||
|
||||
def test_dev_existing_mappings_preserved(self) -> None:
|
||||
self._verify_upgrade("dev", missing=False)
|
||||
|
||||
def _verify_upgrade(self, track: str, *, missing: bool) -> None:
|
||||
previous = "c7e0a3d6f9b2" if track == "release" else "b6d9f2a5c8e1"
|
||||
with tempfile.TemporaryDirectory(prefix="govoplan-function-mapping-upgrade-") as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'upgrade.db'}"
|
||||
config = alembic_config(database_url=url, enabled_modules=("access",), migration_track=track)
|
||||
command.upgrade(config, "4f2a9c8e7b6d")
|
||||
command.upgrade(config, previous)
|
||||
engine = create_engine(url, connect_args={"check_same_thread": False})
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def enforce_foreign_keys(connection, _record) -> None:
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
session.add_all([
|
||||
Tenant(id="tenant-1", slug="tenant-1", name="Existing tenant"),
|
||||
Tenant(id="tenant-2", slug="tenant-2", name="Other tenant"),
|
||||
])
|
||||
session.flush()
|
||||
session.add_all([
|
||||
Role(id="role-1", tenant_id="tenant-1", slug="role-1", name="Existing role", permissions=["access:function:read"]),
|
||||
Role(id="role-2", tenant_id="tenant-2", slug="role-2", name="Other role", permissions=["access:role:read"]),
|
||||
])
|
||||
session.commit()
|
||||
if missing:
|
||||
# Reproduce only in this isolated database: a recorded baseline
|
||||
# with the exact missing table observed in the live 500 response.
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("DROP TABLE access_external_function_role_assignments"))
|
||||
else:
|
||||
self._insert_mapping(engine, "mapping-1", "tenant-1", "role-1")
|
||||
self._insert_mapping(engine, "mapping-2", "tenant-2", "role-2")
|
||||
with engine.connect() as connection:
|
||||
tables_before = set(inspect(connection).get_table_names())
|
||||
parents_before = self._parent_rows(connection)
|
||||
mappings_before = [] if missing else self._mapping_rows(connection)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
principal = ApiPrincipal(
|
||||
principal=PrincipalRef(account_id="reader", membership_id="reader-1", tenant_id="tenant-1", scopes=frozenset({"access:function:read"})),
|
||||
account=None,
|
||||
user=None,
|
||||
)
|
||||
|
||||
def test_session():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_session] = test_session
|
||||
app.dependency_overrides[get_api_principal] = lambda: principal
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
path = "/api/v1/admin/external-function-role-mappings"
|
||||
if missing:
|
||||
self.assertEqual(client.get(f"{path}/delta").status_code, 500)
|
||||
command.upgrade(config, REPAIR_REVISION)
|
||||
command.upgrade(config, REPAIR_REVISION)
|
||||
for suffix in ("", "/delta"):
|
||||
response = client.get(f"{path}{suffix}")
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual([item["id"] for item in response.json()["mappings"]], [] if missing else ["mapping-1"])
|
||||
self.assertEqual(response.json()["total"], 0 if missing else 1)
|
||||
self.assertEqual(client.get(f"{path}{suffix}?tenant_id=tenant-2").status_code, 409)
|
||||
principal.principal = PrincipalRef(account_id="reader", membership_id="reader-1", tenant_id="tenant-1", scopes=frozenset())
|
||||
self.assertEqual(client.get(f"{path}/delta").status_code, 403)
|
||||
|
||||
with engine.connect() as connection:
|
||||
inspector = inspect(connection)
|
||||
self.assertEqual(set(inspector.get_table_names()), tables_before | {TABLE_NAME})
|
||||
self.assertEqual(self._parent_rows(connection), parents_before)
|
||||
self.assertEqual(self._mapping_rows(connection), mappings_before)
|
||||
columns = inspector.get_columns(TABLE_NAME)
|
||||
self.assertEqual({item["name"] for item in columns}, {"id", "tenant_id", "source_module", "function_id", "role_id", "settings", "created_at", "updated_at"})
|
||||
self.assertTrue(all(not item["nullable"] for item in columns))
|
||||
self.assertEqual(inspector.get_pk_constraint(TABLE_NAME)["constrained_columns"], ["id"])
|
||||
self.assertIn(["tenant_id", "source_module", "function_id", "role_id"], [item["column_names"] for item in inspector.get_unique_constraints(TABLE_NAME)])
|
||||
self.assertEqual({tuple(item["column_names"]) for item in inspector.get_indexes(TABLE_NAME)}, {("tenant_id",), ("role_id",), ("function_id",), ("source_module",)})
|
||||
self.assertEqual({(tuple(item["constrained_columns"]), item["referred_table"], item["options"]["ondelete"]) for item in inspector.get_foreign_keys(TABLE_NAME)}, {(("role_id",), "access_roles", "CASCADE"), (("tenant_id",), "core_scopes", "CASCADE")})
|
||||
|
||||
self._insert_mapping(engine, "mapping-after-repair", "tenant-1", "role-1", function_id="new-function")
|
||||
with self.assertRaises(IntegrityError):
|
||||
self._insert_mapping(engine, "duplicate", "tenant-1", "role-1", function_id="new-function")
|
||||
with self.assertRaises(IntegrityError):
|
||||
self._insert_mapping(engine, "bad-role", "tenant-1", "missing-role")
|
||||
with self.assertRaises(IntegrityError):
|
||||
self._insert_mapping(engine, "bad-tenant", "missing-tenant", "role-1")
|
||||
with engine.connect() as connection:
|
||||
all_mappings = self._mapping_rows(connection)
|
||||
command.downgrade(config, previous)
|
||||
command.upgrade(config, REPAIR_REVISION)
|
||||
with engine.connect() as connection:
|
||||
self.assertEqual(self._mapping_rows(connection), all_mappings)
|
||||
self.assertEqual(self._parent_rows(connection), parents_before)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
@staticmethod
|
||||
def _insert_mapping(engine, mapping_id: str, tenant_id: str, role_id: str, *, function_id: str = "function-1") -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
with Session(engine) as session:
|
||||
session.add(ExternalFunctionRoleAssignment(
|
||||
id=mapping_id, tenant_id=tenant_id, role_id=role_id,
|
||||
source_module="organizations", function_id=function_id,
|
||||
settings={"meaning": "Existing mapping", "nested": {"retained": True}},
|
||||
created_at=now, updated_at=now,
|
||||
))
|
||||
session.commit()
|
||||
|
||||
@staticmethod
|
||||
def _mapping_rows(connection):
|
||||
return [dict(row) for row in connection.execute(text("SELECT * FROM access_external_function_role_assignments ORDER BY id")).mappings()]
|
||||
|
||||
@staticmethod
|
||||
def _parent_rows(connection):
|
||||
return {
|
||||
"roles": [dict(row) for row in connection.execute(text("SELECT * FROM access_roles ORDER BY id")).mappings()],
|
||||
"tenants": [dict(row) for row in connection.execute(text("SELECT * FROM core_scopes ORDER BY id")).mappings()],
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
Role,
|
||||
SystemRoleAssignment,
|
||||
User,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_access.backend.security.passwords import verify_password
|
||||
from govoplan_access.backend.tenancy.provisioning import LegacyFirstAdminProvisioner
|
||||
from govoplan_core.core.access import FirstAdminProvisioningError
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||
|
||||
|
||||
class FirstAdminProvisioningTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
AccessBase.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine, expire_on_commit=False)
|
||||
self.session = self.Session()
|
||||
self.tenant = Tenant(id="tenant-1", slug="default", name="Default Tenant")
|
||||
self.session.add(self.tenant)
|
||||
self.session.flush()
|
||||
self.provisioner = LegacyFirstAdminProvisioner()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
AccessBase.metadata.drop_all(bind=self.engine)
|
||||
scope_registry.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_creates_one_system_owner_with_a_login_membership(self) -> None:
|
||||
created = self.provisioner.create_first_system_administrator(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
email="Owner@Example.test",
|
||||
display_name="System Owner",
|
||||
password="a-production-password",
|
||||
)
|
||||
self.session.flush()
|
||||
|
||||
account = self.session.get(Account, created.account_id)
|
||||
membership = self.session.get(User, created.membership_id)
|
||||
self.assertIsNotNone(account)
|
||||
self.assertIsNotNone(membership)
|
||||
assert account is not None
|
||||
assert membership is not None
|
||||
self.assertTrue(verify_password("a-production-password", account.password_hash))
|
||||
self.assertEqual(membership.tenant_id, self.tenant.id)
|
||||
self.assertTrue(membership.is_tenant_admin)
|
||||
system_role = (
|
||||
self.session.query(Role)
|
||||
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
|
||||
.filter(SystemRoleAssignment.account_id == account.id)
|
||||
.one()
|
||||
)
|
||||
tenant_role = (
|
||||
self.session.query(Role)
|
||||
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
|
||||
.filter(UserRoleAssignment.user_id == membership.id)
|
||||
.one()
|
||||
)
|
||||
self.assertEqual(system_role.slug, "system_owner")
|
||||
self.assertEqual(tenant_role.slug, "owner")
|
||||
self.assertTrue(
|
||||
self.provisioner.has_durable_system_administrator(self.session)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(FirstAdminProvisioningError, "already exists"):
|
||||
self.provisioner.create_first_system_administrator(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
email="second@example.test",
|
||||
display_name=None,
|
||||
password="another-production-password",
|
||||
)
|
||||
|
||||
def test_refuses_to_promote_or_reset_an_existing_account(self) -> None:
|
||||
self.session.add(
|
||||
Account(
|
||||
email="existing@example.test",
|
||||
normalized_email="existing@example.test",
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
self.session.flush()
|
||||
|
||||
with self.assertRaisesRegex(FirstAdminProvisioningError, "already belongs"):
|
||||
self.provisioner.create_first_system_administrator(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
email="existing@example.test",
|
||||
display_name=None,
|
||||
password="a-production-password",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, Role, User, UserRoleAssignment
|
||||
from govoplan_access.backend.governance_materializer import SqlAccessGovernanceMaterializer
|
||||
from govoplan_core.core.access import (
|
||||
GovernanceProjectionBatch,
|
||||
GovernanceProjectionCommand,
|
||||
GovernanceTemplateMaterialization,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
def _command(index: int, *, kind: str = "role", operation: str = "upsert") -> GovernanceProjectionCommand:
|
||||
return GovernanceProjectionCommand(
|
||||
assignment_id=f"assignment-{kind}-{index}",
|
||||
operation=operation, # type: ignore[arg-type]
|
||||
template=GovernanceTemplateMaterialization(
|
||||
template_id=f"template-{kind}",
|
||||
kind=kind, # type: ignore[arg-type]
|
||||
tenant_id=f"tenant-{index}",
|
||||
slug=f"managed-{kind}",
|
||||
name=f"Managed {kind}",
|
||||
permissions=("access:role:read",) if kind == "role" else (),
|
||||
required=True,
|
||||
),
|
||||
provenance={"source": "test", "assignment_mode": "required"},
|
||||
)
|
||||
|
||||
|
||||
class GovernanceProjectionTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.materializer = SqlAccessGovernanceMaterializer()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_bulk_projection_is_idempotent_and_returns_per_assignment_outcomes(self) -> None:
|
||||
commands = tuple(_command(index) for index in range(5))
|
||||
first = self.materializer.reconcile(
|
||||
self.session,
|
||||
GovernanceProjectionBatch(operation_id="first", commands=commands),
|
||||
)
|
||||
second = self.materializer.reconcile(
|
||||
self.session,
|
||||
GovernanceProjectionBatch(operation_id="second", commands=commands),
|
||||
)
|
||||
|
||||
self.assertEqual(["created"] * 5, [item.status for item in first.outcomes])
|
||||
self.assertEqual(["unchanged"] * 5, [item.status for item in second.outcomes])
|
||||
self.assertEqual(5, self.session.query(Role).count())
|
||||
self.assertEqual(
|
||||
{item.assignment_id for item in commands},
|
||||
{item.assignment_id for item in second.outcomes},
|
||||
)
|
||||
self.assertTrue(all(item.provenance["source"] == "test" for item in second.outcomes))
|
||||
|
||||
def test_removal_isolated_blocker_preserves_other_batch_outcomes(self) -> None:
|
||||
first, second = _command(1), _command(2)
|
||||
created = self.materializer.reconcile(
|
||||
self.session,
|
||||
GovernanceProjectionBatch(operation_id="create", commands=(first, second)),
|
||||
)
|
||||
roles = {item.tenant_id: item.resource_id for item in created.outcomes}
|
||||
account = Account(
|
||||
id="account-1",
|
||||
email="assigned@example.test",
|
||||
normalized_email="assigned@example.test",
|
||||
)
|
||||
user = User(
|
||||
id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
)
|
||||
self.session.add_all([account, user])
|
||||
self.session.flush()
|
||||
self.session.add(
|
||||
UserRoleAssignment(
|
||||
tenant_id="tenant-1",
|
||||
user_id=user.id,
|
||||
role_id=roles["tenant-1"],
|
||||
)
|
||||
)
|
||||
self.session.flush()
|
||||
|
||||
removals = tuple(
|
||||
GovernanceProjectionCommand(
|
||||
assignment_id=item.assignment_id,
|
||||
operation="remove",
|
||||
template=item.template,
|
||||
provenance=item.provenance,
|
||||
)
|
||||
for item in (first, second)
|
||||
)
|
||||
result = self.materializer.reconcile(
|
||||
self.session,
|
||||
GovernanceProjectionBatch(operation_id="remove", commands=removals),
|
||||
)
|
||||
|
||||
self.assertEqual(["blocked", "removed"], [item.status for item in result.outcomes])
|
||||
self.assertEqual(("role_has_users",), result.outcomes[0].blocker_codes)
|
||||
self.assertIsNotNone(self.session.get(Role, roles["tenant-1"]))
|
||||
self.assertIsNone(self.session.get(Role, roles["tenant-2"]))
|
||||
|
||||
def test_dry_run_does_not_mutate(self) -> None:
|
||||
result = self.materializer.reconcile(
|
||||
self.session,
|
||||
GovernanceProjectionBatch(
|
||||
operation_id="preview",
|
||||
commands=(_command(1, kind="group"),),
|
||||
dry_run=True,
|
||||
),
|
||||
)
|
||||
self.assertEqual("created", result.outcomes[0].status)
|
||||
self.assertEqual(0, self.session.query(Group).count())
|
||||
|
||||
def test_bulk_read_query_count_does_not_grow_per_assignment(self) -> None:
|
||||
def select_count(size: int) -> int:
|
||||
count = 0
|
||||
|
||||
def record_select(_conn, _cursor, statement, _parameters, _context, _executemany):
|
||||
nonlocal count
|
||||
if statement.lstrip().upper().startswith("SELECT"):
|
||||
count += 1
|
||||
|
||||
event.listen(self.engine, "before_cursor_execute", record_select)
|
||||
try:
|
||||
self.materializer.reconcile(
|
||||
self.session,
|
||||
GovernanceProjectionBatch(
|
||||
operation_id=f"preview-{size}",
|
||||
commands=tuple(
|
||||
_command(index, kind="group" if index % 2 else "role")
|
||||
for index in range(size)
|
||||
),
|
||||
dry_run=True,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
event.remove(self.engine, "before_cursor_execute", record_select)
|
||||
return count
|
||||
|
||||
small = select_count(2)
|
||||
large = select_count(200)
|
||||
self.assertEqual(small, large)
|
||||
self.assertLessEqual(large, 4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_access.backend.manifest import manifest
|
||||
|
||||
|
||||
class InterfaceDocumentationContractTests(unittest.TestCase):
|
||||
def test_password_change_flag_is_documented_as_unenforced(self) -> None:
|
||||
topic = next(item for item in manifest.documentation if item.id == "access.reference.authentication-fields")
|
||||
self.assertIn("currently advisory metadata", topic.body)
|
||||
self.assertIn("server-side enforcement are not implemented", topic.body)
|
||||
self.assertIn("serverseitige Durchsetzung sind noch nicht umgesetzt", topic.translations["de"]["body"])
|
||||
|
||||
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||
for topic in manifest.documentation:
|
||||
german = (topic.translations or {}).get("de", {})
|
||||
self.assertEqual(
|
||||
{"title", "summary", "body"},
|
||||
set(german),
|
||||
topic.id,
|
||||
)
|
||||
self.assertTrue(
|
||||
all(str(value).strip() for value in german.values()), topic.id
|
||||
)
|
||||
|
||||
def test_access_admin_topics_publish_stable_help_contexts(self) -> None:
|
||||
topics = {topic.id: topic for topic in manifest.documentation}
|
||||
|
||||
expected_contexts = {
|
||||
"access.workflow.grant-user-access": {
|
||||
"access.admin.users",
|
||||
"access.admin.groups",
|
||||
"access.admin.roles",
|
||||
"access.admin.blocked",
|
||||
},
|
||||
"access.reference.admin-access-fields": {
|
||||
"access.admin.system-users",
|
||||
"access.admin.system-roles",
|
||||
"access.admin.tenant-users",
|
||||
"access.admin.tenant-groups",
|
||||
"access.admin.tenant-roles",
|
||||
},
|
||||
"access.workflow.manage-api-keys": {
|
||||
"access.admin.api-keys",
|
||||
"access.api-keys.action.create",
|
||||
"access.api-keys.action.revoke",
|
||||
"access.api-keys.field.owner",
|
||||
"access.api-keys.field.expiry",
|
||||
"access.api-keys.field.scopes",
|
||||
"access.api-keys.secret",
|
||||
"access.api-keys.confirm-revoke",
|
||||
},
|
||||
"access.workflow.manage-reusable-credentials": {
|
||||
"access.admin.system-credentials",
|
||||
"access.admin.tenant-credentials",
|
||||
"access.admin.group-credentials",
|
||||
"access.admin.user-credentials",
|
||||
"access.settings.credentials",
|
||||
"access.credentials",
|
||||
"access.credentials.field.secret",
|
||||
"access.credentials.field.clear-secret",
|
||||
"access.credentials.field.inherit-to-lower-scopes",
|
||||
"access.credentials.action.delete",
|
||||
"access.credentials.confirm-delete",
|
||||
},
|
||||
"access.reference.external-function-role-mappings": {
|
||||
"access.admin.function-mappings",
|
||||
"access.explanation",
|
||||
},
|
||||
"access.workflow.manage-service-account-credentials": {
|
||||
"access.admin.service-accounts",
|
||||
"access.service-accounts.action.create",
|
||||
"access.service-accounts.action.activation",
|
||||
"access.service-accounts.action.retire",
|
||||
"access.service-accounts.field.scope-ceiling",
|
||||
"access.service-accounts.action.rotate-credential",
|
||||
"access.service-accounts.action.revoke-credential",
|
||||
"access.service-accounts.field.credential-expiry",
|
||||
"access.service-accounts.field.credential-scopes",
|
||||
"access.service-accounts.secret",
|
||||
"access.service-accounts.confirm-retire",
|
||||
},
|
||||
"access.workflow.manage-sessions": {
|
||||
"access.settings.sessions",
|
||||
"access.sessions.action.revoke",
|
||||
"access.sessions.action.revoke-others",
|
||||
"access.admin.user-sessions",
|
||||
},
|
||||
}
|
||||
|
||||
for topic_id, expected in expected_contexts.items():
|
||||
self.assertIn(topic_id, topics)
|
||||
metadata = topics[topic_id].metadata or {}
|
||||
self.assertTrue(
|
||||
expected.issubset(set(metadata.get("help_contexts", ()))),
|
||||
topic_id,
|
||||
)
|
||||
|
||||
credential_topic = topics["access.workflow.manage-reusable-credentials"]
|
||||
self.assertEqual(
|
||||
{"title", "summary", "body"},
|
||||
set(credential_topic.translations["de"]),
|
||||
)
|
||||
self.assertIn(
|
||||
"nicht rückgängig gemacht",
|
||||
credential_topic.translations["de"]["body"],
|
||||
)
|
||||
|
||||
api_key_topic = topics["access.workflow.manage-api-keys"]
|
||||
self.assertEqual(
|
||||
{"title", "summary", "body"},
|
||||
set(api_key_topic.translations["de"]),
|
||||
)
|
||||
self.assertIn(
|
||||
"sofort und kann für diesen Schlüssel nicht rückgängig gemacht werden",
|
||||
api_key_topic.translations["de"]["body"],
|
||||
)
|
||||
|
||||
service_account_topic = topics[
|
||||
"access.workflow.manage-service-account-credentials"
|
||||
]
|
||||
self.assertEqual(
|
||||
{"title", "summary", "body"},
|
||||
set(service_account_topic.translations["de"]),
|
||||
)
|
||||
self.assertIn(
|
||||
"widerruft sämtliche aktiven Zugangsdaten",
|
||||
service_account_topic.translations["de"]["body"],
|
||||
)
|
||||
|
||||
def test_access_admin_surfaces_remain_declared(self) -> None:
|
||||
surface_ids = {surface.id for surface in manifest.frontend.view_surfaces}
|
||||
self.assertTrue(
|
||||
{
|
||||
"access.admin.system-roles",
|
||||
"access.admin.system-users",
|
||||
"access.admin.system-credentials",
|
||||
"access.admin.tenant-roles",
|
||||
"access.admin.tenant-function-mappings",
|
||||
"access.admin.tenant-groups",
|
||||
"access.admin.tenant-users",
|
||||
"access.admin.tenant-credentials",
|
||||
"access.admin.tenant-api-keys",
|
||||
"access.admin.tenant-service-accounts",
|
||||
"access.admin.group-credentials",
|
||||
"access.admin.user-credentials",
|
||||
"access.settings.credentials",
|
||||
"access.settings.sessions",
|
||||
}.issubset(surface_ids)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -5,17 +5,30 @@ import pathlib
|
||||
import tomllib
|
||||
import unittest
|
||||
|
||||
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
SystemSettingsItem,
|
||||
TenantCreateRequest,
|
||||
)
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class OptionalTenancyContractTests(unittest.TestCase):
|
||||
def test_compatibility_admin_schemas_use_german_reference_default(self) -> None:
|
||||
tenant = TenantCreateRequest(slug="example", name="Example")
|
||||
|
||||
self.assertEqual("de", tenant.default_locale)
|
||||
self.assertEqual("de", SystemSettingsItem().default_locale)
|
||||
|
||||
def test_access_package_does_not_require_tenancy_to_install(self) -> None:
|
||||
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
|
||||
dependencies = tuple(project["dependencies"])
|
||||
|
||||
self.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.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))
|
||||
|
||||
|
||||
@@ -30,6 +30,34 @@ class PermissionCatalogContractTests(unittest.TestCase):
|
||||
self.assertIn("files:file:read", scopes)
|
||||
self.assertIn("files:read", scopes)
|
||||
|
||||
def test_api_key_intersection_excludes_canonical_and_legacy_system_scopes(self) -> None:
|
||||
for scope in ("access:system_credential:write", "access:tenant:create", "system:tenants:create"):
|
||||
with self.subTest(scope=scope):
|
||||
self.assertEqual([], access_catalog.intersect_api_key_scopes([scope], [scope]))
|
||||
|
||||
def test_api_key_module_wildcards_expand_only_to_concrete_tenant_scopes(self) -> None:
|
||||
scopes = access_catalog.intersect_api_key_scopes(["access:*"], ["access:*"])
|
||||
self.assertIn("access:membership:read", scopes)
|
||||
self.assertNotIn("access:*", scopes)
|
||||
self.assertFalse(access_catalog.scopes_grant(scopes, "access:system_credential:write"))
|
||||
catalog = access_catalog.permission_map()
|
||||
self.assertTrue(all(catalog[scope].level == "tenant" for scope in scopes if scope in catalog))
|
||||
|
||||
def test_api_key_intersection_preserves_unknown_concrete_module_grants(self) -> None:
|
||||
self.assertEqual(
|
||||
["optional-module:record:read"],
|
||||
access_catalog.intersect_api_key_scopes(["optional-module:record:read"], ["optional-module:record:read"]),
|
||||
)
|
||||
|
||||
def test_api_key_intersection_preserves_concrete_tenant_compatibility_aliases(self) -> None:
|
||||
scopes = access_catalog.intersect_api_key_scopes(["files:read"], ["files:file:read"])
|
||||
self.assertIn("files:read", scopes)
|
||||
self.assertIn("files:file:read", scopes)
|
||||
self.assertTrue(access_catalog.scopes_grant(scopes, "files:file:read"))
|
||||
|
||||
def test_api_key_intersection_does_not_retain_unknown_wildcards(self) -> None:
|
||||
self.assertEqual([], access_catalog.intersect_api_key_scopes(["optional-module:*"], ["optional-module:*"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_access.backend.reference_options import (
|
||||
SqlAccessReferenceOptionProvider,
|
||||
)
|
||||
from govoplan_core.core.references import ReferenceSearchRequest
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class AccessReferenceOptionProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[Account.__table__, User.__table__, Group.__table__],
|
||||
)
|
||||
self.session = Session(self.engine)
|
||||
for index in range(120):
|
||||
account = Account(
|
||||
id=f"account-{index:03}",
|
||||
email=f"person-{index:03}@example.test",
|
||||
normalized_email=f"person-{index:03}@example.test",
|
||||
)
|
||||
self.session.add(account)
|
||||
self.session.add(
|
||||
User(
|
||||
id=f"membership-{index:03}",
|
||||
tenant_id="tenant-1",
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=f"Person {index:03}",
|
||||
)
|
||||
)
|
||||
for index in range(75):
|
||||
self.session.add(
|
||||
Group(
|
||||
id=f"group-{index:03}",
|
||||
tenant_id="tenant-1",
|
||||
slug=f"group-{index:03}",
|
||||
name=f"Group {index:03}",
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
self.provider = SqlAccessReferenceOptionProvider()
|
||||
self.admin = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-000",
|
||||
group_ids=frozenset(),
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_large_directory_search_is_bounded_and_paged(self) -> None:
|
||||
first = self.provider.search_reference_options(
|
||||
self.session,
|
||||
self.admin,
|
||||
request=ReferenceSearchRequest(
|
||||
kind="membership",
|
||||
tenant_id="tenant-1",
|
||||
limit=25,
|
||||
context={"administrative": True},
|
||||
),
|
||||
)
|
||||
second = self.provider.search_reference_options(
|
||||
self.session,
|
||||
self.admin,
|
||||
request=ReferenceSearchRequest(
|
||||
kind="membership",
|
||||
tenant_id="tenant-1",
|
||||
limit=25,
|
||||
cursor=first.next_cursor,
|
||||
context={"administrative": True},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(25, len(first.options))
|
||||
self.assertTrue(first.has_more)
|
||||
self.assertEqual("offset:25", first.next_cursor)
|
||||
self.assertEqual("membership-000", first.options[0].value)
|
||||
self.assertEqual("membership-025", second.options[0].value)
|
||||
|
||||
def test_search_and_selected_values_do_not_materialize_the_directory(self) -> None:
|
||||
page = self.provider.search_reference_options(
|
||||
self.session,
|
||||
self.admin,
|
||||
request=ReferenceSearchRequest(
|
||||
kind="membership",
|
||||
tenant_id="tenant-1",
|
||||
query="PERSON 119",
|
||||
selected_values=("membership-005", "removed-membership"),
|
||||
limit=10,
|
||||
context={"administrative": True},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
["membership-119", "membership-005"],
|
||||
[option.value for option in page.options],
|
||||
)
|
||||
self.assertFalse(page.has_more)
|
||||
|
||||
def test_non_administrators_only_search_their_permitted_references(self) -> None:
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-004",
|
||||
group_ids=frozenset({"group-007"}),
|
||||
)
|
||||
|
||||
users = self.provider.search_reference_options(
|
||||
self.session,
|
||||
principal,
|
||||
request=ReferenceSearchRequest(
|
||||
kind="user",
|
||||
tenant_id="tenant-1",
|
||||
),
|
||||
)
|
||||
groups = self.provider.search_reference_options(
|
||||
self.session,
|
||||
principal,
|
||||
request=ReferenceSearchRequest(
|
||||
kind="group",
|
||||
tenant_id="tenant-1",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(["account-004"], [option.value for option in users.options])
|
||||
self.assertEqual(["group-007"], [option.value for option in groups.options])
|
||||
|
||||
def test_invalid_cursor_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "Invalid reference search cursor"):
|
||||
self.provider.search_reference_options(
|
||||
self.session,
|
||||
self.admin,
|
||||
request=ReferenceSearchRequest(
|
||||
kind="group",
|
||||
tenant_id="tenant-1",
|
||||
cursor="page:2",
|
||||
context={"administrative": True},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS,
|
||||
AccessExplanationSubjectDecision,
|
||||
PrincipalRef,
|
||||
)
|
||||
from govoplan_access.backend.api.v1.routes import (
|
||||
_access_explanation_subject_decision,
|
||||
)
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _principal() -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
),
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
|
||||
|
||||
class _SubjectPolicy:
|
||||
def decide_subject_selection(
|
||||
self,
|
||||
session: object,
|
||||
principal: PrincipalRef,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> AccessExplanationSubjectDecision:
|
||||
del session, principal, tenant_id
|
||||
return AccessExplanationSubjectDecision(
|
||||
allow_other_users=True,
|
||||
reason="Permitted by test policy.",
|
||||
source="test.policy",
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider: object | None = None) -> None:
|
||||
self.provider = provider
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return (
|
||||
name == CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS
|
||||
and self.provider is not None
|
||||
)
|
||||
|
||||
def require_capability(self, name: str) -> object:
|
||||
if not self.has_capability(name):
|
||||
raise KeyError(name)
|
||||
return self.provider
|
||||
|
||||
|
||||
class ResourceAccessExplanationSubjectTests(unittest.TestCase):
|
||||
def test_missing_policy_defaults_to_current_user(self) -> None:
|
||||
with patch(
|
||||
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||
return_value=None,
|
||||
):
|
||||
decision = _access_explanation_subject_decision(
|
||||
object(), # type: ignore[arg-type]
|
||||
_principal(),
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
|
||||
self.assertFalse(decision.allow_other_users)
|
||||
self.assertEqual("access.safe_default", decision.source)
|
||||
|
||||
def test_policy_capability_controls_cross_user_selection(self) -> None:
|
||||
with patch(
|
||||
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||
return_value=_Registry(_SubjectPolicy()),
|
||||
):
|
||||
decision = _access_explanation_subject_decision(
|
||||
object(), # type: ignore[arg-type]
|
||||
_principal(),
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
|
||||
self.assertTrue(decision.allow_other_users)
|
||||
self.assertEqual("test.policy", decision.source)
|
||||
|
||||
def test_route_contract_is_tenant_bounded_and_audited(self) -> None:
|
||||
source = (
|
||||
ROOT / "src/govoplan_access/backend/api/v1/routes.py"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn('User.tenant_id == tenant.id', source)
|
||||
self.assertIn('User.id == principal.membership_id', source)
|
||||
self.assertIn(
|
||||
'action="access.resource_explanation.selected_user_viewed"',
|
||||
source,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,332 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
AuthSession,
|
||||
User,
|
||||
)
|
||||
from govoplan_access.backend.service_accounts import (
|
||||
ServiceAccountConflictError,
|
||||
create_service_account,
|
||||
create_service_account_credential,
|
||||
revoke_service_account_credential,
|
||||
retire_service_account,
|
||||
rotate_service_account_credential,
|
||||
service_account_credential_summaries,
|
||||
update_service_account,
|
||||
)
|
||||
from govoplan_access.backend.auth.dependencies import (
|
||||
_resolve_api_key_principal_context,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.tenancy.scope import (
|
||||
Tenant,
|
||||
create_scope_tables,
|
||||
scope_registry,
|
||||
)
|
||||
|
||||
|
||||
class ServiceAccountTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
AccessBase.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.tenant = Tenant(
|
||||
id="tenant-1",
|
||||
slug="tenant-1",
|
||||
name="Tenant 1",
|
||||
)
|
||||
self.account = Account(
|
||||
id="account-1",
|
||||
email="admin@example.test",
|
||||
normalized_email="admin@example.test",
|
||||
)
|
||||
self.user = User(
|
||||
id="user-1",
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=self.account.id,
|
||||
email=self.account.email,
|
||||
)
|
||||
self.session.add_all(
|
||||
(self.tenant, self.account, self.user)
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
AccessBase.metadata.drop_all(bind=self.engine)
|
||||
scope_registry.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def _principal(
|
||||
self,
|
||||
scopes: frozenset[str] = frozenset({"tenant:*"}),
|
||||
) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id=self.account.id,
|
||||
membership_id=self.user.id,
|
||||
tenant_id=self.tenant.id,
|
||||
scopes=scopes,
|
||||
),
|
||||
account=self.account,
|
||||
user=self.user,
|
||||
)
|
||||
|
||||
def test_create_builds_a_non_login_identity_without_secrets(self) -> None:
|
||||
item = create_service_account(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
principal=self._principal(),
|
||||
name=" Monthly import ",
|
||||
description="Runs the governed monthly import.",
|
||||
scope_ceiling=(
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
),
|
||||
)
|
||||
|
||||
backing_account = self.session.get(
|
||||
Account,
|
||||
item.account_id,
|
||||
)
|
||||
membership = self.session.get(
|
||||
User,
|
||||
item.membership_id,
|
||||
)
|
||||
self.assertEqual("Monthly import", item.name)
|
||||
self.assertEqual(
|
||||
[
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
],
|
||||
item.scope_ceiling,
|
||||
)
|
||||
self.assertEqual(
|
||||
"service_account",
|
||||
backing_account.auth_provider,
|
||||
)
|
||||
self.assertIsNone(backing_account.password_hash)
|
||||
self.assertEqual(
|
||||
"service_account",
|
||||
membership.auth_provider,
|
||||
)
|
||||
self.assertIsNone(membership.password_hash)
|
||||
self.assertEqual(
|
||||
0,
|
||||
self.session.query(ApiKey)
|
||||
.filter(ApiKey.user_id == membership.id)
|
||||
.count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
0,
|
||||
self.session.query(AuthSession)
|
||||
.filter(AuthSession.user_id == membership.id)
|
||||
.count(),
|
||||
)
|
||||
|
||||
def test_scope_escalation_and_stale_updates_fail_closed(self) -> None:
|
||||
principal = self._principal(
|
||||
frozenset({"dataflow:pipeline:run"})
|
||||
)
|
||||
with self.assertRaises(PermissionError):
|
||||
create_service_account(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
principal=principal,
|
||||
name="Escalating worker",
|
||||
description=None,
|
||||
scope_ceiling=("system:settings:write",),
|
||||
)
|
||||
|
||||
item = create_service_account(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
principal=principal,
|
||||
name="Bounded worker",
|
||||
description=None,
|
||||
scope_ceiling=("dataflow:pipeline:run",),
|
||||
)
|
||||
updated = update_service_account(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
principal=principal,
|
||||
expected_revision=1,
|
||||
changes={"description": "Updated"},
|
||||
)
|
||||
self.assertEqual(2, updated.revision)
|
||||
with self.assertRaises(ServiceAccountConflictError):
|
||||
update_service_account(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
principal=principal,
|
||||
expected_revision=1,
|
||||
changes={"description": "Stale"},
|
||||
)
|
||||
|
||||
def test_retirement_revokes_the_backing_principal(self) -> None:
|
||||
principal = self._principal()
|
||||
item = create_service_account(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
principal=principal,
|
||||
name="Retired worker",
|
||||
description=None,
|
||||
scope_ceiling=("dataflow:pipeline:run",),
|
||||
)
|
||||
|
||||
retired = retire_service_account(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
principal=principal,
|
||||
expected_revision=1,
|
||||
)
|
||||
|
||||
self.assertFalse(retired.is_active)
|
||||
self.assertIsNotNone(retired.retired_at)
|
||||
self.assertFalse(
|
||||
self.session.get(Account, retired.account_id).is_active
|
||||
)
|
||||
self.assertFalse(
|
||||
self.session.get(User, retired.membership_id).is_active
|
||||
)
|
||||
|
||||
def test_credentials_are_one_time_scope_bounded_and_rotatable(self) -> None:
|
||||
principal = self._principal()
|
||||
item = create_service_account(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
principal=principal,
|
||||
name="Monthly worker",
|
||||
description=None,
|
||||
scope_ceiling=("dataflow:pipeline:run",),
|
||||
)
|
||||
item, first = create_service_account_credential(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
principal=principal,
|
||||
expected_revision=1,
|
||||
name="Worker credential",
|
||||
scopes=("dataflow:pipeline:run",),
|
||||
expires_at=None,
|
||||
)
|
||||
self.assertEqual(2, item.revision)
|
||||
self.assertTrue(first.secret.startswith("gpn_"))
|
||||
self.assertNotEqual(first.secret, first.model.key_hash)
|
||||
|
||||
item, previous, replacement = rotate_service_account_credential(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
credential_id=first.model.id,
|
||||
principal=principal,
|
||||
expected_revision=2,
|
||||
name=None,
|
||||
scopes=None,
|
||||
expires_at=None,
|
||||
)
|
||||
self.assertEqual(3, item.revision)
|
||||
self.assertIsNotNone(previous.revoked_at)
|
||||
self.assertIsNone(replacement.model.revoked_at)
|
||||
self.assertNotEqual(first.secret, replacement.secret)
|
||||
|
||||
with self.assertRaises(ServiceAccountConflictError):
|
||||
revoke_service_account_credential(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
credential_id=replacement.model.id,
|
||||
principal=principal,
|
||||
expected_revision=2,
|
||||
)
|
||||
|
||||
item, revoked = revoke_service_account_credential(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
credential_id=replacement.model.id,
|
||||
principal=principal,
|
||||
expected_revision=3,
|
||||
)
|
||||
self.assertEqual(4, item.revision)
|
||||
self.assertIsNotNone(revoked.revoked_at)
|
||||
summary = service_account_credential_summaries(
|
||||
self.session,
|
||||
service_accounts=(item,),
|
||||
)[item.id]
|
||||
self.assertEqual(2, summary.credential_count)
|
||||
self.assertEqual(0, summary.active_credential_count)
|
||||
|
||||
def test_service_account_credential_uses_current_ceiling(self) -> None:
|
||||
principal = self._principal()
|
||||
item = create_service_account(
|
||||
self.session,
|
||||
tenant=self.tenant,
|
||||
principal=principal,
|
||||
name="Bounded API worker",
|
||||
description=None,
|
||||
scope_ceiling=("dataflow:pipeline:run",),
|
||||
)
|
||||
item, created = create_service_account_credential(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
principal=principal,
|
||||
expected_revision=1,
|
||||
name="Runtime",
|
||||
scopes=("dataflow:pipeline:run",),
|
||||
expires_at=None,
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
context = _resolve_api_key_principal_context(
|
||||
self.session,
|
||||
token=created.secret,
|
||||
idm_directory=None,
|
||||
identity_directory=None,
|
||||
organization_directory=None,
|
||||
)
|
||||
self.assertIsNotNone(context)
|
||||
self.assertEqual("service_account", context.principal.auth_method)
|
||||
self.assertEqual(item.id, context.principal.service_account_id)
|
||||
self.assertEqual(created.model.id, context.principal.api_key_id)
|
||||
self.assertEqual(
|
||||
frozenset({"dataflow:pipeline:run"}),
|
||||
context.principal.scopes,
|
||||
)
|
||||
|
||||
update_service_account(
|
||||
self.session,
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=item.id,
|
||||
principal=principal,
|
||||
expected_revision=2,
|
||||
changes={"scope_ceiling": []},
|
||||
)
|
||||
self.session.commit()
|
||||
narrowed = _resolve_api_key_principal_context(
|
||||
self.session,
|
||||
token=created.secret,
|
||||
idm_directory=None,
|
||||
identity_directory=None,
|
||||
organization_directory=None,
|
||||
)
|
||||
self.assertEqual(frozenset(), narrowed.principal.scopes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,277 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import Account, AuthSession, User
|
||||
from govoplan_access.backend.security.sessions import authenticate_session_token, hash_session_token
|
||||
from govoplan_access.backend.security.passwords import hash_password
|
||||
from govoplan_access.backend.api.v1.admin_schemas import AdminSessionItem
|
||||
from govoplan_access.backend.api.v1.auth import AccountSessionInfo
|
||||
from govoplan_access.backend.api.v1.routes import _require_session_admin_reauthorization
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from fastapi import HTTPException
|
||||
from govoplan_access.backend.session_management import (
|
||||
MAX_CLIENT_LABEL_LENGTH,
|
||||
list_account_sessions,
|
||||
revoke_account_session,
|
||||
revoke_other_account_sessions,
|
||||
)
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||
|
||||
|
||||
class SessionManagementTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
AccessBase.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.now = datetime(2026, 8, 19, 20, 0, tzinfo=timezone.utc)
|
||||
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||
other_tenant = Tenant(id="tenant-2", slug="tenant-2", name="Tenant 2")
|
||||
self.account = Account(
|
||||
id="account-1",
|
||||
email="person@example.test",
|
||||
normalized_email="person@example.test",
|
||||
)
|
||||
other_account = Account(
|
||||
id="account-2",
|
||||
email="other@example.test",
|
||||
normalized_email="other@example.test",
|
||||
)
|
||||
user = User(
|
||||
id="user-1",
|
||||
tenant_id=tenant.id,
|
||||
account_id=self.account.id,
|
||||
email=self.account.email,
|
||||
)
|
||||
other_user = User(
|
||||
id="user-2",
|
||||
tenant_id=tenant.id,
|
||||
account_id=other_account.id,
|
||||
email=other_account.email,
|
||||
)
|
||||
self.session.add_all((tenant, other_tenant, self.account, other_account, user, other_user))
|
||||
self.session.flush()
|
||||
self.tokens = {
|
||||
"current": "ms_current",
|
||||
"other": "ms_other",
|
||||
"expired": "ms_expired",
|
||||
"revoked": "ms_revoked",
|
||||
"other-account": "ms_other_account",
|
||||
}
|
||||
self.session.add_all(
|
||||
(
|
||||
self._auth_session("current", "tenant-1", "user-1", "account-1"),
|
||||
self._auth_session("other", "tenant-2", "user-1", "account-1"),
|
||||
self._auth_session("expired", "tenant-1", "user-1", "account-1", expires=-1),
|
||||
self._auth_session("revoked", "tenant-1", "user-1", "account-1", revoked=True),
|
||||
self._auth_session("other-account", "tenant-1", "user-2", "account-2"),
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
AccessBase.metadata.drop_all(bind=self.engine)
|
||||
scope_registry.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def _auth_session(
|
||||
self,
|
||||
name: str,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
account_id: str,
|
||||
*,
|
||||
expires: int = 2,
|
||||
revoked: bool = False,
|
||||
) -> AuthSession:
|
||||
return AuthSession(
|
||||
id=f"session-{name}",
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
token_hash=hash_session_token(self.tokens[name]),
|
||||
expires_at=self.now + timedelta(hours=expires),
|
||||
last_seen_at=self.now - timedelta(minutes=5),
|
||||
revoked_at=self.now - timedelta(minutes=1) if revoked else None,
|
||||
user_agent="Browser " + ("x" * 500),
|
||||
ip_address="192.0.2.55",
|
||||
)
|
||||
|
||||
def test_listing_is_account_scoped_bounded_and_redacted(self) -> None:
|
||||
sensitive = {
|
||||
"token",
|
||||
"token_hash",
|
||||
"csrf_token_hash",
|
||||
"cookie",
|
||||
"ip_address",
|
||||
}
|
||||
self.assertTrue(sensitive.isdisjoint(AccountSessionInfo.model_fields))
|
||||
self.assertTrue(sensitive.isdisjoint(AdminSessionItem.model_fields))
|
||||
active = list_account_sessions(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
current_session_id="session-current",
|
||||
now=self.now,
|
||||
)
|
||||
self.assertEqual({"session-current", "session-other"}, {item.id for item in active})
|
||||
self.assertTrue(next(item for item in active if item.id == "session-current").current)
|
||||
self.assertTrue(all(len(item.client or "") <= MAX_CLIENT_LABEL_LENGTH for item in active))
|
||||
self.assertNotIn("192.0.2.55", repr(active))
|
||||
self.assertNotIn("token_hash", repr(active))
|
||||
|
||||
all_states = list_account_sessions(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
current_session_id="session-current",
|
||||
include_inactive=True,
|
||||
now=self.now,
|
||||
)
|
||||
self.assertEqual(
|
||||
{"active", "expired", "revoked"},
|
||||
{item.status for item in all_states},
|
||||
)
|
||||
tenant_only = list_account_sessions(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
current_session_id="session-current",
|
||||
tenant_id="tenant-1",
|
||||
include_inactive=True,
|
||||
now=self.now,
|
||||
)
|
||||
self.assertNotIn("session-other", {item.id for item in tenant_only})
|
||||
|
||||
def test_single_revocation_is_idempotent_and_effective_on_next_request(self) -> None:
|
||||
item, changed = revoke_account_session(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
session_id="session-other",
|
||||
protected_session_id="session-current",
|
||||
now=self.now,
|
||||
)
|
||||
self.assertTrue(changed)
|
||||
self.session.commit()
|
||||
self.assertIsNotNone(item)
|
||||
self.assertIsNone(
|
||||
authenticate_session_token(self.session, self.tokens["other"])
|
||||
)
|
||||
|
||||
repeated, changed = revoke_account_session(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
session_id="session-other",
|
||||
protected_session_id="session-current",
|
||||
now=self.now,
|
||||
)
|
||||
self.assertIs(item, repeated)
|
||||
self.assertFalse(changed)
|
||||
hidden, changed = revoke_account_session(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
session_id="session-other-account",
|
||||
now=self.now,
|
||||
)
|
||||
self.assertIsNone(hidden)
|
||||
self.assertFalse(changed)
|
||||
|
||||
def test_listing_applies_activity_filter_and_limit_before_loading_history(self) -> None:
|
||||
statements: list[str] = []
|
||||
|
||||
def capture_query(connection, cursor, statement, parameters, context, executemany):
|
||||
if statement.lstrip().upper().startswith("SELECT") and "access_auth_sessions" in statement:
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(self.engine, "before_cursor_execute", capture_query)
|
||||
try:
|
||||
for include_inactive in (False, True):
|
||||
with self.subTest(include_inactive=include_inactive):
|
||||
statements.clear()
|
||||
summaries = list_account_sessions(
|
||||
self.session,
|
||||
account_id="account-1",
|
||||
current_session_id="session-current",
|
||||
include_inactive=include_inactive,
|
||||
limit=1,
|
||||
now=self.now,
|
||||
)
|
||||
self.assertEqual(1, len(summaries))
|
||||
self.assertEqual(1, len(statements))
|
||||
self.assertIn("LIMIT", statements[0])
|
||||
if not include_inactive:
|
||||
self.assertEqual("active", summaries[0].status)
|
||||
self.assertIn("revoked_at IS NULL", statements[0])
|
||||
self.assertIn("expires_at >", statements[0])
|
||||
finally:
|
||||
event.remove(self.engine, "before_cursor_execute", capture_query)
|
||||
|
||||
def test_current_session_is_protected_and_revoke_others_skips_expired(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "current session"):
|
||||
revoke_account_session(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
session_id="session-current",
|
||||
protected_session_id="session-current",
|
||||
now=self.now,
|
||||
)
|
||||
revoked = revoke_other_account_sessions(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
current_session_id="session-current",
|
||||
now=self.now,
|
||||
)
|
||||
self.assertEqual(("session-other",), revoked)
|
||||
self.assertIsNone(self.session.get(AuthSession, "session-current").revoked_at)
|
||||
self.assertIsNone(self.session.get(AuthSession, "session-expired").revoked_at)
|
||||
self.assertEqual(
|
||||
(),
|
||||
revoke_other_account_sessions(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
current_session_id="session-current",
|
||||
now=self.now,
|
||||
),
|
||||
)
|
||||
|
||||
def test_administrative_revocation_requires_session_and_current_password(self) -> None:
|
||||
self.account.password_hash = hash_password("correct horse")
|
||||
membership = self.session.get(User, "user-1")
|
||||
current = self.session.get(AuthSession, "session-current")
|
||||
principal_ref = PrincipalRef(
|
||||
account_id=self.account.id,
|
||||
membership_id=membership.id,
|
||||
tenant_id=membership.tenant_id,
|
||||
scopes=frozenset({"access:membership:update"}),
|
||||
auth_method="session",
|
||||
session_id=current.id,
|
||||
)
|
||||
without_session = ApiPrincipal(
|
||||
principal=principal_ref,
|
||||
account=self.account,
|
||||
user=membership,
|
||||
)
|
||||
with self.assertRaises(HTTPException) as missing:
|
||||
_require_session_admin_reauthorization(without_session, "correct horse")
|
||||
self.assertEqual(403, missing.exception.status_code)
|
||||
|
||||
principal = ApiPrincipal(
|
||||
principal=principal_ref,
|
||||
account=self.account,
|
||||
user=membership,
|
||||
auth_session=current,
|
||||
)
|
||||
with self.assertRaises(HTTPException) as incorrect:
|
||||
_require_session_admin_reauthorization(principal, "incorrect")
|
||||
self.assertEqual(403, incorrect.exception.status_code)
|
||||
_require_session_admin_reauthorization(principal, "correct horse")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
AuthSession,
|
||||
Group,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from govoplan_access.backend.tenant_erasure_provider import (
|
||||
AccessTenantErasureProvider,
|
||||
)
|
||||
|
||||
|
||||
def test_access_erasure_is_tenant_bounded_and_retains_global_account() -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
AccessBase.metadata.create_all(engine)
|
||||
now = datetime.now(UTC)
|
||||
with Session(engine) as session:
|
||||
account = Account(
|
||||
email="shared@example.test",
|
||||
normalized_email="shared@example.test",
|
||||
is_active=True,
|
||||
auth_provider="local",
|
||||
)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
first = User(
|
||||
tenant_id="tenant-1",
|
||||
account_id=account.id,
|
||||
email="shared@example.test",
|
||||
is_active=True,
|
||||
is_tenant_admin=True,
|
||||
auth_provider="local",
|
||||
)
|
||||
second = User(
|
||||
tenant_id="tenant-2",
|
||||
account_id=account.id,
|
||||
email="shared@example.test",
|
||||
is_active=True,
|
||||
is_tenant_admin=False,
|
||||
auth_provider="local",
|
||||
)
|
||||
session.add_all(
|
||||
[
|
||||
first,
|
||||
second,
|
||||
Group(tenant_id="tenant-1", slug="group", name="Group"),
|
||||
Group(tenant_id="tenant-2", slug="group", name="Group"),
|
||||
Role(tenant_id="tenant-1", slug="role", name="Role"),
|
||||
Role(tenant_id="tenant-2", slug="role", name="Role"),
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
session.add_all(
|
||||
[
|
||||
ApiKey(
|
||||
tenant_id="tenant-1",
|
||||
user_id=first.id,
|
||||
name="key",
|
||||
prefix="prefix",
|
||||
key_hash="hash",
|
||||
scopes=[],
|
||||
),
|
||||
AuthSession(
|
||||
tenant_id="tenant-1",
|
||||
user_id=first.id,
|
||||
account_id=account.id,
|
||||
token_hash="token-hash",
|
||||
expires_at=now + timedelta(hours=1),
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
provider = AccessTenantErasureProvider()
|
||||
preview = provider.preview_tenant_erasure(session, "tenant-1")
|
||||
|
||||
assert preview.allowed
|
||||
assert [step.step_id for step in preview.steps] == [
|
||||
"revoke-tenant-credentials",
|
||||
"erase-tenant-access",
|
||||
]
|
||||
assert "revoke-tenant-credentials" in preview.steps[1].depends_on
|
||||
|
||||
revoked = provider.execute_tenant_erasure_step(
|
||||
session,
|
||||
"tenant-1",
|
||||
"revoke-tenant-credentials",
|
||||
"operation:access:credentials",
|
||||
)
|
||||
erased = provider.execute_tenant_erasure_step(
|
||||
session,
|
||||
"tenant-1",
|
||||
"erase-tenant-access",
|
||||
"operation:access:tenant",
|
||||
)
|
||||
session.commit()
|
||||
|
||||
assert revoked.state == "completed"
|
||||
assert erased.state == "completed"
|
||||
assert provider.preview_tenant_erasure(session, "tenant-1").steps == ()
|
||||
assert session.scalar(select(Account).where(Account.id == account.id)) is not None
|
||||
assert session.scalar(select(User).where(User.tenant_id == "tenant-2")) is not None
|
||||
assert session.scalar(select(Group).where(Group.tenant_id == "tenant-2")) is not None
|
||||
assert session.scalar(select(Role).where(Role.tenant_id == "tenant-2")) is not None
|
||||
|
||||
|
||||
def test_access_erasure_replay_is_idempotent() -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
AccessBase.metadata.create_all(engine)
|
||||
provider = AccessTenantErasureProvider()
|
||||
with Session(engine) as session:
|
||||
first = provider.execute_tenant_erasure_step(
|
||||
session,
|
||||
"tenant-1",
|
||||
"erase-tenant-access",
|
||||
"operation:access:tenant",
|
||||
)
|
||||
second = provider.reconcile_tenant_erasure_step(
|
||||
session,
|
||||
"tenant-1",
|
||||
"erase-tenant-access",
|
||||
"operation:access:tenant",
|
||||
)
|
||||
|
||||
assert first.metrics == {"deleted": 0}
|
||||
assert second.metrics == {"deleted": 0}
|
||||
+8
-5
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -13,11 +16,11 @@
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.11",
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const read = (path) => readFileSync(resolve(root, path), "utf8");
|
||||
|
||||
const adminPage = read("src/features/admin/AdminPage.tsx");
|
||||
const users = read("src/features/admin/UsersPanel.tsx");
|
||||
const groups = read("src/features/admin/GroupsPanel.tsx");
|
||||
const roles = read("src/features/admin/RolesPanel.tsx");
|
||||
const systemUsers = read("src/features/admin/SystemUsersPanel.tsx");
|
||||
const systemRoles = read("src/features/admin/SystemRolesPanel.tsx");
|
||||
const apiKeys = read("src/features/admin/ApiKeysPanel.tsx");
|
||||
const serviceAccounts = read("src/features/admin/ServiceAccountsPanel.tsx");
|
||||
const mappings = read("src/features/admin/ExternalFunctionRoleMappingsPanel.tsx");
|
||||
const credentials = read("src/features/admin/CredentialEnvelopesPanel.tsx");
|
||||
const files = read("src/features/admin/FileConnectorsPanel.tsx");
|
||||
const mail = read("src/features/admin/MailProfilesPanel.tsx");
|
||||
const moduleSource = read("src/module.ts");
|
||||
const sessions = read("src/features/sessions/SessionSettingsPanel.tsx");
|
||||
const surfaces = [users, groups, roles, systemUsers, systemRoles, apiKeys, mappings];
|
||||
const allAdminSource = [adminPage, credentials, files, mail, serviceAccounts, ...surfaces].join("\n");
|
||||
|
||||
assert.match(adminPage, /TreeSubnav/);
|
||||
assert.match(adminPage, /ActionBlockerHint/);
|
||||
assert.match(adminPage, /ACCESS_WORKFLOW_DOCUMENTATION/);
|
||||
|
||||
for (const source of surfaces) {
|
||||
assert.match(source, /AdminPageLayout/);
|
||||
assert.match(source, /DataGrid/);
|
||||
assert.match(source, /DocumentationHelpLink/);
|
||||
assert.match(source, /disabledReason/);
|
||||
}
|
||||
|
||||
for (const source of [users, groups, roles, systemUsers, systemRoles, apiKeys, mappings]) {
|
||||
assert.match(source, /ConfirmDialog/);
|
||||
}
|
||||
|
||||
assert.match(credentials, /CredentialEnvelopeManager/);
|
||||
assert.match(credentials, /DocumentationHelpLink/);
|
||||
assert.match(files, /usePlatformUiCapability<FilesConnectorsUiCapability>/);
|
||||
assert.match(files, /ActionBlockerHint/);
|
||||
assert.match(mail, /usePlatformUiCapability<MailProfilesUiCapability>/);
|
||||
assert.match(mail, /ActionBlockerHint/);
|
||||
assert.match(serviceAccounts, /Service accounts/);
|
||||
assert.match(serviceAccounts, /createServiceAccountCredential/);
|
||||
assert.match(serviceAccounts, /rotateServiceAccountCredential/);
|
||||
assert.match(serviceAccounts, /revokeServiceAccountCredential/);
|
||||
assert.match(serviceAccounts, /Secrets are shown once/);
|
||||
assert.match(serviceAccounts, /<ConfirmDialog[\s\S]*Retire service account/);
|
||||
|
||||
for (const contextId of [
|
||||
"access.api-keys.action.create",
|
||||
"access.api-keys.action.revoke",
|
||||
"access.api-keys.field.owner",
|
||||
"access.api-keys.field.expiry",
|
||||
"access.api-keys.field.scopes",
|
||||
"access.api-keys.secret",
|
||||
"access.api-keys.confirm-revoke"
|
||||
]) {
|
||||
assert.ok(apiKeys.includes(contextId), `API-key help context ${contextId} is missing`);
|
||||
}
|
||||
|
||||
for (const contextId of [
|
||||
"access.service-accounts.action.create",
|
||||
"access.service-accounts.action.activation",
|
||||
"access.service-accounts.action.retire",
|
||||
"access.service-accounts.field.scope-ceiling",
|
||||
"access.service-accounts.action.rotate-credential",
|
||||
"access.service-accounts.action.revoke-credential",
|
||||
"access.service-accounts.field.credential-expiry",
|
||||
"access.service-accounts.field.credential-scopes",
|
||||
"access.service-accounts.secret",
|
||||
"access.service-accounts.confirm-retire"
|
||||
]) {
|
||||
assert.ok(serviceAccounts.includes(contextId), `Service-account help context ${contextId} is missing`);
|
||||
}
|
||||
assert.match(moduleSource, /access\.admin\.tenant-service-accounts/);
|
||||
assert.match(moduleSource, /access\.settings\.sessions/);
|
||||
assert.match(moduleSource, /"settings\.sections": accessSettingsSections/);
|
||||
assert.match(sessions, /PageActionBar/);
|
||||
assert.match(sessions, /reloadAction/);
|
||||
assert.match(sessions, /destructiveActions/);
|
||||
assert.match(sessions, /DataGrid/);
|
||||
assert.match(sessions, /ConfirmDialog/);
|
||||
assert.doesNotMatch(sessions, /window\.(alert|confirm|prompt)\s*\(/);
|
||||
assert.match(users, /fetchAdminUserSessions/);
|
||||
assert.match(users, /revokeAdminUserSession/);
|
||||
assert.match(users, /admin-user-sessions-v1/);
|
||||
assert.match(users, /PasswordField/);
|
||||
assert.match(users, /canRevokeSessions/);
|
||||
assert.match(moduleSource, /translations,/);
|
||||
assert.match(moduleSource, /version: "0\.1\.11"/);
|
||||
|
||||
assert.doesNotMatch(allAdminSource, /window\.(alert|confirm|prompt)\s*\(/);
|
||||
assert.doesNotMatch(allAdminSource, /@govoplan\/(files|mail|organizations|idm)-webui\//);
|
||||
|
||||
console.log("Access interface pattern-language checks passed.");
|
||||
@@ -0,0 +1,31 @@
|
||||
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type ActingContext = {
|
||||
assignment_id: string;
|
||||
acting_for_account_id: string;
|
||||
function_id: string;
|
||||
organization_unit_id: string;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
};
|
||||
|
||||
export type ActingContextList = {
|
||||
contexts: ActingContext[];
|
||||
active_assignment_id?: string | null;
|
||||
};
|
||||
|
||||
export function fetchActingContexts(settings: ApiSettings): Promise<ActingContextList> {
|
||||
return apiFetch<ActingContextList>(settings, "/api/v1/auth/acting-contexts", {
|
||||
cache: "no-store"
|
||||
});
|
||||
}
|
||||
|
||||
export function switchActingContext(
|
||||
settings: ApiSettings,
|
||||
assignmentId: string | null
|
||||
): Promise<ActingContextList> {
|
||||
return apiFetch<ActingContextList>(settings, "/api/v1/auth/switch-acting-context", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ assignment_id: assignmentId })
|
||||
});
|
||||
}
|
||||
+110
-80
@@ -20,12 +20,6 @@ export type {
|
||||
TenantAdminItem
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
export type TenantOwnerCandidate = {
|
||||
account_id: string;
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
};
|
||||
|
||||
export type RoleSummary = {
|
||||
id: string;
|
||||
slug: string;
|
||||
@@ -178,24 +172,6 @@ export type LanguagePackage = {
|
||||
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 = {
|
||||
tenant_id: string;
|
||||
mode: "available" | "required";
|
||||
@@ -228,6 +204,43 @@ export type ApiKeyAdminItem = {
|
||||
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 = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
@@ -251,14 +264,8 @@ export type GroupListDeltaResponse = { groups: GroupSummary[] } & DeltaResponseF
|
||||
export type RoleListDeltaResponse = { roles: RoleSummary[] } & DeltaResponseFields;
|
||||
export type SystemAccountListDeltaResponse = { accounts: SystemAccountItem[]; roles: RoleSummary[] } & DeltaResponseFields;
|
||||
export type ApiKeyListDeltaResponse = { api_keys: ApiKeyAdminItem[] } & DeltaResponseFields;
|
||||
export type TenantListDeltaResponse = { tenants: TenantAdminItem[] } & DeltaResponseFields;
|
||||
export type GovernanceTemplateListDeltaResponse = { templates: GovernanceTemplateItem[] } & 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 {
|
||||
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[]> {
|
||||
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/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" });
|
||||
}
|
||||
|
||||
export async function fetchServiceAccounts(settings: ApiSettings): Promise<ServiceAccountItem[]> {
|
||||
const response = await apiFetch<{ items: ServiceAccountItem[] }>(settings, "/api/v1/admin/service-accounts");
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export function createServiceAccount(settings: ApiSettings, payload: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
scope_ceiling: string[];
|
||||
}): Promise<ServiceAccountItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/service-accounts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateServiceAccount(settings: ApiSettings, serviceAccountId: string, payload: {
|
||||
expected_revision: number;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
scope_ceiling?: string[];
|
||||
is_active?: boolean;
|
||||
}): Promise<ServiceAccountItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function retireServiceAccount(settings: ApiSettings, serviceAccountId: string, expectedRevision: number): Promise<ServiceAccountItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/retire`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expected_revision: expectedRevision })
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchServiceAccountCredentials(settings: ApiSettings, serviceAccountId: string, includeRevoked = true): Promise<ServiceAccountCredentialListResponse> {
|
||||
return apiFetch(settings, apiPath(`/api/v1/admin/service-accounts/${serviceAccountId}/credentials`, {
|
||||
include_revoked: includeRevoked
|
||||
}));
|
||||
}
|
||||
|
||||
export function createServiceAccountCredential(settings: ApiSettings, serviceAccountId: string, payload: {
|
||||
expected_revision: number;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
expires_at?: string | null;
|
||||
}): Promise<ServiceAccountCredentialMutationResponse & { secret: string }> {
|
||||
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/credentials`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function rotateServiceAccountCredential(settings: ApiSettings, serviceAccountId: string, credentialId: string, payload: {
|
||||
expected_revision: number;
|
||||
name?: string | null;
|
||||
scopes?: string[] | null;
|
||||
expires_at?: string | null;
|
||||
}): Promise<ServiceAccountCredentialMutationResponse & { secret: string }> {
|
||||
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/credentials/${credentialId}/rotate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function revokeServiceAccountCredential(settings: ApiSettings, serviceAccountId: string, credentialId: string, expectedRevision: number): Promise<ServiceAccountCredentialMutationResponse> {
|
||||
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/credentials/${credentialId}/revoke`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expected_revision: expectedRevision })
|
||||
});
|
||||
}
|
||||
|
||||
export function createSystemAccount(settings: ApiSettings, payload: {
|
||||
email: string;
|
||||
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 { useSearchParams } from "react-router-dom";
|
||||
import { useSearchParams } from "react-router";
|
||||
import type {
|
||||
AdminSectionContribution,
|
||||
AdminSectionsUiCapability,
|
||||
@@ -11,24 +11,50 @@ import type {
|
||||
OrganizationFunctionPickerUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import { fetchShellAuth } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
|
||||
import { ActionBlockerHint } from "@govoplan/core-webui";
|
||||
import { PageLayout, WorkspaceLayout } from "@govoplan/core-webui";
|
||||
import { PageScrollViewport } from "@govoplan/core-webui";
|
||||
import {
|
||||
TreeSubnav,
|
||||
type TreeSubnavNode
|
||||
} from "@govoplan/core-webui";
|
||||
import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
|
||||
import SystemUsersPanel from "./SystemUsersPanel";
|
||||
import TenantSettingsPanel from "./TenantSettingsPanel";
|
||||
import SystemRolesPanel from "./SystemRolesPanel";
|
||||
import TenantsPanel from "./TenantsPanel";
|
||||
import UsersPanel from "./UsersPanel";
|
||||
import GroupsPanel from "./GroupsPanel";
|
||||
import RolesPanel from "./RolesPanel";
|
||||
import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPanel";
|
||||
import ApiKeysPanel from "./ApiKeysPanel";
|
||||
import ServiceAccountsPanel from "./ServiceAccountsPanel";
|
||||
import FileConnectorsPanel from "./FileConnectorsPanel";
|
||||
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 OrderedAdminNavItem = { id: AdminSection; label: string; order: number };
|
||||
type OrderedAdminNavItem = {
|
||||
id: AdminSection;
|
||||
label: string;
|
||||
order: number;
|
||||
moduleId?: string;
|
||||
kind?: "management" | "settings";
|
||||
};
|
||||
type AdminNavGroup = {
|
||||
id: string;
|
||||
title: string;
|
||||
items: OrderedAdminNavItem[];
|
||||
};
|
||||
|
||||
const handledAdminSectionIds = new Set<string>([
|
||||
"overview",
|
||||
@@ -36,27 +62,70 @@ const handledAdminSectionIds = new Set<string>([
|
||||
"system-configuration-changes",
|
||||
"system-configuration-packages",
|
||||
"system-modules",
|
||||
"system-tenants",
|
||||
"system-roles",
|
||||
"system-role-templates",
|
||||
"system-groups",
|
||||
"system-users",
|
||||
"system-file-connectors",
|
||||
"system-mail-servers",
|
||||
"tenant-settings",
|
||||
"system-credentials",
|
||||
"tenant-roles",
|
||||
"tenant-function-role-mappings",
|
||||
"tenant-groups",
|
||||
"tenant-users",
|
||||
"tenant-file-connectors",
|
||||
"tenant-mail-servers",
|
||||
"tenant-credentials",
|
||||
"tenant-api-keys",
|
||||
"tenant-service-accounts",
|
||||
"tenant-group-file-connectors",
|
||||
"tenant-group-mail-servers",
|
||||
"tenant-group-credentials",
|
||||
"tenant-user-file-connectors",
|
||||
"tenant-user-mail-servers"
|
||||
"tenant-user-mail-servers",
|
||||
"tenant-user-credentials"
|
||||
]);
|
||||
|
||||
const builtInAdminSurfaceIds: Record<string, string> = {
|
||||
"system-roles": "access.admin.system-roles",
|
||||
"system-users": "access.admin.system-users",
|
||||
"system-credentials": "access.admin.system-credentials",
|
||||
"tenant-roles": "access.admin.tenant-roles",
|
||||
"tenant-function-role-mappings": "access.admin.tenant-function-mappings",
|
||||
"tenant-groups": "access.admin.tenant-groups",
|
||||
"tenant-users": "access.admin.tenant-users",
|
||||
"tenant-credentials": "access.admin.tenant-credentials",
|
||||
"tenant-api-keys": "access.admin.tenant-api-keys",
|
||||
"tenant-service-accounts": "access.admin.tenant-service-accounts",
|
||||
"tenant-group-credentials": "access.admin.group-credentials",
|
||||
"tenant-user-credentials": "access.admin.user-credentials",
|
||||
"system-mail-servers": "mail.admin.system-servers",
|
||||
"tenant-mail-servers": "mail.admin.tenant-servers",
|
||||
"tenant-group-mail-servers": "mail.admin.group-servers",
|
||||
"tenant-user-mail-servers": "mail.admin.user-servers",
|
||||
"tenant-group-file-connectors": "files.admin.group-connectors",
|
||||
"tenant-user-file-connectors": "files.admin.user-connectors"
|
||||
};
|
||||
|
||||
const builtInAdminSectionMetadata: Record<
|
||||
string,
|
||||
Pick<OrderedAdminNavItem, "moduleId" | "kind">
|
||||
> = {
|
||||
"system-settings": { moduleId: "admin", kind: "settings" },
|
||||
"system-file-connectors": { moduleId: "files", kind: "settings" },
|
||||
"system-mail-servers": { moduleId: "mail", kind: "settings" },
|
||||
"system-credentials": { moduleId: "access", kind: "settings" },
|
||||
"tenant-file-connectors": { moduleId: "files", kind: "settings" },
|
||||
"tenant-mail-servers": { moduleId: "mail", kind: "settings" },
|
||||
"tenant-credentials": { moduleId: "access", kind: "settings" },
|
||||
"tenant-group-file-connectors": { moduleId: "files", kind: "settings" },
|
||||
"tenant-group-mail-servers": { moduleId: "mail", kind: "settings" },
|
||||
"tenant-group-credentials": { moduleId: "access", kind: "settings" },
|
||||
"tenant-user-file-connectors": { moduleId: "files", kind: "settings" },
|
||||
"tenant-user-mail-servers": { moduleId: "mail", kind: "settings" },
|
||||
"tenant-user-credentials": { moduleId: "access", kind: "settings" }
|
||||
};
|
||||
|
||||
export default function AdminPage({
|
||||
settings,
|
||||
auth,
|
||||
@@ -70,14 +139,23 @@ export default function AdminPage({
|
||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||
const organizationFunctionPicker = usePlatformUiCapability<OrganizationFunctionPickerUiCapability>("organizations.functionPicker");
|
||||
const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
|
||||
const effectiveView = useEffectiveView();
|
||||
const viewSurfaces = useViewSurfaces();
|
||||
const mailProfilesAvailable = Boolean(mailProfilesUi);
|
||||
const fileConnectorsAvailable = Boolean(fileConnectorsUi);
|
||||
const contributedSections = useMemo(
|
||||
() =>
|
||||
adminSectionCapabilities
|
||||
.flatMap((capability) => capability.sections)
|
||||
.filter((section) =>
|
||||
isViewSurfaceVisible(
|
||||
effectiveView,
|
||||
section.surfaceId,
|
||||
viewSurfaces
|
||||
)
|
||||
)
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
|
||||
[adminSectionCapabilities]
|
||||
[adminSectionCapabilities, effectiveView, viewSurfaces]
|
||||
);
|
||||
const contributionById = useMemo(() => {
|
||||
const mapped = new Map<string, AdminSectionContribution>();
|
||||
@@ -95,7 +173,9 @@ export default function AdminPage({
|
||||
if (hasScope(auth, "system:settings:read")) {
|
||||
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:roles:read", "system:access:read"])) sections.add("system-roles");
|
||||
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 (organizationFunctionPicker && hasAnyScope(auth, ["admin:roles:read", "access:function:read", "access:role:read"])) sections.add("tenant-function-role-mappings");
|
||||
if (hasScope(auth, "admin:api_keys:read")) sections.add("tenant-api-keys");
|
||||
if (hasScope(auth, "access:service_account:read")) sections.add("tenant-service-accounts");
|
||||
if (mailProfilesAvailable && hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) {
|
||||
sections.add("tenant-mail-servers");
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-mail-servers");
|
||||
@@ -112,9 +193,21 @@ export default function AdminPage({
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-file-connectors");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors");
|
||||
}
|
||||
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
|
||||
return sections;
|
||||
}, [auth, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker]);
|
||||
if (hasAnyScope(auth, ["admin:settings:read", "access:credential:read"])) {
|
||||
sections.add("tenant-credentials");
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-credentials");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-credentials");
|
||||
}
|
||||
return new Set(
|
||||
[...sections].filter((sectionId) =>
|
||||
isViewSurfaceVisible(
|
||||
effectiveView,
|
||||
builtInAdminSurfaceIds[sectionId],
|
||||
viewSurfaces
|
||||
)
|
||||
)
|
||||
);
|
||||
}, [auth, contributedSections, effectiveView, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker, viewSurfaces]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const requestedSection = searchParams.get("section") as AdminSection | null;
|
||||
const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview");
|
||||
@@ -137,19 +230,34 @@ export default function AdminPage({
|
||||
|
||||
if (!hasAnyScope(auth, adminReadScopes)) {
|
||||
return (
|
||||
<PageScrollViewport>
|
||||
<div className="content-pad">
|
||||
<Card title="i18n:govoplan-access.administration_unavailable.b86d4cb5">
|
||||
<p>i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69</p>
|
||||
</Card>
|
||||
<ActionBlockerHint
|
||||
tone="warning"
|
||||
reason={{
|
||||
summary: "i18n:govoplan-access.administration_unavailable.b86d4cb5",
|
||||
details: "i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69",
|
||||
requiredAction: ACCESS_INTERFACE_I18N.requestAdministrationAccess,
|
||||
actor: ACCESS_INTERFACE_I18N.accessAdministrator,
|
||||
target: ACCESS_INTERFACE_I18N.accessAdministration
|
||||
}}
|
||||
labels={{
|
||||
requiredAction: ACCESS_INTERFACE_I18N.requiredAction,
|
||||
actor: ACCESS_INTERFACE_I18N.actor,
|
||||
target: ACCESS_INTERFACE_I18N.destinationLabel
|
||||
}}
|
||||
documentation={ACCESS_WORKFLOW_DOCUMENTATION}
|
||||
/>
|
||||
</div>
|
||||
</PageScrollViewport>
|
||||
);
|
||||
}
|
||||
|
||||
const adminSubnav: ModuleSubnavGroup<AdminSection>[] = [
|
||||
const adminNavGroups: AdminNavGroup[] = [
|
||||
{
|
||||
title: "ADMINISTRATION",
|
||||
items: asSubnavItems(
|
||||
sortNavItems([
|
||||
id: "administration",
|
||||
title: "i18n:govoplan-access.admin.4e7afebc",
|
||||
items: sortNavItems([
|
||||
...contributedNavItems(contributedSections, available, "ROOT"),
|
||||
visibleNavItem(available, "system-modules", "i18n:govoplan-access.modules.04e9462c", 10),
|
||||
visibleNavItem(available, "system-configuration-packages", "i18n:govoplan-access.packages.0a999012", 20),
|
||||
@@ -157,75 +265,95 @@ export default function AdminPage({
|
||||
visibleNavItem(available, "system-configuration-changes", "i18n:govoplan-access.changes.8aa57de6", 40),
|
||||
...contributedNavItems(contributedSections, available, "ADMINISTRATION", handledAdminSectionIds)
|
||||
])
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "GLOBAL",
|
||||
items: asSubnavItems(
|
||||
sortNavItems([
|
||||
visibleNavItem(available, "system-tenants", "i18n:govoplan-access.tenants.1f7ae776", 10),
|
||||
id: "global",
|
||||
title: "i18n:govoplan-access.global",
|
||||
items: sortNavItems([
|
||||
visibleNavItem(available, "system-roles", "i18n:govoplan-access.system_roles.a9461aa6", 20),
|
||||
visibleNavItem(available, "system-role-templates", "i18n:govoplan-access.tenant_role_templates", 30),
|
||||
visibleNavItem(available, "system-groups", "i18n:govoplan-access.group_templates", 40),
|
||||
visibleNavItem(available, "system-users", "i18n:govoplan-access.users.57f2b181", 50),
|
||||
visibleNavItem(available, "system-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 60),
|
||||
visibleNavItem(available, "system-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 70),
|
||||
visibleNavItem(available, "system-credentials", "i18n:govoplan-core.credentials.dd097a22", 80),
|
||||
...contributedNavItems(contributedSections, available, "GLOBAL", handledAdminSectionIds),
|
||||
...contributedNavItems(contributedSections, available, "SYSTEM", handledAdminSectionIds)
|
||||
])
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "TENANT",
|
||||
items: asSubnavItems(
|
||||
sortNavItems([
|
||||
id: "tenant",
|
||||
title: "i18n:govoplan-access.tenant.3ca93c78",
|
||||
items: sortNavItems([
|
||||
visibleNavItem(available, "tenant-roles", "i18n:govoplan-access.roles.47dcc27d", 10),
|
||||
visibleNavItem(available, "tenant-function-role-mappings", "i18n:govoplan-access.function_role_mappings.2b64e9c3", 20),
|
||||
visibleNavItem(available, "tenant-groups", "i18n:govoplan-access.groups.ae9629f4", 30),
|
||||
visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 40),
|
||||
visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 50),
|
||||
visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60),
|
||||
visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 70),
|
||||
visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 90),
|
||||
visibleNavItem(available, "tenant-credentials", "i18n:govoplan-core.credentials.dd097a22", 70),
|
||||
visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 80),
|
||||
visibleNavItem(available, "tenant-service-accounts", "Service accounts", 90),
|
||||
...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds)
|
||||
])
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "GROUP",
|
||||
items: asSubnavItems(
|
||||
sortNavItems([
|
||||
id: "group",
|
||||
title: "i18n:govoplan-access.group.171a0606",
|
||||
items: sortNavItems([
|
||||
visibleNavItem(available, "tenant-group-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
|
||||
visibleNavItem(available, "tenant-group-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
|
||||
visibleNavItem(available, "tenant-group-credentials", "i18n:govoplan-core.credentials.dd097a22", 30),
|
||||
...contributedNavItems(contributedSections, available, "GROUP", handledAdminSectionIds)
|
||||
])
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "USER",
|
||||
items: asSubnavItems(
|
||||
sortNavItems([
|
||||
id: "user",
|
||||
title: "i18n:govoplan-access.user.9f8a2389",
|
||||
items: sortNavItems([
|
||||
visibleNavItem(available, "tenant-user-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
|
||||
visibleNavItem(available, "tenant-user-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
|
||||
visibleNavItem(available, "tenant-user-credentials", "i18n:govoplan-core.credentials.dd097a22", 30),
|
||||
...contributedNavItems(contributedSections, available, "USER", handledAdminSectionIds)
|
||||
])
|
||||
)
|
||||
}
|
||||
].filter((group) => group.items.length > 0);
|
||||
const adminTree = adminNavigationTree(adminNavGroups);
|
||||
const contributedSection = contributionById.get(active);
|
||||
const contributionContext = { settings, auth, onAuthChange, refreshAuth, availableSections: available, selectSection };
|
||||
|
||||
return (
|
||||
<div className="workspace module-workspace">
|
||||
<ModuleSubnav active={active} groups={adminSubnav} onSelect={selectSection} />
|
||||
<section className="workspace-content">
|
||||
<div className="content-pad workspace-data-page">
|
||||
<WorkspaceLayout
|
||||
className="module-workspace"
|
||||
primary={(
|
||||
<TreeSubnav
|
||||
active={active}
|
||||
nodes={adminTree}
|
||||
onSelect={selectSection}
|
||||
ariaLabel="i18n:govoplan-access.admin.4e7afebc"
|
||||
/>
|
||||
)}
|
||||
primaryLabel="i18n:govoplan-access.admin.4e7afebc"
|
||||
contentLabel="i18n:govoplan-access.admin.4e7afebc"
|
||||
documentationType="admin"
|
||||
>
|
||||
<PageLayout
|
||||
archetype="workspace"
|
||||
title="i18n:govoplan-access.admin.4e7afebc"
|
||||
mode="workspace"
|
||||
showHeader={false}
|
||||
documentationType="admin"
|
||||
>
|
||||
{contributedSection && contributedSection.render(contributionContext)}
|
||||
{!contributedSection && active === "system-mail-servers" && (
|
||||
<MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />
|
||||
)}
|
||||
{!contributedSection && active === "system-tenants" && (
|
||||
<TenantsPanel settings={settings} auth={auth} canCreate={hasScope(auth, "system:tenants:create")} canUpdate={hasScope(auth, "system:tenants:update")} canSuspend={hasScope(auth, "system:tenants:suspend")} onAuthRefresh={refreshAuth} />
|
||||
{!contributedSection && active === "system-credentials" && (
|
||||
<CredentialEnvelopesPanel
|
||||
settings={settings}
|
||||
scopeType="system"
|
||||
canWrite={hasAnyScope(auth, ["system:settings:write", "access:system_credential:write"])}
|
||||
/>
|
||||
)}
|
||||
{!contributedSection && active === "system-users" && (
|
||||
<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 === "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-roles" && <RolesPanel settings={settings} auth={auth} canDefine={hasScope(auth, "admin:roles:write")} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && <ExternalFunctionRoleMappingsPanel settings={settings} auth={auth} functionPicker={organizationFunctionPicker} canWrite={hasAnyScope(auth, ["admin:roles:write", "access:function:write", "access:role:assign"])} onAuthRefresh={refreshAuth} />}
|
||||
{!contributedSection && active === "tenant-api-keys" && <ApiKeysPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:api_keys:create")} canRevoke={hasScope(auth, "admin:api_keys:revoke")} />}
|
||||
{!contributedSection && active === "tenant-service-accounts" && <ServiceAccountsPanel settings={settings} auth={auth} canWrite={hasScope(auth, "access:service_account:write")} />}
|
||||
{!contributedSection && active === "tenant-mail-servers" && <MailProfilesPanel settings={settings} scopeType="tenant" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasScope(auth, "admin:policies:write")} />}
|
||||
{!contributedSection && active === "tenant-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="tenant" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
|
||||
{!contributedSection && active === "tenant-user-mail-servers" && <MailProfilesPanel settings={settings} scopeType="user" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
|
||||
{!contributedSection && active === "tenant-group-mail-servers" && <MailProfilesPanel settings={settings} scopeType="group" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
|
||||
{!contributedSection && active === "tenant-user-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
|
||||
{!contributedSection && active === "tenant-group-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
|
||||
{!contributedSection && active === "tenant-user-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
|
||||
{!contributedSection && active === "tenant-group-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
|
||||
{!contributedSection && active === "tenant-settings" && <TenantSettingsPanel settings={settings} canWrite={hasScope(auth, "admin:settings:write")} onAuthRefresh={refreshAuth} />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -270,17 +400,78 @@ function contributedNavItems(
|
||||
): OrderedAdminNavItem[] {
|
||||
return sections
|
||||
.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 {
|
||||
return available.has(id) ? { id, label, order } : null;
|
||||
return available.has(id)
|
||||
? { id, label, order, ...builtInAdminSectionMetadata[id] }
|
||||
: null;
|
||||
}
|
||||
|
||||
function sortNavItems(items: Array<OrderedAdminNavItem | null>): OrderedAdminNavItem[] {
|
||||
return items.filter((item): item is OrderedAdminNavItem => item !== null).sort((left, right) => left.order - right.order);
|
||||
}
|
||||
|
||||
function asSubnavItems(items: OrderedAdminNavItem[]) {
|
||||
return items.map(({ id, label }) => ({ id, label }));
|
||||
function adminNavigationTree(
|
||||
groups: AdminNavGroup[]
|
||||
): TreeSubnavNode<AdminSection>[] {
|
||||
return groups.map((group) => {
|
||||
const managementItems = group.items.filter(
|
||||
(item) => item.kind !== "settings"
|
||||
);
|
||||
const settingsItems = group.items.filter(
|
||||
(item) => item.kind === "settings"
|
||||
);
|
||||
const children: TreeSubnavNode<AdminSection>[] = managementItems.map(
|
||||
({ id, label }) => ({ id, label })
|
||||
);
|
||||
if (settingsItems.length > 0) {
|
||||
const byModule = new Map<string, OrderedAdminNavItem[]>();
|
||||
for (const item of settingsItems) {
|
||||
const moduleId = item.moduleId ?? "platform";
|
||||
byModule.set(moduleId, [...(byModule.get(moduleId) ?? []), item]);
|
||||
}
|
||||
children.push({
|
||||
branchId: `admin-${group.id}-settings`,
|
||||
label: "i18n:govoplan-core.settings.c7f73bb5",
|
||||
defaultExpanded: false,
|
||||
children: [...byModule.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([moduleId, items]) => ({
|
||||
branchId: `admin-${group.id}-settings-${moduleId}`,
|
||||
label: moduleLabel(moduleId),
|
||||
defaultExpanded: items.some(
|
||||
(item) => item.id === "system-settings"
|
||||
),
|
||||
children: items.map(({ id, label }) => ({ id, label }))
|
||||
}))
|
||||
});
|
||||
}
|
||||
return {
|
||||
branchId: `admin-${group.id}`,
|
||||
label: group.title,
|
||||
defaultExpanded: group.id === "administration",
|
||||
children
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function moduleLabel(moduleId: string): string {
|
||||
if (moduleId === "platform") return "i18n:govoplan-access.platform_administration";
|
||||
if (moduleId === "access") return "i18n:govoplan-access.access.2f81a22d";
|
||||
if (moduleId === "admin") return "i18n:govoplan-access.admin.4e7afebc";
|
||||
if (moduleId === "files") return "i18n:govoplan-access.files.6ce6c512";
|
||||
if (moduleId === "mail") return "i18n:govoplan-access.mail_servers.d627326a";
|
||||
return moduleId
|
||||
.split(/[-_]/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part[0].toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
@@ -10,9 +11,10 @@ import { DateTimeField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, 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 { loadDeltaRows } from "./utils/deltaRows";
|
||||
import { ACCESS_INTERFACE_I18N, ACCESS_REFERENCE_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||
|
||||
function defaultDraft(userId: string) {
|
||||
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: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa" },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 108, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, 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: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, helpContextId: "access.api-keys.action.inspect", helpModuleId: "access", onClick: () => setViewing(row) },
|
||||
{ id: "revoke", label: i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name }), icon: <Trash2 />, variant: "danger", helpContextId: "access.api-keys.action.revoke", helpModuleId: "access", applicable: !row.revoked_at, disabled: !canRevoke, disabledReason: row.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : !canRevoke ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => setRevoking(row) }
|
||||
]} /> }],
|
||||
[canRevoke]);
|
||||
|
||||
@@ -151,33 +153,33 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
|
||||
|
||||
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>
|
||||
</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></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<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.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.expiry.ba8f571e"><DateTimeField value={draft.expiresAt} onChange={(value) => setDraft({ ...draft, expiresAt: value })} /></FormField>
|
||||
</div>
|
||||
<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>
|
||||
<Dialog variant="administration" size="wide" open={creating} title="i18n:govoplan-access.create_api_key.d7b30388" helpContextId="access.api-keys.action.create" helpModuleId="access" onClose={() => !busy && setCreating(false)} className="" footer={<><Button onClick={() => setCreating(false)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" helpContextId="access.api-keys.action.create" helpModuleId="access" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canCreate, complete: Boolean(draft.name.trim() && draft.userId && draft.scopes.length) })}>{busy ? "i18n:govoplan-access.creating.94d7d8ee" : "i18n:govoplan-access.create_key.e028cb09"}</Button></>}>
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="i18n:govoplan-access.name.709a2322" helpContextId="access.api-keys.field.name" helpModuleId="access"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.owner.89ff3122" helpContextId="access.api-keys.field.owner" helpModuleId="access"><select value={draft.userId} onChange={(event) => {const userId = event.target.value;const user = users.find((item) => item.id === userId);const allowed = new Set(permissions.filter((permission) => user?.effective_scopes.some((scope) => scopeGrants(scope, permission.scope))).map((permission) => permission.scope));setDraft({ ...draft, userId, scopes: draft.scopes.filter((scope) => allowed.has(scope)) });}}><option value="">i18n:govoplan-access.select_user.b8a1d9de</option>{users.map((user) => <option key={user.id} value={user.id}>{user.display_name || user.email} — {user.email}</option>)}</select></FormField>
|
||||
<FormField label="i18n:govoplan-access.expiry.ba8f571e" helpContextId="access.api-keys.field.expiry" helpModuleId="access"><DateTimeField value={draft.expiresAt} onChange={(value) => setDraft({ ...draft, expiresAt: value })} /></FormField>
|
||||
</FormGrid>
|
||||
<div className="form-field" data-help-context-id="access.api-keys.field.scopes" data-help-module-id="access"><span className="form-label">i18n:govoplan-access.allowed_scopes.d94515ff</span><AdminSelectionList options={allowedPermissions.map((permission) => ({ id: permission.scope, label: permission.label, description: i18nMessage("i18n:govoplan-access.value_value.0e2772ed", { value0: permission.scope, value1: permission.description }) }))} selected={draft.scopes} onChange={(scopes) => setDraft({ ...draft, scopes })} emptyText="i18n:govoplan-access.the_selected_user_has_no_tenant_permissions_avai.96985ec7" /></div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="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>}>
|
||||
{viewing && <><dl className="admin-details-grid">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</dl><h3>i18n:govoplan-access.scopes.c23540e5</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
||||
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.api_key_details.f70c16be" helpContextId="access.api-keys.action.inspect" helpModuleId="access" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <><DescriptionList>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.name.709a2322</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.prefix.90eceb01</>}>{viewing.prefix}…</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.owner.89ff3122</>}>{viewing.user_email}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.status.bae7d5be</>}>{viewing.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : "i18n:govoplan-access.active.a733b809"}</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.created.accf40c8</>}>{formatDateTime(viewing.created_at)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.last_used.f1109d3d</>}>{formatDateTime(viewing.last_used_at)}</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.expires.a99be3da</>}>{viewing.expires_at ? formatDateTime(viewing.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.revoked.85f17ac0</>}>{formatDateTime(viewing.revoked_at)}</DescriptionItem>
|
||||
</DescriptionList><h3>i18n:govoplan-access.scopes.c23540e5</h3><div className="admin-scope-list">{viewing.scopes.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(secret)} title="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></>}
|
||||
</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;
|
||||
}
|
||||
@@ -10,14 +10,15 @@ import {
|
||||
type ExternalFunctionRoleMappingItem,
|
||||
type RoleSummary
|
||||
} from "../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Button, FormGrid } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, 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 { loadDeltaRows } from "./utils/deltaRows";
|
||||
import { ACCESS_INTERFACE_I18N, FUNCTION_MAPPING_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||
|
||||
const emptyDraft = {
|
||||
sourceModule: "organizations",
|
||||
@@ -258,8 +259,8 @@ export default function ExternalFunctionRoleMappingsPanel({
|
||||
resizable: false,
|
||||
align: "right",
|
||||
render: (row) => <TableActionGroup actions={[
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id }), icon: <Pencil />, disabled: !canWrite, 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: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id }), icon: <Pencil />, disabled: !canWrite, disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.function_id }), icon: <Trash2 />, variant: "danger", disabled: !canWrite, disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => setDeleting(row) }
|
||||
]} />
|
||||
}
|
||||
],
|
||||
@@ -276,8 +277,9 @@ export default function ExternalFunctionRoleMappingsPanel({
|
||||
success={success}
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={() => void load()} disabled={loading}>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} />
|
||||
<DocumentationHelpLink reference={FUNCTION_MAPPING_DOCUMENTATION} />
|
||||
<Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button>
|
||||
<AdminIconButton label="i18n:govoplan-access.add_function_role_mapping.1bc376ac" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite || !assignableRoles.length} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.createPermissionRequired : !assignableRoles.length ? ACCESS_INTERFACE_I18N.selectAssignableRole : undefined} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -293,21 +295,21 @@ export default function ExternalFunctionRoleMappingsPanel({
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog
|
||||
<Dialog variant="administration" size="large"
|
||||
open={editing !== null}
|
||||
title={editing === "new" ? "i18n:govoplan-access.create_function_role_mapping.3718168d" : "i18n:govoplan-access.edit_function_role_mapping.91ee75af"}
|
||||
onClose={() => !busy && closeEditor()}
|
||||
className="admin-dialog"
|
||||
className=""
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={closeEditor} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button>
|
||||
<Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.functionId.trim() || !draft.roleId}>
|
||||
<Button onClick={closeEditor} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button>
|
||||
<Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canWrite, complete: Boolean(draft.functionId.trim() && draft.roleId) })}>
|
||||
{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_mapping.a4ac90e9"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="admin-form-grid">
|
||||
<FormGrid columns={1} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="i18n:govoplan-access.function_id.e5e08937">
|
||||
{functionPicker.renderPicker({
|
||||
settings,
|
||||
@@ -325,7 +327,7 @@ export default function ExternalFunctionRoleMappingsPanel({
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormGrid>
|
||||
<p className="muted small-note">i18n:govoplan-access.function_role_mapping_help.0cf9996a</p>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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 { loadDeltaRows } from "./utils/deltaRows";
|
||||
import {
|
||||
ACCESS_INTERFACE_I18N,
|
||||
FILE_CONNECTOR_DOCUMENTATION
|
||||
} from "./interfacePatterns";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
@@ -82,9 +86,21 @@ export default function FileConnectorsPanel({ settings, scopeType, canWrite }: P
|
||||
if (!FileConnectorScopeManager) {
|
||||
return (
|
||||
<AdminPageLayout title={labels.title} description={labels.description}>
|
||||
<Card title="i18n:govoplan-access.files_module_unavailable.0ee90db1">
|
||||
<p className="muted">i18n:govoplan-access.install_and_enable_the_files_module_to_manage_fi.f842c153</p>
|
||||
</Card>
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: "i18n:govoplan-access.files_module_unavailable.0ee90db1",
|
||||
details: "i18n:govoplan-access.install_and_enable_the_files_module_to_manage_fi.f842c153",
|
||||
requiredAction: ACCESS_INTERFACE_I18N.installFiles,
|
||||
actor: ACCESS_INTERFACE_I18N.systemModuleAdministrator,
|
||||
target: ACCESS_INTERFACE_I18N.moduleManagement
|
||||
}}
|
||||
labels={{
|
||||
requiredAction: ACCESS_INTERFACE_I18N.requiredAction,
|
||||
actor: ACCESS_INTERFACE_I18N.actor,
|
||||
target: ACCESS_INTERFACE_I18N.destinationLabel
|
||||
}}
|
||||
documentation={FILE_CONNECTOR_DOCUMENTATION}
|
||||
/>
|
||||
</AdminPageLayout>);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ContentGrid, DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
@@ -8,8 +9,13 @@ import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, 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 {
|
||||
ACCESS_INTERFACE_I18N,
|
||||
ACCESS_WORKFLOW_DOCUMENTATION,
|
||||
saveDisabledReason
|
||||
} from "./interfacePatterns";
|
||||
|
||||
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: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !(canDefine || canManageMembers || canAssignRoles), 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: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !(canDefine || canManageMembers || canAssignRoles), disabledReason: !(canDefine || canManageMembers || canAssignRoles) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canDefine || Boolean(row.system_required), disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canDefine ? ACCESS_INTERFACE_I18N.writePermissionRequired : row.system_required ? ACCESS_INTERFACE_I18N.systemManagedObject : undefined, onClick: () => setDeactivating(row) }
|
||||
]} /> }],
|
||||
[canAssignRoles, canDefine, canManageMembers]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="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>
|
||||
</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>}
|
||||
<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.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new" || !canDefine} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_required)} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} disabled={!canDefine || editing !== "new" && Boolean(editing?.system_template_id)} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</div>
|
||||
<div className="admin-assignment-grid">
|
||||
</FormGrid>
|
||||
<ContentGrid columns={2} spacing="block" collapseAt="wide">
|
||||
<div><span className="form-label">i18n:govoplan-access.members.1cb449c1</span><AdminSelectionList options={users.map((user) => ({ id: user.id, label: user.display_name || user.email, description: user.email, disabled: !canManageMembers || !user.is_active || !user.account_is_active }))} selected={draft.memberIds} onChange={(memberIds) => setDraft({ ...draft, memberIds })} emptyText="i18n:govoplan-access.no_tenant_users_exist.96b4a88a" /></div>
|
||||
<div><span className="form-label">i18n:govoplan-access.inherited_roles.8def9f05</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} emptyText="i18n:govoplan-access.no_assignable_roles_exist.a4c268c2" /></div>
|
||||
</div>
|
||||
</ContentGrid>
|
||||
<p className="muted small-note">i18n:govoplan-access.the_backend_evaluates_the_resulting_access_graph.f318a7dc</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="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>}>
|
||||
{viewing && <dl className="admin-details-grid">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</dl>}
|
||||
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.group_details.df844ae3" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <DescriptionList>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.group.171a0606</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.slug.094da9b9</>}>{viewing.slug}</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.status.bae7d5be</>}>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.management.63cecca6</>}>{viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_managed.843eed93"}</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.members.1cb449c1</>}>{viewing.member_count}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.roles.47dcc27d</>}>{joinLabels(viewing.roles)}</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.created.accf40c8</>}>{formatDateTime(viewing.created_at)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.updated.f2f8570d</>}>{formatDateTime(viewing.updated_at)}</DescriptionItem>
|
||||
</DescriptionList>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deactivating)} title="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 type { ApiSettings, MailProfileScope, MailProfilesUiCapability, MailProfileTargetOption } from "@govoplan/core-webui";
|
||||
import { fetchGroupsDelta, fetchUsersDelta, type GroupSummary, type UserAdminItem } from "../../api/admin";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks } from "@govoplan/core-webui";
|
||||
import { ActionBlockerHint, AdminPageLayout, adminErrorMessage, useDeltaWatermarks } from "@govoplan/core-webui";
|
||||
import { usePlatformUiCapability } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
import {
|
||||
ACCESS_INTERFACE_I18N,
|
||||
MAIL_PROFILE_DOCUMENTATION
|
||||
} from "./interfacePatterns";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
@@ -106,9 +109,21 @@ export default function MailProfilesPanel({ settings, scopeType, canWriteProfile
|
||||
if (!MailProfileScopeManager) {
|
||||
return (
|
||||
<AdminPageLayout title={labels.title} description={labels.description}>
|
||||
<Card title="i18n:govoplan-access.mail_module_unavailable.b4e95104">
|
||||
<p className="muted">i18n:govoplan-access.install_and_enable_the_mail_module_to_manage_mai.a8ad5b3a</p>
|
||||
</Card>
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
summary: "i18n:govoplan-access.mail_module_unavailable.b4e95104",
|
||||
details: "i18n:govoplan-access.install_and_enable_the_mail_module_to_manage_mai.a8ad5b3a",
|
||||
requiredAction: ACCESS_INTERFACE_I18N.installMail,
|
||||
actor: ACCESS_INTERFACE_I18N.systemModuleAdministrator,
|
||||
target: ACCESS_INTERFACE_I18N.moduleManagement
|
||||
}}
|
||||
labels={{
|
||||
requiredAction: ACCESS_INTERFACE_I18N.requiredAction,
|
||||
actor: ACCESS_INTERFACE_I18N.actor,
|
||||
target: ACCESS_INTERFACE_I18N.destinationLabel
|
||||
}}
|
||||
documentation={MAIL_PROFILE_DOCUMENTATION}
|
||||
/>
|
||||
</AdminPageLayout>);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
@@ -8,9 +9,10 @@ import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, 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 { loadDeltaRows } from "./utils/deltaRows";
|
||||
import { ACCESS_INTERFACE_I18N, ACCESS_WORKFLOW_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||
|
||||
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: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine, 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: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine, disabledReason: row.is_builtin || row.system_template_id ? ACCESS_INTERFACE_I18N.systemManagedObject : !canDefine ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine || row.user_assignments + row.group_assignments > 0, disabledReason: row.is_builtin || row.system_template_id ? ACCESS_INTERFACE_I18N.systemManagedObject : !canDefine ? ACCESS_INTERFACE_I18N.writePermissionRequired : row.user_assignments + row.group_assignments > 0 ? ACCESS_INTERFACE_I18N.assignedObjectCannotBeDeleted : undefined, onClick: () => setDeleting(row) }
|
||||
]} /> }],
|
||||
[canDefine, permissions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="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>
|
||||
</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></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_role.db859bad" : "i18n:govoplan-access.edit_role.61dd63e9"} onClose={() => !busy && setEditing(null)} className="" footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canDefine, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
{editing !== "new" && <FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>}
|
||||
</div>
|
||||
</FormGrid>
|
||||
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => {
|
||||
const scopes = items.map((permission) => permission.scope);
|
||||
return <fieldset key={category} className="admin-permission-group"><legend>{category}</legend><AdminSelectionList options={items.map((permission) => ({ id: permission.scope, label: permission.label, description: <>{permission.description}<code>{permission.scope}</code></> }))} selected={draft.permissions.filter((scope) => scopes.includes(scope))} onChange={(selected) => setPermissionGroup(scopes, selected)} /></fieldset>;
|
||||
})}</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="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>}>
|
||||
{viewing && <><dl className="admin-details-grid">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</dl><h3>i18n:govoplan-access.permissions.d06d5557</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
||||
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.role_details.a16b5d9f" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <><DescriptionList>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.role.c3f104d1</>}>{viewing.name}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.slug.094da9b9</>}>{viewing.slug}</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.type.3deb7456</>}>{viewing.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : viewing.system_template_id ? i18nMessage("i18n:govoplan-access.system_managed_value.eb564eb1", { value0: viewing.system_required ? "i18n:govoplan-access.required.2e5396fd" : "i18n:govoplan-access.available.ce372771" }) : "i18n:govoplan-access.tenant_custom.4307081e"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.assignable.a88debc5</>}>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.user_assignments.bc7cc801</>}>{viewing.user_assignments}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.group_assignments.e534bb56</>}>{viewing.group_assignments}</DescriptionItem>
|
||||
</DescriptionList><h3>i18n:govoplan-access.permissions.d06d5557</h3><div className="admin-scope-list">{viewing.permissions.map((scope) => <code key={scope}>{scope}</code>)}</div></>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deleting)} title="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,3 +1,4 @@
|
||||
import { DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
@@ -16,8 +17,9 @@ import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, 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 { ACCESS_INTERFACE_I18N, ACCESS_REFERENCE_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||
|
||||
const emptyDraft = {
|
||||
slug: "",
|
||||
@@ -225,8 +227,8 @@ export default function SystemRolesPanel({
|
||||
const protectedOwner = row.slug === "system_owner";
|
||||
return <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !protectedOwner, disabled: !canWrite, 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: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !protectedOwner, disabled: !canWrite, disabledReason: protectedOwner ? ACCESS_INTERFACE_I18N.systemManagedObject : !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !protectedOwner, disabled: !canWrite || row.user_assignments > 0, disabledReason: protectedOwner ? ACCESS_INTERFACE_I18N.systemManagedObject : !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : row.user_assignments > 0 ? ACCESS_INTERFACE_I18N.assignedObjectCannotBeDeleted : undefined, onClick: () => setDeleting(row) }
|
||||
]} />;
|
||||
}
|
||||
}],
|
||||
@@ -240,26 +242,26 @@ export default function SystemRolesPanel({
|
||||
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_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">
|
||||
<DataGrid id="admin-system-role-definitions-v4" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_system_roles_found.051cf727" />
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog
|
||||
<Dialog variant="administration" size="wide"
|
||||
open={editing !== null}
|
||||
title={editing === "new" ? "i18n:govoplan-access.create_system_role.a1e40b25" : "i18n:govoplan-access.edit_system_role.6ebb7cb0"}
|
||||
onClose={() => !busy && setEditing(null)}
|
||||
className="admin-dialog admin-dialog-wide"
|
||||
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>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></>}>
|
||||
className=""
|
||||
footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canWrite, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
|
||||
|
||||
<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.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>
|
||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</div>
|
||||
</FormGrid>
|
||||
<div className="form-field">
|
||||
<span className="form-label">i18n:govoplan-access.system_permissions.53ff0ab2</span>
|
||||
<AdminSelectionList
|
||||
@@ -271,8 +273,8 @@ export default function SystemRolesPanel({
|
||||
</div>
|
||||
</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>}>
|
||||
{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>}
|
||||
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title={viewing?.name || "i18n:govoplan-access.system_role_details.3d6a8f15"} onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <DescriptionList><DescriptionItem term={<>i18n:govoplan-access.slug.094da9b9</>}>{viewing.slug}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.protected.28531336</>}>{viewing.slug === "system_owner" ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.assignable.a88debc5</>}>{viewing.is_assignable ? "i18n:govoplan-access.yes.5397e058" : "i18n:govoplan-access.no.816c52fd"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.account_assignments.f5a91f2a</>}>{viewing.user_assignments}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.description.55f8ebc8</>}>{viewing.description || "—"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.effective_permissions.17c0fe8a</>}>{viewing.effective_permission_count}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.assigned_scopes.c7b09b12</>}>{viewing.permissions.length ? joinLabels(viewing.permissions.map((name) => ({ name }))) : "—"}</DescriptionItem></DescriptionList>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(deleting)} title="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,3 +1,4 @@
|
||||
import { ContentGrid, DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
@@ -20,7 +21,8 @@ import {
|
||||
type SystemMembershipDraft,
|
||||
type TenantAdminItem
|
||||
} 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 = {
|
||||
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: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), 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: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), disabledReason: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.memberships.some((membership) => membership.is_last_active_owner), disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canSuspend ? ACCESS_INTERFACE_I18N.updatePermissionRequired : row.memberships.some((membership) => membership.is_last_active_owner) ? ACCESS_INTERFACE_I18N.lastOwnerCannotBeDeactivated : undefined, onClick: () => setDeactivating(row) }
|
||||
]} /> }],
|
||||
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
||||
|
||||
@@ -227,18 +229,20 @@ export default function SystemUsersPanel({
|
||||
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_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>
|
||||
</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></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<Dialog variant="administration" size="wide" open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_global_account.e821f016" : "i18n:govoplan-access.edit_global_account.d13b8485"} onClose={() => !busy && setEditing(null)} className="" footer={<><Button onClick={() => setEditing(null)} disabled={busy} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: editing === "new" ? canCreate : canUpdate || canSuspend || canAssignRoles || canManageMemberships, complete: Boolean(draft.email.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_account.0b761f5c"}</Button></>}>
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||
{editing === "new" &&
|
||||
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
|
||||
<FormField label="i18n:govoplan-access.initial_password.2278be8c" helpContextId="access.admin.system-users.initial-password" helpModuleId="access">
|
||||
<PasswordField
|
||||
helpContextId="access.admin.system-users.initial-password"
|
||||
helpModuleId="access"
|
||||
value={draft.password}
|
||||
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
||||
autoComplete="new-password"
|
||||
@@ -247,20 +251,20 @@ export default function SystemUsersPanel({
|
||||
</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>
|
||||
<div className="admin-assignment-grid">
|
||||
</FormGrid>
|
||||
<ContentGrid columns={2} spacing="block" collapseAt="wide">
|
||||
<div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
|
||||
<div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><AdminSelectionList options={tenants.map((tenant) => ({ id: tenant.id, label: tenant.name, description: tenant.slug, disabled: !canManageMemberships || Boolean(draft.memberships.find((item) => item.tenant_id === tenant.id)?.is_last_active_owner) }))} selected={draft.memberships.map((item) => item.tenant_id)} onChange={setMembershipSelection} /></div>
|
||||
</div>
|
||||
</ContentGrid>
|
||||
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>}
|
||||
<p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="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>}>
|
||||
{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>}
|
||||
<Dialog variant="administration" size="wide" open={Boolean(viewing)} title="i18n:govoplan-access.global_account_details.0a0cf240" onClose={() => setViewing(null)} className="" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <DescriptionList><DescriptionItem term={<>i18n:govoplan-access.account.85dfa32c</>}>{viewing.email}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.display_name.c7874aaa</>}>{viewing.display_name || "—"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.status.bae7d5be</>}>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.inactive.09af574c"}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.last_login.43dab84f</>}>{formatDateTime(viewing.last_login_at)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.system_roles.a9461aa6</>}>{joinLabels(viewing.roles)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.tenants.1f7ae776</>}>{viewing.memberships.map((item) => item.tenant_name).join(", ") || "—"}</DescriptionItem></DescriptionList>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(temporaryPassword)} title="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></>}
|
||||
</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,5 +1,6 @@
|
||||
import { ContentGrid, DescriptionItem, DescriptionList, FormGrid } from "@govoplan/core-webui";
|
||||
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 { 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";
|
||||
@@ -10,9 +11,19 @@ import { PasswordField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, 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 { loadDeltaRows } from "./utils/deltaRows";
|
||||
import {
|
||||
fetchAdminUserSessions,
|
||||
revokeAdminUserSession,
|
||||
type AccountSession
|
||||
} from "../../api/sessions";
|
||||
import {
|
||||
ACCESS_INTERFACE_I18N,
|
||||
ACCESS_WORKFLOW_DOCUMENTATION,
|
||||
saveDisabledReason
|
||||
} from "./interfacePatterns";
|
||||
|
||||
const emptyDraft = {
|
||||
email: "",
|
||||
@@ -24,7 +35,7 @@ const emptyDraft = {
|
||||
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 [groups, setGroups] = useState<GroupSummary[]>([]);
|
||||
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 [accessExplanationLoading, setAccessExplanationLoading] = useState(false);
|
||||
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 [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
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>[]>(() => [
|
||||
{ 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) },
|
||||
@@ -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: "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: "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: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canManageGroups || canAssignRoles), 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: "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, 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 (
|
||||
<>
|
||||
<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>
|
||||
</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></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<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></>}>
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="i18n:govoplan-access.email.84add5b2"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.display_name.c7874aaa"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||
{editing === "new" &&
|
||||
<FormField label="i18n:govoplan-access.initial_password.2278be8c">
|
||||
<FormField label="i18n:govoplan-access.initial_password.2278be8c" helpContextId="access.admin.tenant-users.initial-password" helpModuleId="access">
|
||||
<PasswordField
|
||||
helpContextId="access.admin.tenant-users.initial-password"
|
||||
helpModuleId="access"
|
||||
value={draft.password}
|
||||
placeholder="i18n:govoplan-access.leave_empty_to_generate.e58222d8"
|
||||
autoComplete="new-password"
|
||||
@@ -216,36 +297,52 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
||||
</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>
|
||||
{editing === "new" && <ToggleSwitch label="i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3" checked={draft.passwordResetRequired} onChange={(passwordResetRequired) => setDraft({ ...draft, passwordResetRequired })} />}
|
||||
</FormGrid>
|
||||
{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>}
|
||||
<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.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>
|
||||
</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>}>
|
||||
{viewing && <dl className="admin-details-grid">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</dl>}
|
||||
<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 && <DescriptionList>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.user.9f8a2389</>}>{viewing.display_name || viewing.email}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.email.84add5b2</>}>{viewing.email}</DescriptionItem>
|
||||
<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>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.groups.ae9629f4</>}>{joinLabels(viewing.groups)}</DescriptionItem><DescriptionItem term={<>i18n:govoplan-access.direct_roles.c4db7156</>}>{joinLabels(viewing.roles)}</DescriptionItem>
|
||||
<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>
|
||||
</DescriptionList>}
|
||||
</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>}
|
||||
{accessExplanation && <>
|
||||
<dl className="admin-details-grid">
|
||||
<div><dt>i18n:govoplan-access.user.9f8a2389</dt><dd>{accessExplanation.user.display_name || accessExplanation.user.email}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.account.85dfa32c</dt><dd>{accessExplanation.user.account_id}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.role_sources.6f42a672</dt><dd>{accessExplanation.role_sources.length}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.function_facts.848b32cc</dt><dd>{accessExplanation.function_facts.length}</dd></div>
|
||||
</dl>
|
||||
<DescriptionList>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.user.9f8a2389</>}>{accessExplanation.user.display_name || accessExplanation.user.email}</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.account.85dfa32c</>}>{accessExplanation.user.account_id}</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.role_sources.6f42a672</>}>{accessExplanation.role_sources.length}</DescriptionItem>
|
||||
<DescriptionItem term={<>i18n:govoplan-access.function_facts.848b32cc</>}>{accessExplanation.function_facts.length}</DescriptionItem>
|
||||
</DescriptionList>
|
||||
<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)}>
|
||||
<strong>{source.role_name}</strong>
|
||||
<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)} />
|
||||
<div className="admin-scope-list">{source.permissions.map((scope) => <code key={scope}>{scope}</code>)}</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>
|
||||
{accessExplanation.function_facts.length ? <dl className="admin-details-grid">
|
||||
{accessExplanation.function_facts.map((fact) => <div key={fact.assignment_id}>
|
||||
<dt>{fact.function_name || fact.function_id}</dt>
|
||||
<dd>
|
||||
{accessExplanation.function_facts.length ? <DescriptionList>
|
||||
{accessExplanation.function_facts.map((fact) => <DescriptionItem key={fact.assignment_id} term={fact.function_name || fact.function_id}>
|
||||
<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.mapped_roles.504d9ff9</span>: {factRoles(fact)}
|
||||
<AccessExplanationLinks organizationHref={organizationHrefForFact(fact)} idmHref={idmHrefForFact(fact)} />
|
||||
</dd>
|
||||
</div>)}
|
||||
</dl> : <p className="muted small-note">i18n:govoplan-access.no_function_facts_found.b2ecee17</p>}
|
||||
</DescriptionItem>)}
|
||||
</DescriptionList> : <p className="muted small-note">i18n:govoplan-access.no_function_facts_found.b2ecee17</p>}
|
||||
<h3>i18n:govoplan-access.effective_permissions.17c0fe8a</h3>
|
||||
<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>)}
|
||||
@@ -277,7 +371,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
||||
</>}
|
||||
</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></>}
|
||||
</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 = {
|
||||
"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.access_updated_for_value.87f22245": "Access updated for {value0}.",
|
||||
"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.source_module.62b7241c": "Source module",
|
||||
"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_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}",
|
||||
@@ -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."
|
||||
},
|
||||
"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.access_updated_for_value.87f22245": "Access updated for {value0}.",
|
||||
"i18n:govoplan-access.access_explanation.75ee7f62": "Zugriffserklaerung",
|
||||
@@ -606,7 +708,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.slug.094da9b9": "Slug",
|
||||
"i18n:govoplan-access.source_module.62b7241c": "Quellmodul",
|
||||
"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_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}",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { default } from "./module";
|
||||
export * from "./module";
|
||||
export * from "./api/admin";
|
||||
export * from "./api/sessions";
|
||||
export { default as AdminPage } from "./features/admin/AdminPage";
|
||||
export { ResourceAccessExplanation } 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 type { PlatformRouteContext, PlatformWebModule } from "@govoplan/core-webui";
|
||||
import type { ActingContextRuntimeUiCapability, PlatformRouteContext, PlatformWebModule, SettingsSectionsUiCapability } from "@govoplan/core-webui";
|
||||
import { adminReadScopes } from "@govoplan/core-webui";
|
||||
import ActingContextSelector from "./features/acting-context/ActingContextSelector";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
|
||||
const AdminPage = lazy(() => import("./features/admin/AdminPage"));
|
||||
const SessionSettingsPanel = lazy(() => import("./features/sessions/SessionSettingsPanel"));
|
||||
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
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) {
|
||||
if (!onAuthChange) {
|
||||
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 = {
|
||||
id: "access",
|
||||
label: "i18n:govoplan-access.access.2f81a22d",
|
||||
version: "1.0.0",
|
||||
version: "0.1.11",
|
||||
translations,
|
||||
viewSurfaces: accessAdminSurfaces,
|
||||
navItems: [
|
||||
{ to: "/admin", label: "i18n:govoplan-access.admin.4e7afebc", iconName: "admin", anyOf: adminReadScopes, order: 900 }],
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user