Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d1b1e356e | ||
|
|
fa0c85e03a | ||
|
|
94b604a3af | ||
|
|
d3daf42bd9 | ||
|
|
6052dde760 | ||
|
|
566b3b83ad | ||
|
|
e4bae0121d | ||
|
|
5c6f446cd5 | ||
|
|
2be1dbc132 | ||
|
|
38fc22c06b | ||
|
|
3661fdd370 | ||
|
|
3966c7f33c | ||
|
|
9ba69286f7 | ||
|
|
55d87a7812 | ||
|
|
a861338b9a | ||
|
|
0b9e3751c2 | ||
|
|
e04671034f | ||
|
|
44799b15e5 | ||
|
|
367ffc4564 | ||
|
|
a7ed28f6a3 | ||
|
|
794fcf90f8 | ||
|
|
d5db5c2378 | ||
|
|
4653542247 | ||
|
|
668f7cf108 | ||
|
|
e710cf5fb8 | ||
|
|
998d47ae94 | ||
|
|
1409dbf94d | ||
|
|
fe247999e9 | ||
|
|
9776f862f8 | ||
|
|
e58dcbdd7b | ||
|
|
aeda457fb1 | ||
|
|
a70cc375c2 | ||
|
|
e8ea347654 | ||
|
|
a758c8f2da | ||
|
|
6295cfa840 | ||
|
|
b6c2c89adf | ||
|
|
6b0dd8beab | ||
|
|
984a015704 | ||
|
|
3df4fc5bff | ||
|
|
1b57df7753 | ||
|
|
bf1ecc54b3 | ||
|
|
fdfcfbb440 | ||
|
|
f1d64d247e | ||
|
|
198803d5b9 | ||
|
|
79781662e8 | ||
|
|
4075ac4d73 | ||
|
|
21f77ffc50 | ||
|
|
d0ef0531c0 | ||
|
|
c2917459a4 | ||
|
|
fb1b573855 | ||
|
|
8c74e360d2 | ||
|
|
e91935c03a | ||
|
|
01d082e552 | ||
|
|
90d8f65835 | ||
|
|
1c8e18e109 | ||
|
|
ab07075a67 |
@@ -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,
|
||||
|
||||
@@ -18,7 +18,13 @@ module directly. Access-side admin service helpers remain
|
||||
here for users, groups, roles, system accounts, sessions, API keys, tenant
|
||||
access enforcement, admin/audit lookup capabilities, tenant owner
|
||||
provisioning, and governance-template materialization into access-owned groups
|
||||
and roles. Governance-template metadata CRUD lives in `govoplan-admin`. The
|
||||
and roles. The module also enforces narrowly declared managed
|
||||
`RoleTemplate.default_authenticated` baselines. Their explicit permissions are
|
||||
derived from the active manifest set for every authenticated tenant member,
|
||||
without writing assignments during an authorization read. The optional role
|
||||
row is a non-assignable administration projection, not the source of the
|
||||
automatic grant. Domain permissions and resource policy remain separate checks.
|
||||
Governance-template metadata CRUD lives in `govoplan-admin`. The
|
||||
transitional administration WebUI route shell and
|
||||
access-owned panels live under `webui/src` as `@govoplan/access-webui`. Generic
|
||||
system administration panels are contributed by `@govoplan/admin-webui` through
|
||||
@@ -57,7 +63,8 @@ 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
|
||||
|
||||
The governance-template routes under `/admin/system/governance-templates` are
|
||||
@@ -70,13 +77,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
|
||||
|
||||
@@ -113,3 +140,21 @@ From the core checkout:
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
./.venv/bin/python -m pip install -e ../govoplan-access
|
||||
```
|
||||
|
||||
## Login Throttling
|
||||
|
||||
Interactive password login is throttled by normalized global login identity and by
|
||||
the directly connected client address. The deployment defaults are 10 identity
|
||||
failures and 100 client failures in a 15-minute window. Counters use
|
||||
`REDIS_URL` when Redis is reachable, allowing all API workers to share the same
|
||||
limits. Local development and Redis outages fall back automatically to a
|
||||
bounded, process-local counter; authentication remains available, but limits
|
||||
then apply per API process.
|
||||
|
||||
The deployment settings are `AUTH_LOGIN_THROTTLE_ENABLED`,
|
||||
`AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT`, `AUTH_LOGIN_THROTTLE_CLIENT_LIMIT`,
|
||||
`AUTH_LOGIN_THROTTLE_WINDOW_SECONDS`, and
|
||||
`AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS`. Client-supplied forwarding headers
|
||||
are not trusted for throttling. A reverse proxy should pass the real peer
|
||||
address only through the platform's separately configured trusted-proxy
|
||||
boundary.
|
||||
|
||||
@@ -146,8 +146,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 +180,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
|
||||
|
||||
|
||||
@@ -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,23 @@
|
||||
# Session And Device Management
|
||||
|
||||
Authenticated users can inspect their active browser sessions under **Settings
|
||||
> Sessions and devices**. Each row exposes only a stable session identifier,
|
||||
current-session marker, bounded user-agent label, creation time, last activity,
|
||||
expiry, and lifecycle state. Session tokens, token and CSRF hashes, cookies, IP
|
||||
addresses, and unrelated request metadata are never returned.
|
||||
|
||||
Users may revoke one other session or all other active sessions. The current
|
||||
session is deliberately protected by these operations; use normal logout to end
|
||||
it. Repeating a revocation is safe. Revoked sessions fail authentication on the
|
||||
next request, including when a principal summary was previously cached.
|
||||
|
||||
Tenant administrators may list sessions only for a membership in their governed
|
||||
tenant and may revoke only a session belonging to that membership and tenant.
|
||||
The mutation requires both the central membership-update permission and an
|
||||
interactive-session password re-authorization. API-key administration and
|
||||
cross-tenant session disclosure fail closed.
|
||||
|
||||
Audit events retain the actor, target session or account, action, and revoked
|
||||
count where applicable. They do not copy client labels, network addresses, or
|
||||
credentials. Expired and revoked sessions are retained according to Access data
|
||||
retention and are omitted from the active-session list.
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -18,11 +18,11 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"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": {
|
||||
|
||||
+3
-2
@@ -4,14 +4,15 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-access"
|
||||
version = "0.1.8"
|
||||
version = "0.1.19"
|
||||
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.8",
|
||||
"govoplan-core>=0.1.18",
|
||||
"redis>=5,<6",
|
||||
"SQLAlchemy>=2,<3",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN access platform module."""
|
||||
|
||||
__version__ = "0.1.6"
|
||||
__version__ = "0.1.19"
|
||||
|
||||
@@ -31,7 +31,7 @@ from govoplan_access.backend.permissions.catalog import (
|
||||
validate_tenant_permissions,
|
||||
role_templates_for_level,
|
||||
)
|
||||
from govoplan_core.tenancy.service import tenant_counts # re-exported compatibility helper
|
||||
from govoplan_core.tenancy.service import tenant_counts # noqa: F401 - re-exported compatibility helper
|
||||
|
||||
_TEMP_PASSWORD_ALPHABET = string.ascii_letters + string.digits + "-_!@#"
|
||||
|
||||
@@ -56,7 +56,7 @@ def ensure_default_roles(session: Session, tenant: Tenant | None = None) -> dict
|
||||
query = query.filter(Role.tenant_id == tenant.id) if tenant is not None else query.filter(Role.tenant_id.is_(None))
|
||||
role = query.one_or_none()
|
||||
is_builtin = _template_is_builtin(template_managed=template.managed, protected=template.protected, tenant_role=tenant is not None)
|
||||
is_assignable = True
|
||||
is_assignable = not template.default_authenticated
|
||||
if role is None:
|
||||
role = Role(
|
||||
tenant_id=tenant.id if tenant is not None else None,
|
||||
@@ -200,7 +200,24 @@ def set_user_groups(session: Session, *, user: User, group_ids: Iterable[str]) -
|
||||
|
||||
|
||||
def set_user_roles(session: Session, *, user: User, role_ids: Iterable[str]) -> None:
|
||||
ids = sorted(set(role_ids))
|
||||
default_slugs = {
|
||||
template.slug
|
||||
for template in role_templates_for_level("tenant")
|
||||
if template.default_authenticated
|
||||
}
|
||||
default_roles = (
|
||||
session.query(Role)
|
||||
.filter(
|
||||
Role.tenant_id == user.tenant_id,
|
||||
Role.slug.in_(default_slugs),
|
||||
)
|
||||
.all()
|
||||
if default_slugs
|
||||
else []
|
||||
)
|
||||
default_role_ids = {role.id for role in default_roles}
|
||||
requested_ids = set(role_ids) - default_role_ids
|
||||
ids = sorted(requested_ids)
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.filter(Role.tenant_id == user.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.admin.service import (
|
||||
@@ -21,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,
|
||||
)
|
||||
@@ -41,10 +43,15 @@ from govoplan_access.backend.db.models import (
|
||||
Tenant,
|
||||
User,
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import OrganizationDirectory
|
||||
from govoplan_access.backend.permissions.catalog import effective_permission_count, expand_scopes
|
||||
from govoplan_access.backend.permissions.catalog import (
|
||||
effective_permission_count,
|
||||
expand_scopes,
|
||||
scopes_grant,
|
||||
)
|
||||
|
||||
|
||||
def _http_admin_error(exc: Exception) -> HTTPException:
|
||||
@@ -82,13 +89,147 @@ def _resolve_tenant(
|
||||
return tenant
|
||||
|
||||
|
||||
def _role_summary(session: Session, role: Role) -> RoleSummary:
|
||||
def _tenant_role_assignment_counts(session: Session, role_ids: list[str]) -> dict[str, tuple[int, int]]:
|
||||
if not role_ids:
|
||||
return {}
|
||||
user_counts = {
|
||||
role_id: count
|
||||
for role_id, count in session.query(UserRoleAssignment.role_id, func.count(UserRoleAssignment.id))
|
||||
.filter(UserRoleAssignment.role_id.in_(role_ids))
|
||||
.group_by(UserRoleAssignment.role_id)
|
||||
.all()
|
||||
}
|
||||
group_counts = {
|
||||
role_id: count
|
||||
for role_id, count in session.query(GroupRoleAssignment.role_id, func.count(GroupRoleAssignment.id))
|
||||
.filter(GroupRoleAssignment.role_id.in_(role_ids))
|
||||
.group_by(GroupRoleAssignment.role_id)
|
||||
.all()
|
||||
}
|
||||
return {role_id: (int(user_counts.get(role_id, 0)), int(group_counts.get(role_id, 0))) for role_id in role_ids}
|
||||
|
||||
|
||||
def _system_role_assignment_counts(session: Session, role_ids: list[str]) -> dict[str, int]:
|
||||
if not role_ids:
|
||||
return {}
|
||||
return {
|
||||
role_id: int(count)
|
||||
for role_id, count in session.query(SystemRoleAssignment.role_id, func.count(SystemRoleAssignment.id))
|
||||
.filter(SystemRoleAssignment.role_id.in_(role_ids))
|
||||
.group_by(SystemRoleAssignment.role_id)
|
||||
.all()
|
||||
}
|
||||
|
||||
|
||||
def _accounts_by_id(session: Session, account_ids: list[str]) -> dict[str, Account]:
|
||||
if not account_ids:
|
||||
return {}
|
||||
return {
|
||||
account.id: account
|
||||
for account in session.query(Account).filter(Account.id.in_(sorted(set(account_ids)))).all()
|
||||
}
|
||||
|
||||
|
||||
def _accounts_by_user_id(session: Session, user_ids: list[str]) -> dict[str, Account]:
|
||||
if not user_ids:
|
||||
return {}
|
||||
return {
|
||||
user.id: account
|
||||
for user, account in session.query(User, Account)
|
||||
.join(Account, Account.id == User.account_id)
|
||||
.filter(User.id.in_(sorted(set(user_ids))))
|
||||
.all()
|
||||
}
|
||||
|
||||
|
||||
def _group_member_ids_by_group_id(session: Session, *, tenant_id: str, group_ids: list[str]) -> dict[str, list[str]]:
|
||||
grouped: dict[str, list[str]] = defaultdict(list)
|
||||
if not group_ids:
|
||||
return {}
|
||||
for group_id, user_id in (
|
||||
session.query(UserGroupMembership.group_id, UserGroupMembership.user_id)
|
||||
.filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.group_id.in_(sorted(set(group_ids))))
|
||||
.order_by(UserGroupMembership.group_id.asc(), UserGroupMembership.user_id.asc())
|
||||
.all()
|
||||
):
|
||||
grouped[group_id].append(user_id)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def _roles_by_group_id(session: Session, *, tenant_id: str, group_ids: list[str]) -> dict[str, list[Role]]:
|
||||
grouped: dict[str, list[Role]] = defaultdict(list)
|
||||
if not group_ids:
|
||||
return {}
|
||||
for group_id, role in (
|
||||
session.query(GroupRoleAssignment.group_id, Role)
|
||||
.join(Role, Role.id == GroupRoleAssignment.role_id)
|
||||
.filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.group_id.in_(sorted(set(group_ids))), Role.tenant_id == tenant_id)
|
||||
.order_by(GroupRoleAssignment.group_id.asc(), Role.name.asc())
|
||||
.all()
|
||||
):
|
||||
grouped[group_id].append(role)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def _groups_by_user_id(session: Session, *, tenant_id: str, user_ids: list[str]) -> dict[str, list[Group]]:
|
||||
grouped: dict[str, list[Group]] = defaultdict(list)
|
||||
if not user_ids:
|
||||
return {}
|
||||
for user_id, group in (
|
||||
session.query(UserGroupMembership.user_id, Group)
|
||||
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||
.filter(
|
||||
UserGroupMembership.tenant_id == tenant_id,
|
||||
UserGroupMembership.user_id.in_(sorted(set(user_ids))),
|
||||
Group.is_active.is_(True),
|
||||
)
|
||||
.order_by(UserGroupMembership.user_id.asc(), Group.name.asc())
|
||||
.all()
|
||||
):
|
||||
grouped[user_id].append(group)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def _roles_by_user_id(session: Session, *, tenant_id: str, user_ids: list[str]) -> dict[str, list[Role]]:
|
||||
grouped: dict[str, list[Role]] = defaultdict(list)
|
||||
if not user_ids:
|
||||
return {}
|
||||
for user_id, role in (
|
||||
session.query(UserRoleAssignment.user_id, Role)
|
||||
.join(Role, Role.id == UserRoleAssignment.role_id)
|
||||
.filter(
|
||||
UserRoleAssignment.tenant_id == tenant_id,
|
||||
UserRoleAssignment.user_id.in_(sorted(set(user_ids))),
|
||||
Role.tenant_id == tenant_id,
|
||||
)
|
||||
.order_by(UserRoleAssignment.user_id.asc(), Role.name.asc())
|
||||
.all()
|
||||
):
|
||||
grouped[user_id].append(role)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def _role_summary(
|
||||
session: Session,
|
||||
role: Role,
|
||||
*,
|
||||
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
|
||||
system_role_assignment_counts: dict[str, int] | None = None,
|
||||
) -> RoleSummary:
|
||||
group_count = 0
|
||||
if role.tenant_id is None:
|
||||
user_count = session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count()
|
||||
user_count = (
|
||||
system_role_assignment_counts.get(role.id, 0)
|
||||
if system_role_assignment_counts is not None
|
||||
else session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count()
|
||||
)
|
||||
permission_level = "system"
|
||||
else:
|
||||
user_count, group_count = role_assignment_counts(session, role.id)
|
||||
user_count, group_count = (
|
||||
tenant_role_assignment_counts.get(role.id, (0, 0))
|
||||
if tenant_role_assignment_counts is not None
|
||||
else role_assignment_counts(session, role.id)
|
||||
)
|
||||
permission_level = "tenant"
|
||||
permissions = list(role.permissions or [])
|
||||
return RoleSummary(
|
||||
@@ -108,20 +249,36 @@ def _role_summary(session: Session, role: Role) -> RoleSummary:
|
||||
)
|
||||
|
||||
|
||||
def _group_summary(session: Session, group: Group, *, include_members: bool = True) -> GroupSummary:
|
||||
member_ids = [
|
||||
def _group_summary(
|
||||
session: Session,
|
||||
group: Group,
|
||||
*,
|
||||
include_members: bool = True,
|
||||
member_ids_by_group: dict[str, list[str]] | None = None,
|
||||
roles_by_group: dict[str, list[Role]] | None = None,
|
||||
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
|
||||
) -> GroupSummary:
|
||||
member_ids = (
|
||||
member_ids_by_group.get(group.id, [])
|
||||
if member_ids_by_group is not None
|
||||
else [
|
||||
row[0]
|
||||
for row in session.query(UserGroupMembership.user_id)
|
||||
.filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id)
|
||||
.all()
|
||||
]
|
||||
)
|
||||
roles = (
|
||||
roles_by_group.get(group.id, [])
|
||||
if roles_by_group is not None
|
||||
else (
|
||||
session.query(Role)
|
||||
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
|
||||
.filter(GroupRoleAssignment.tenant_id == group.tenant_id, GroupRoleAssignment.group_id == group.id)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
)
|
||||
return GroupSummary(
|
||||
id=group.id,
|
||||
slug=group.slug,
|
||||
@@ -130,7 +287,7 @@ def _group_summary(session: Session, group: Group, *, include_members: bool = Tr
|
||||
is_active=group.is_active,
|
||||
member_count=len(member_ids),
|
||||
member_ids=member_ids if include_members else [],
|
||||
roles=[_role_summary(session, role) for role in roles],
|
||||
roles=[_role_summary(session, role, tenant_role_assignment_counts=tenant_role_assignment_counts) for role in roles],
|
||||
created_at=group.created_at,
|
||||
updated_at=group.updated_at,
|
||||
system_template_id=group.system_template_id,
|
||||
@@ -145,12 +302,18 @@ def _user_item(
|
||||
owner_ids: set[str] | None = None,
|
||||
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
accounts_by_id: dict[str, Account] | None = None,
|
||||
groups_by_user: dict[str, list[Group]] | None = None,
|
||||
roles_by_user: dict[str, list[Role]] | None = None,
|
||||
group_member_ids_by_group: dict[str, list[str]] | None = None,
|
||||
group_roles_by_group: dict[str, list[Role]] | None = None,
|
||||
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
|
||||
) -> UserAdminItem:
|
||||
account = session.get(Account, user.account_id)
|
||||
account = accounts_by_id.get(user.account_id) if accounts_by_id is not None else session.get(Account, user.account_id)
|
||||
if account is None:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing")
|
||||
groups = collect_user_groups(session, user)
|
||||
roles = collect_direct_user_roles(session, user)
|
||||
groups = groups_by_user.get(user.id, []) if groups_by_user is not None else collect_user_groups(session, user)
|
||||
roles = roles_by_user.get(user.id, []) if roles_by_user is not None else collect_direct_user_roles(session, user)
|
||||
external_roles = (
|
||||
collect_external_function_roles(
|
||||
session,
|
||||
@@ -176,8 +339,18 @@ def _user_item(
|
||||
account_is_active=account.is_active,
|
||||
password_reset_required=account.password_reset_required,
|
||||
last_login_at=account.last_login_at,
|
||||
groups=[_group_summary(session, group, include_members=False) for group in groups],
|
||||
roles=[_role_summary(session, role) for role in roles],
|
||||
groups=[
|
||||
_group_summary(
|
||||
session,
|
||||
group,
|
||||
include_members=False,
|
||||
member_ids_by_group=group_member_ids_by_group,
|
||||
roles_by_group=group_roles_by_group,
|
||||
tenant_role_assignment_counts=tenant_role_assignment_counts,
|
||||
)
|
||||
for group in groups
|
||||
],
|
||||
roles=[_role_summary(session, role, tenant_role_assignment_counts=tenant_role_assignment_counts) for role in roles],
|
||||
function_assignment_ids=sorted(dict.fromkeys(function_assignment_ids)),
|
||||
function_delegation_ids=collect_function_delegation_ids(session, user),
|
||||
effective_scopes=expand_scopes(effective_scopes),
|
||||
@@ -188,45 +361,253 @@ 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 _api_key_item(session: Session, item: ApiKey) -> ApiKeyAdminItem:
|
||||
def _system_account_items(
|
||||
session: Session,
|
||||
accounts: list[Account],
|
||||
) -> list[SystemAccountItem]:
|
||||
if not accounts:
|
||||
return []
|
||||
account_ids = [account.id for account in accounts]
|
||||
accounts_by_id = {account.id: account for account in accounts}
|
||||
membership_rows = _system_membership_rows(session, account_ids)
|
||||
memberships_by_account = _memberships_by_account(membership_rows)
|
||||
user_ids = [user.id for user, _tenant in membership_rows]
|
||||
roles_by_user = _system_direct_roles_by_user(session, user_ids)
|
||||
groups_by_user = _system_groups_by_user(session, user_ids)
|
||||
group_roles_by_user = _system_group_roles_by_user(session, user_ids)
|
||||
owner_ids_by_tenant = _system_owner_ids_by_tenant(
|
||||
membership_rows,
|
||||
accounts_by_id=accounts_by_id,
|
||||
direct_roles_by_user=roles_by_user,
|
||||
group_roles_by_user=group_roles_by_user,
|
||||
)
|
||||
system_roles_by_account, system_role_counts = _system_roles_for_accounts(
|
||||
session,
|
||||
account_ids,
|
||||
)
|
||||
return [
|
||||
_system_account_response_item(
|
||||
session,
|
||||
account,
|
||||
memberships=memberships_by_account[account.id],
|
||||
roles_by_user=roles_by_user,
|
||||
groups_by_user=groups_by_user,
|
||||
owner_ids_by_tenant=owner_ids_by_tenant,
|
||||
system_roles=system_roles_by_account[account.id],
|
||||
system_role_counts=system_role_counts,
|
||||
)
|
||||
for account in accounts
|
||||
]
|
||||
|
||||
|
||||
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
|
||||
return _system_account_items(session, [account])[0]
|
||||
|
||||
|
||||
def _api_key_item(session: Session, item: ApiKey, *, accounts_by_user_id: dict[str, Account] | None = None) -> ApiKeyAdminItem:
|
||||
if accounts_by_user_id is not None:
|
||||
account = accounts_by_user_id.get(item.user_id)
|
||||
else:
|
||||
user = session.get(User, item.user_id)
|
||||
account = session.get(Account, user.account_id) if user else None
|
||||
return ApiKeyAdminItem(
|
||||
|
||||
@@ -3,41 +3,11 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
|
||||
RETENTION_DAY_KEYS = (
|
||||
"raw_campaign_json_retention_days",
|
||||
"generated_eml_retention_days",
|
||||
"stored_report_detail_retention_days",
|
||||
"mock_mailbox_retention_days",
|
||||
"audit_detail_retention_days",
|
||||
)
|
||||
RETENTION_POLICY_FIELD_KEYS = (
|
||||
"store_raw_campaign_json",
|
||||
*RETENTION_DAY_KEYS,
|
||||
"audit_detail_level",
|
||||
)
|
||||
|
||||
|
||||
def default_allow_lower_level_limits() -> dict[str, bool]:
|
||||
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
|
||||
|
||||
|
||||
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
|
||||
if value in (None, ""):
|
||||
return default_allow_lower_level_limits() if fill_defaults else None
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("allow_lower_level_limits must be an object")
|
||||
normalized = default_allow_lower_level_limits() if fill_defaults else {}
|
||||
for key, allowed in value.items():
|
||||
clean_key = str(key)
|
||||
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
|
||||
raise ValueError(f"Unknown retention policy field: {clean_key}")
|
||||
normalized[clean_key] = bool(allowed)
|
||||
return normalized
|
||||
|
||||
from govoplan_core.i18n import REFERENCE_LANGUAGE_CODE
|
||||
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem
|
||||
|
||||
|
||||
class PermissionItem(BaseModel):
|
||||
@@ -67,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
|
||||
@@ -98,6 +99,13 @@ class TenantOwnerCandidateListResponse(BaseModel):
|
||||
accounts: list[TenantOwnerCandidate]
|
||||
|
||||
|
||||
class PagedListResponse(BaseModel):
|
||||
total: int = 0
|
||||
page: int = 1
|
||||
page_size: int = 500
|
||||
pages: int = 1
|
||||
|
||||
|
||||
class TenantCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -105,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
|
||||
@@ -129,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)
|
||||
@@ -179,7 +191,7 @@ class IdentityAdminItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class IdentityListResponse(BaseModel):
|
||||
class IdentityListResponse(PagedListResponse):
|
||||
identities: list[IdentityAdminItem]
|
||||
|
||||
|
||||
@@ -217,7 +229,7 @@ class OrganizationUnitItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OrganizationUnitListResponse(BaseModel):
|
||||
class OrganizationUnitListResponse(PagedListResponse):
|
||||
organization_units: list[OrganizationUnitItem]
|
||||
|
||||
|
||||
@@ -256,7 +268,7 @@ class FunctionAdminItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionListResponse(BaseModel):
|
||||
class FunctionListResponse(PagedListResponse):
|
||||
functions: list[FunctionAdminItem]
|
||||
|
||||
|
||||
@@ -271,7 +283,7 @@ class ExternalFunctionRoleMappingItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingListResponse(BaseModel):
|
||||
class ExternalFunctionRoleMappingListResponse(PagedListResponse):
|
||||
mappings: list[ExternalFunctionRoleMappingItem]
|
||||
|
||||
|
||||
@@ -342,7 +354,7 @@ class FunctionAssignmentAdminItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionAssignmentListResponse(BaseModel):
|
||||
class FunctionAssignmentListResponse(PagedListResponse):
|
||||
assignments: list[FunctionAssignmentAdminItem]
|
||||
|
||||
|
||||
@@ -392,7 +404,7 @@ class FunctionDelegationAdminItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionDelegationListResponse(BaseModel):
|
||||
class FunctionDelegationListResponse(PagedListResponse):
|
||||
delegations: list[FunctionDelegationAdminItem]
|
||||
|
||||
|
||||
@@ -529,7 +541,22 @@ class ResourceAccessExplanationResponse(BaseModel):
|
||||
provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
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]
|
||||
|
||||
|
||||
@@ -567,7 +594,7 @@ class UserUpdateRequest(BaseModel):
|
||||
role_ids: list[str] | None = None
|
||||
|
||||
|
||||
class GroupListResponse(BaseModel):
|
||||
class GroupListResponse(PagedListResponse):
|
||||
groups: list[GroupSummary]
|
||||
|
||||
|
||||
@@ -599,7 +626,7 @@ class GroupUpdateRequest(BaseModel):
|
||||
role_ids: list[str] | None = None
|
||||
|
||||
|
||||
class RoleListResponse(BaseModel):
|
||||
class RoleListResponse(PagedListResponse):
|
||||
roles: list[RoleSummary]
|
||||
|
||||
|
||||
@@ -638,7 +665,7 @@ class SystemAccountItem(BaseModel):
|
||||
last_login_at: datetime | None = None
|
||||
|
||||
|
||||
class SystemAccountListResponse(BaseModel):
|
||||
class SystemAccountListResponse(PagedListResponse):
|
||||
accounts: list[SystemAccountItem]
|
||||
roles: list[RoleSummary]
|
||||
|
||||
@@ -704,42 +731,6 @@ class PolicySourceStepItem(BaseModel):
|
||||
policy: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PrivacyRetentionPolicyItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
store_raw_campaign_json: bool = True
|
||||
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
|
||||
generated_eml_retention_days: int | None = Field(default=None, ge=0)
|
||||
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
|
||||
audit_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
|
||||
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
|
||||
|
||||
@field_validator("allow_lower_level_limits", mode="before")
|
||||
@classmethod
|
||||
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
|
||||
return normalize_allow_lower_level_limits(value, fill_defaults=True)
|
||||
|
||||
|
||||
class PrivacyRetentionPolicyPatchItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
store_raw_campaign_json: bool | None = None
|
||||
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
|
||||
generated_eml_retention_days: int | None = Field(default=None, ge=0)
|
||||
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
|
||||
audit_detail_retention_days: int | None = Field(default=None, ge=0)
|
||||
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
|
||||
allow_lower_level_limits: dict[str, bool] | None = None
|
||||
|
||||
@field_validator("allow_lower_level_limits", mode="before")
|
||||
@classmethod
|
||||
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
|
||||
return normalize_allow_lower_level_limits(value, fill_defaults=False)
|
||||
|
||||
|
||||
class PrivacyRetentionPolicyScopeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -774,7 +765,7 @@ class ConfigurationSafetyFieldItem(BaseModel):
|
||||
storage: str
|
||||
ui_managed: bool
|
||||
risk: Literal["low", "medium", "high", "destructive"]
|
||||
secret_handling: Literal["none", "reference_only", "env_only"] = "none"
|
||||
secret_handling: Literal["none", "reference_only", "env_only"] = "none" # noqa: S105 - policy vocabulary.
|
||||
required_scopes: list[str] = Field(default_factory=list)
|
||||
dry_run_required: bool = False
|
||||
validation_required: bool = True
|
||||
@@ -877,7 +868,7 @@ class ConfigurationPackageExportResponse(BaseModel):
|
||||
|
||||
|
||||
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
|
||||
@@ -912,7 +903,7 @@ class ApiKeyAdminItem(BaseModel):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ApiKeyListResponse(BaseModel):
|
||||
class ApiKeyListResponse(PagedListResponse):
|
||||
api_keys: list[ApiKeyAdminItem]
|
||||
|
||||
|
||||
@@ -936,6 +927,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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,467 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.admin.governance import assert_api_keys_allowed
|
||||
from govoplan_access.backend.api.v1.admin_common import _resolve_tenant
|
||||
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
ServiceAccountCreateRequest,
|
||||
ServiceAccountCredentialCreateRequest,
|
||||
ServiceAccountCredentialItem,
|
||||
ServiceAccountCredentialListResponse,
|
||||
ServiceAccountCredentialMutationResponse,
|
||||
ServiceAccountCredentialRevokeRequest,
|
||||
ServiceAccountCredentialRotateRequest,
|
||||
ServiceAccountCredentialSecretResponse,
|
||||
ServiceAccountItem,
|
||||
ServiceAccountListResponse,
|
||||
ServiceAccountRetireRequest,
|
||||
ServiceAccountUpdateRequest,
|
||||
)
|
||||
from govoplan_access.backend.service_accounts import (
|
||||
ServiceAccountConflictError,
|
||||
ServiceAccountCredentialNotFoundError,
|
||||
ServiceAccountCredentialSummary,
|
||||
ServiceAccountError,
|
||||
ServiceAccountNotFoundError,
|
||||
create_service_account,
|
||||
create_service_account_credential,
|
||||
get_service_account,
|
||||
list_service_accounts,
|
||||
list_service_account_credentials,
|
||||
revoke_service_account_credential,
|
||||
retire_service_account,
|
||||
rotate_service_account_credential,
|
||||
service_account_credential_summaries,
|
||||
update_service_account,
|
||||
)
|
||||
from govoplan_core.admin.common import AdminConflictError
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||
from govoplan_core.db.session import get_session
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/service-accounts",
|
||||
tags=["admin", "service-accounts"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ServiceAccountListResponse)
|
||||
def list_managed_service_accounts(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:read")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
items = list_service_accounts(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
summaries = service_account_credential_summaries(
|
||||
session,
|
||||
service_accounts=items,
|
||||
)
|
||||
return ServiceAccountListResponse(
|
||||
items=[
|
||||
_service_account_item(item, summaries.get(item.id))
|
||||
for item in items
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{service_account_id}",
|
||||
response_model=ServiceAccountItem,
|
||||
)
|
||||
def get_managed_service_account(
|
||||
service_account_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:read")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
item = get_service_account(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
)
|
||||
except ServiceAccountError as exc:
|
||||
raise _service_account_http_error(exc) from exc
|
||||
summary = service_account_credential_summaries(
|
||||
session,
|
||||
service_accounts=(item,),
|
||||
)[item.id]
|
||||
return _service_account_item(item, summary)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ServiceAccountItem,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_managed_service_account(
|
||||
payload: ServiceAccountCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
item = create_service_account(
|
||||
session,
|
||||
tenant=tenant,
|
||||
principal=principal,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
scope_ceiling=payload.scope_ceiling,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.created",
|
||||
scope="tenant",
|
||||
object_type="service_account",
|
||||
object_id=item.id,
|
||||
details={
|
||||
"name": item.name,
|
||||
"scope_ceiling": list(item.scope_ceiling),
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return _service_account_item(item)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{service_account_id}",
|
||||
response_model=ServiceAccountItem,
|
||||
)
|
||||
def update_managed_service_account(
|
||||
service_account_id: str,
|
||||
payload: ServiceAccountUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
changes = {
|
||||
field: getattr(payload, field)
|
||||
for field in payload.model_fields_set
|
||||
if field != "expected_revision"
|
||||
}
|
||||
for field in ("name", "scope_ceiling", "is_active"):
|
||||
if field in changes and changes[field] is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"{field} cannot be null",
|
||||
)
|
||||
try:
|
||||
item = update_service_account(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
changes=changes,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.updated",
|
||||
scope="tenant",
|
||||
object_type="service_account",
|
||||
object_id=item.id,
|
||||
details={
|
||||
"changed_fields": sorted(changes),
|
||||
"revision": item.revision,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return _service_account_item(item)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{service_account_id}/retire",
|
||||
response_model=ServiceAccountItem,
|
||||
)
|
||||
def retire_managed_service_account(
|
||||
service_account_id: str,
|
||||
payload: ServiceAccountRetireRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
item = retire_service_account(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.retired",
|
||||
scope="tenant",
|
||||
object_type="service_account",
|
||||
object_id=item.id,
|
||||
details={"revision": item.revision},
|
||||
)
|
||||
session.commit()
|
||||
return _service_account_item(item)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{service_account_id}/credentials",
|
||||
response_model=ServiceAccountCredentialListResponse,
|
||||
)
|
||||
def list_managed_service_account_credentials(
|
||||
service_account_id: str,
|
||||
include_revoked: bool = Query(default=True),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:read")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
item, credentials = list_service_account_credentials(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
include_revoked=include_revoked,
|
||||
)
|
||||
except ServiceAccountError as exc:
|
||||
raise _service_account_http_error(exc) from exc
|
||||
return ServiceAccountCredentialListResponse(
|
||||
service_account_revision=item.revision,
|
||||
items=[_credential_item(credential) for credential in credentials],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{service_account_id}/credentials",
|
||||
response_model=ServiceAccountCredentialSecretResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_managed_service_account_credential(
|
||||
service_account_id: str,
|
||||
payload: ServiceAccountCredentialCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
assert_api_keys_allowed(session, tenant)
|
||||
item, created = create_service_account_credential(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
name=payload.name,
|
||||
scopes=payload.scopes,
|
||||
expires_at=payload.expires_at,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError, AdminConflictError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.credential_created",
|
||||
scope="tenant",
|
||||
object_type="service_account_credential",
|
||||
object_id=created.model.id,
|
||||
details={
|
||||
"service_account_id": item.id,
|
||||
"prefix": created.model.prefix,
|
||||
"scopes": list(created.model.scopes),
|
||||
"service_account_revision": item.revision,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return ServiceAccountCredentialSecretResponse(
|
||||
service_account_revision=item.revision,
|
||||
credential=_credential_item(created.model),
|
||||
secret=created.secret,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{service_account_id}/credentials/{credential_id}/rotate",
|
||||
response_model=ServiceAccountCredentialSecretResponse,
|
||||
)
|
||||
def rotate_managed_service_account_credential(
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
payload: ServiceAccountCredentialRotateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
assert_api_keys_allowed(session, tenant)
|
||||
item, previous, created = rotate_service_account_credential(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
credential_id=credential_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
name=payload.name,
|
||||
scopes=payload.scopes,
|
||||
expires_at=payload.expires_at,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError, AdminConflictError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.credential_rotated",
|
||||
scope="tenant",
|
||||
object_type="service_account_credential",
|
||||
object_id=created.model.id,
|
||||
details={
|
||||
"service_account_id": item.id,
|
||||
"previous_credential_id": previous.id,
|
||||
"prefix": created.model.prefix,
|
||||
"scopes": list(created.model.scopes),
|
||||
"service_account_revision": item.revision,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return ServiceAccountCredentialSecretResponse(
|
||||
service_account_revision=item.revision,
|
||||
credential=_credential_item(created.model),
|
||||
secret=created.secret,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{service_account_id}/credentials/{credential_id}/revoke",
|
||||
response_model=ServiceAccountCredentialMutationResponse,
|
||||
)
|
||||
def revoke_managed_service_account_credential(
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
payload: ServiceAccountCredentialRevokeRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
item, credential = revoke_service_account_credential(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
credential_id=credential_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.credential_revoked",
|
||||
scope="tenant",
|
||||
object_type="service_account_credential",
|
||||
object_id=credential.id,
|
||||
details={
|
||||
"service_account_id": item.id,
|
||||
"prefix": credential.prefix,
|
||||
"service_account_revision": item.revision,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return ServiceAccountCredentialMutationResponse(
|
||||
service_account_revision=item.revision,
|
||||
credential=_credential_item(credential),
|
||||
)
|
||||
|
||||
|
||||
def _service_account_item(
|
||||
item: object,
|
||||
summary: ServiceAccountCredentialSummary | None = None,
|
||||
) -> ServiceAccountItem:
|
||||
values = ServiceAccountItem.model_validate(
|
||||
item, from_attributes=True
|
||||
)
|
||||
if summary is None:
|
||||
return values
|
||||
return values.model_copy(
|
||||
update={
|
||||
"credential_count": summary.credential_count,
|
||||
"active_credential_count": summary.active_credential_count,
|
||||
"last_credential_used_at": summary.last_credential_used_at,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _credential_item(item: object) -> ServiceAccountCredentialItem:
|
||||
return ServiceAccountCredentialItem.model_validate(
|
||||
item,
|
||||
from_attributes=True,
|
||||
)
|
||||
|
||||
|
||||
def _service_account_http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, ServiceAccountNotFoundError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
if isinstance(exc, ServiceAccountCredentialNotFoundError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
if isinstance(exc, ServiceAccountConflictError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
)
|
||||
if isinstance(exc, PermissionError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(exc),
|
||||
)
|
||||
if isinstance(exc, AdminConflictError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
)
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from threading import Lock
|
||||
from time import monotonic
|
||||
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.principal_cache import AuthPrincipalRevision
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CachedPrincipal:
|
||||
principal: PrincipalRef
|
||||
revision: AuthPrincipalRevision
|
||||
stored_at: float
|
||||
|
||||
|
||||
class PrincipalSummaryCache:
|
||||
"""A bounded process-local cache containing no ORM or secret objects."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._entries: OrderedDict[str, CachedPrincipal] = OrderedDict()
|
||||
self._lock = Lock()
|
||||
|
||||
def get(
|
||||
self,
|
||||
token_digest: str,
|
||||
*,
|
||||
session_ttl_seconds: int,
|
||||
api_key_ttl_seconds: int,
|
||||
) -> CachedPrincipal | None:
|
||||
with self._lock:
|
||||
entry = self._entries.get(token_digest)
|
||||
if entry is None:
|
||||
return None
|
||||
ttl = (
|
||||
api_key_ttl_seconds
|
||||
if entry.principal.auth_method == "api_key"
|
||||
else session_ttl_seconds
|
||||
)
|
||||
if ttl <= 0 or monotonic() - entry.stored_at > ttl:
|
||||
self._entries.pop(token_digest, None)
|
||||
return None
|
||||
self._entries.move_to_end(token_digest)
|
||||
return entry
|
||||
|
||||
def put(
|
||||
self,
|
||||
token_digest: str,
|
||||
*,
|
||||
principal: PrincipalRef,
|
||||
revision: AuthPrincipalRevision,
|
||||
max_entries: int,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._entries[token_digest] = CachedPrincipal(
|
||||
principal=principal,
|
||||
revision=revision,
|
||||
stored_at=monotonic(),
|
||||
)
|
||||
self._entries.move_to_end(token_digest)
|
||||
while len(self._entries) > max(1, max_entries):
|
||||
self._entries.popitem(last=False)
|
||||
|
||||
def discard(self, token_digest: str) -> None:
|
||||
with self._lock:
|
||||
self._entries.pop(token_digest, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._entries.clear()
|
||||
|
||||
|
||||
principal_summary_cache = PrincipalSummaryCache()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CachedPrincipal",
|
||||
"PrincipalSummaryCache",
|
||||
"principal_summary_cache",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -2,18 +2,23 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY
|
||||
from govoplan_access.backend.dsar_provider import ACCESS_DSAR_CAPABILITY
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_ADMINISTRATION,
|
||||
CAPABILITY_ACCESS_DIRECTORY,
|
||||
CAPABILITY_ACCESS_EXPLANATION,
|
||||
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1,
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER,
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||
@@ -25,7 +30,9 @@ from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityD
|
||||
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory
|
||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory
|
||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
@@ -33,12 +40,15 @@ from govoplan_core.core.modules import (
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY
|
||||
from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH
|
||||
from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition:
|
||||
@@ -69,11 +79,14 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
_permission("access:system_role:assign", "Assign system roles", "Assign instance-wide roles to accounts while preserving a system owner.", "Access", "system"),
|
||||
_permission("access:system_setting:read", "View system settings", "Read instance defaults and tenant-governance defaults.", "Access", "system"),
|
||||
_permission("access:system_setting:write", "Manage system settings", "Change instance defaults and tenant-governance defaults.", "Access", "system"),
|
||||
_permission("access:system_credential:read", "View system credentials", "List instance-wide reusable credential envelopes without revealing secret values.", "Access", "system"),
|
||||
_permission("access:system_credential:write", "Manage system credentials", "Create, update, and retire instance-wide reusable credential envelopes.", "Access", "system"),
|
||||
_permission("access:maintenance:access", "Access during maintenance", "Use the system while maintenance mode is active.", "Access", "system"),
|
||||
_permission("access:audit:read", "View system audit", "Read audit records across tenants.", "Access", "system"),
|
||||
_permission("access:membership:read", "View memberships", "List tenant memberships and effective access.", "Tenant access", "tenant"),
|
||||
_permission("access:membership:create", "Create memberships", "Create tenant-local account memberships.", "Tenant access", "tenant"),
|
||||
_permission("access:membership:update", "Update memberships", "Update or suspend tenant memberships.", "Tenant access", "tenant"),
|
||||
_permission("access:session:manage_own", "Manage own sessions", "Inspect and revoke the current account's browser sessions without exposing credentials.", "Tenant access", "tenant"),
|
||||
_permission("access:group:read", "View groups", "List tenant groups and members.", "Tenant access", "tenant"),
|
||||
_permission("access:group:write", "Manage groups", "Create and update tenant groups.", "Tenant access", "tenant"),
|
||||
_permission("access:group:manage_members", "Manage group members", "Add and remove memberships from groups.", "Tenant access", "tenant"),
|
||||
@@ -87,10 +100,19 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
_permission("access:api_key:read", "View API keys", "List API keys without revealing secrets.", "Tenant access", "tenant"),
|
||||
_permission("access:api_key:create", "Create API keys", "Create tenant API keys within delegation limits.", "Tenant access", "tenant"),
|
||||
_permission("access:api_key:revoke", "Revoke API keys", "Revoke tenant API keys.", "Tenant access", "tenant"),
|
||||
_permission("access:service_account:read", "View service accounts", "List non-login automation principals and their current scope ceilings.", "Tenant access", "tenant"),
|
||||
_permission("access:service_account:write", "Manage service accounts", "Create, update, suspend, and retire scope-bounded automation principals.", "Tenant access", "tenant"),
|
||||
_permission("access:setting:read", "View settings", "Read access and governance settings.", "Tenant access", "tenant"),
|
||||
_permission("access:setting:write", "Manage settings", "Update access and governance settings.", "Tenant access", "tenant"),
|
||||
_permission("access:credential:read", "View credentials", "List reusable credential envelopes without revealing secret values.", "Tenant access", "tenant"),
|
||||
_permission("access:credential:write", "Manage credentials", "Create, update, and retire reusable credential envelopes.", "Tenant access", "tenant"),
|
||||
_permission("access:credential:manage_own", "Manage own credentials", "Manage reusable credentials owned by the current membership.", "Tenant access", "tenant"),
|
||||
_permission("access:policy:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant access", "tenant"),
|
||||
_permission("access:policy:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant access", "tenant"),
|
||||
_permission("access:privacy:read", "View data-subject requests", "Inspect tenant data-subject requests, provider coverage, and retained evidence decisions.", "Privacy", "tenant"),
|
||||
_permission("access:privacy:manage", "Manage data-subject requests", "Create requests and run provider searches and erasure planning.", "Privacy", "tenant"),
|
||||
_permission("access:privacy:export", "Export data-subject requests", "Export the collected personal-data package and its coverage manifest.", "Privacy", "tenant"),
|
||||
_permission("access:privacy:erase", "Execute data erasure", "Execute explicitly selected, provider-owned erasure and anonymization actions.", "Privacy", "tenant"),
|
||||
_permission("access:governance:read", "View governance", "Inspect managed role and group templates.", "Access", "system"),
|
||||
_permission("access:governance:write", "Manage governance", "Create and assign managed role and group templates.", "Access", "system"),
|
||||
)
|
||||
@@ -123,6 +145,8 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
|
||||
"access:system_role:assign",
|
||||
"access:system_setting:read",
|
||||
"access:system_setting:write",
|
||||
"access:system_credential:read",
|
||||
"access:system_credential:write",
|
||||
"access:governance:read",
|
||||
"access:governance:write",
|
||||
),
|
||||
@@ -155,6 +179,16 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
|
||||
managed=False,
|
||||
protected=False,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="account_security",
|
||||
name="Account security",
|
||||
description="Authenticated baseline for inspecting and revoking the current account's browser sessions.",
|
||||
permissions=("access:session:manage_own",),
|
||||
level="tenant",
|
||||
managed=True,
|
||||
protected=True,
|
||||
default_authenticated=True,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="owner",
|
||||
name="Tenant owner",
|
||||
@@ -181,8 +215,12 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
|
||||
"access:api_key:read",
|
||||
"access:api_key:create",
|
||||
"access:api_key:revoke",
|
||||
"access:service_account:read",
|
||||
"access:service_account:write",
|
||||
"access:setting:read",
|
||||
"access:setting:write",
|
||||
"access:credential:read",
|
||||
"access:credential:write",
|
||||
"access:policy:read",
|
||||
"access:policy:write",
|
||||
),
|
||||
@@ -224,6 +262,20 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
|
||||
managed=True,
|
||||
protected=False,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="privacy_officer",
|
||||
name="Privacy officer",
|
||||
description="Search, export, plan, and execute governed data-subject requests.",
|
||||
permissions=(
|
||||
"access:privacy:read",
|
||||
"access:privacy:manage",
|
||||
"access:privacy:export",
|
||||
"access:privacy:erase",
|
||||
),
|
||||
level="tenant",
|
||||
managed=True,
|
||||
protected=False,
|
||||
),
|
||||
)
|
||||
|
||||
ADMIN_READ_SCOPES = (
|
||||
@@ -231,6 +283,7 @@ ADMIN_READ_SCOPES = (
|
||||
"admin:groups:read",
|
||||
"admin:roles:read",
|
||||
"admin:api_keys:read",
|
||||
"access:service_account:read",
|
||||
"admin:settings:read",
|
||||
"system:tenants:read",
|
||||
"system:accounts:read",
|
||||
@@ -242,9 +295,122 @@ ADMIN_READ_SCOPES = (
|
||||
"access:account:read",
|
||||
"access:governance:read",
|
||||
"access:function:read",
|
||||
"access:privacy:read",
|
||||
"views:definition:read",
|
||||
"views:assignment:read",
|
||||
"views:system_definition:read",
|
||||
"views:system_assignment:read",
|
||||
)
|
||||
|
||||
ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
DocumentationTopic(
|
||||
id="access.reference.effective-appearance",
|
||||
title="Understand the effective appearance source",
|
||||
summary="The authenticated profile explains the effective palette, policy lock, and governed personal token overrides.",
|
||||
body=(
|
||||
"An explicit personal palette normally wins over tenant and system defaults. "
|
||||
"Resetting it stores inheritance, not a copy of the current default. A tenant "
|
||||
"policy lock suppresses personal choices; a system lock suppresses both tenant "
|
||||
"and personal choices. Access enforces the lock during profile writes and returns "
|
||||
"the effective palette, source, inherited value, and lock state on full profile responses. "
|
||||
"Advanced accent, surface, and status overrides are accepted only when the system opts in, "
|
||||
"the tenant does not block them, and neither palette scope is locked. Both light and dark "
|
||||
"documents are versioned and validated atomically for hexadecimal values, WCAG AA paired "
|
||||
"contrast, and distinct status colors. Invalid or disallowed documents are never partially "
|
||||
"applied; users may still remove an inactive stored override to return to inheritance."
|
||||
),
|
||||
layer="always",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "tenant_admin", "system_admin"),
|
||||
order=8,
|
||||
links=(DocumentationLink(label="Profile API", href="/api/v1/auth/profile", kind="api"),),
|
||||
related_modules=("admin", "tenancy"),
|
||||
metadata={"kind": "reference", "help_contexts": ["core.settings", "admin.system-settings", "tenancy.admin.tenant-settings"]},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.reference.resource-explanation-subjects",
|
||||
title="Select a user for resource-access diagnostics",
|
||||
summary=(
|
||||
"Resource explanations default to the signed-in user and expose "
|
||||
"other tenant users only when Policy permits the diagnostic."
|
||||
),
|
||||
body=(
|
||||
"The shared Files and Campaign explanation dialog asks Access for "
|
||||
"the permitted subject list. If Policy is unavailable or the actor "
|
||||
"lacks policy:access_explanation:select_user, Access returns only "
|
||||
"the signed-in active membership and no metadata for other users. "
|
||||
"When Policy permits selection, the picker is limited to active "
|
||||
"users in the current tenant. Every explanation run for another "
|
||||
"user creates audit evidence with the target membership, resource, "
|
||||
"requested action, and policy source. The explanation is diagnostic "
|
||||
"and never grants resource access."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "security_reviewer"),
|
||||
order=29,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access",),
|
||||
any_scopes=(
|
||||
"admin:users:read",
|
||||
"admin:roles:read",
|
||||
"access:membership:read",
|
||||
"access:role:read",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Permitted explanation subjects API",
|
||||
href="/api/v1/admin/access/resource-explanation/subjects",
|
||||
kind="api",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Resource explanation API",
|
||||
href="/api/v1/admin/access/resource-explanation",
|
||||
kind="api",
|
||||
),
|
||||
),
|
||||
related_modules=("audit", "campaigns", "files", "policy"),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": ["access.resource-explanation.subject"],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.operator.enroll-first-administrator",
|
||||
title="Enroll the first production administrator",
|
||||
summary="A local operator can issue one expiring credential while no durable system administrator exists, then use it once to create the protected system owner and an initial tenant membership.",
|
||||
body=(
|
||||
"Run the Core first-admin issue command after migrations and after Access is installed. The command stores the random secret in a local mode-0600 artifact and prints only its path, fingerprint, and expiry. "
|
||||
"The public bootstrap status endpoint exposes only minimum readiness. The enrollment endpoint accepts only the first account and initial tenant fields, creates one protected system owner plus one tenant-owner membership atomically, and retires the credential. "
|
||||
"Identical retries return the completed account without creating another owner. Lost or expired material can be rotated only by the local recovery command and only while the durable-administrator check remains empty. Development bootstrap settings are a separate dev-only path and are never enabled by enrollment."
|
||||
),
|
||||
layer="always",
|
||||
documentation_types=("admin",),
|
||||
audience=("operator", "system_admin"),
|
||||
order=10,
|
||||
links=(
|
||||
DocumentationLink(label="Bootstrap readiness API", href="/api/v1/bootstrap/status", kind="api"),
|
||||
DocumentationLink(label="First-administrator enrollment API", href="/api/v1/bootstrap/first-admin", kind="api"),
|
||||
),
|
||||
metadata={
|
||||
"kind": "operator_workflow",
|
||||
"commands": [
|
||||
"python -m govoplan_core.commands.first_admin status",
|
||||
"python -m govoplan_core.commands.first_admin issue --reason 'initial production installation'",
|
||||
"python -m govoplan_core.commands.first_admin recover --reason 'lost or expired handoff'",
|
||||
],
|
||||
"security_properties": [
|
||||
"random expiring credential",
|
||||
"mode-0600 local artifact",
|
||||
"single-use idempotent enrollment",
|
||||
"empty-install authority gate",
|
||||
"hash-chained and audit evidence",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.workflow.grant-user-access",
|
||||
title="Grant a person access",
|
||||
@@ -276,6 +442,12 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
configuration_keys=("access_governance",),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": [
|
||||
"access.admin.users",
|
||||
"access.admin.groups",
|
||||
"access.admin.roles",
|
||||
"access.admin.blocked",
|
||||
],
|
||||
"outcome": "A person can sign in to the tenant and receives the intended access through groups and roles.",
|
||||
"prerequisites": [
|
||||
"You can open Admin.",
|
||||
@@ -304,11 +476,49 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.operator.governance-projection",
|
||||
title="Reconcile managed groups and roles in bulk",
|
||||
summary="Access projects centrally assigned governance templates into tenant groups and roles through one bounded, versioned reconciliation contract.",
|
||||
body=(
|
||||
"Admin supplies stable template and tenant-assignment inputs to the Access governance projection v1 capability. Access bulk-loads managed groups, roles, memberships, and role mappings, then returns one created, updated, unchanged, removed, absent, blocked, or failed outcome per assignment. Repeating the same request is idempotent. A removal remains blocked while the managed resource has members or role assignments, and protected or unrelated tenant resources are never adopted. Dry runs calculate the same outcomes without changing Access data; applied and preview runs retain source and assignment-mode provenance in the Admin audit event."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("system_admin", "operator"),
|
||||
order=31,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access", "admin"),
|
||||
any_scopes=("access:governance:read", "access:governance:write"),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Governance templates", href="/admin?section=system-role-templates", kind="runtime"),
|
||||
DocumentationLink(label="Bulk synchronization API", href="/api/v1/admin/system/governance-templates/synchronize", kind="api"),
|
||||
DocumentationLink(label="Access module boundary", href="docs/ACCESS_MODULE_BOUNDARY.md", kind="repository"),
|
||||
),
|
||||
metadata={
|
||||
"kind": "operator_workflow",
|
||||
"help_contexts": ["admin.governance-templates"],
|
||||
"capability": "access.governanceProjection.v1",
|
||||
"batch_limit": 500,
|
||||
"outcome_statuses": [
|
||||
"created",
|
||||
"updated",
|
||||
"unchanged",
|
||||
"removed",
|
||||
"absent",
|
||||
"blocked",
|
||||
"failed",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.reference.admin-access-fields",
|
||||
title="Access administration fields",
|
||||
summary="The access administration screens show tenant memberships, groups, roles, and API keys. Admin docs map the visible fields to API payloads and permission scopes.",
|
||||
body="Users need the visible labels and a short explanation. Admins also need the backing route, API field, permission, and governance note so they can diagnose unavailable actions.",
|
||||
body="Users need the visible labels and a short explanation. Admins also need the backing route, API field, permission, and governance note so they can diagnose unavailable actions. New API-key secrets use the GovOPlaN `gpn_` marker; previously issued keys retain their original value and remain valid until expiry or revocation.",
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
@@ -334,10 +544,19 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
DocumentationLink(label="Groups API", href="/api/v1/admin/groups", kind="api"),
|
||||
DocumentationLink(label="Roles API", href="/api/v1/admin/roles", kind="api"),
|
||||
DocumentationLink(label="API keys API", href="/api/v1/admin/api-keys", kind="api"),
|
||||
DocumentationLink(label="Service accounts", href="/admin?section=tenant-service-accounts", kind="runtime"),
|
||||
DocumentationLink(label="Service accounts API", href="/api/v1/admin/service-accounts", kind="api"),
|
||||
),
|
||||
configuration_keys=("access_governance",),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
"access.admin.system-users",
|
||||
"access.admin.system-roles",
|
||||
"access.admin.tenant-users",
|
||||
"access.admin.tenant-groups",
|
||||
"access.admin.tenant-roles",
|
||||
],
|
||||
"route": "/admin",
|
||||
"screen": "Admin",
|
||||
"section": "Users, groups, roles, and API keys",
|
||||
@@ -404,6 +623,339 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.workflow.manage-api-keys",
|
||||
title="Create and revoke tenant API keys",
|
||||
summary="Issue a one-time automation secret for an accountable tenant user, constrain its scope and lifetime, and revoke it when access must stop.",
|
||||
body=(
|
||||
"Tenant API keys are non-interactive automation credentials owned by an existing tenant user. Select an owner whose current effective permissions contain every requested scope; authorization continues to intersect the stored key scopes with that owner's current permissions, so removing the owner's access also narrows the key. Set the shortest practical expiry and grant only the scopes the client needs. "
|
||||
"The secret is displayed once after creation. GovOPlaN then retains only its one-way hash and visible prefix, so administrators cannot display or recover it later. Record the value directly in an approved external secret manager and close the one-time dialog only after custody is confirmed. "
|
||||
"Revocation is immediate and irreversible for that key: existing clients lose access and must be configured with a newly issued credential. Inspect and audit views expose metadata, scopes, timestamps, and the non-authenticating prefix but never secret material."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
order=32,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access",),
|
||||
any_scopes=(
|
||||
"access:api_key:read",
|
||||
"access:api_key:create",
|
||||
"access:api_key:revoke",
|
||||
"admin:api_keys:read",
|
||||
"admin:api_keys:create",
|
||||
"admin:api_keys:revoke",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Tenant API keys", href="/admin?section=tenant-api-keys", kind="runtime"),
|
||||
DocumentationLink(label="API keys API", href="/api/v1/admin/api-keys", kind="api"),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Mandanten-API-Schlüssel erstellen und widerrufen",
|
||||
"summary": "Geben Sie ein einmal sichtbares Automatisierungsgeheimnis für eine verantwortliche Person aus, begrenzen Sie Umfang und Laufzeit und widerrufen Sie den Schlüssel, sobald der Zugriff enden muss.",
|
||||
"body": "Mandanten-API-Schlüssel sind nicht interaktive Automatisierungszugänge einer vorhandenen Person im Mandanten. Wählen Sie eine verantwortliche Person, deren aktuelle wirksame Berechtigungen alle gewünschten Scopes enthalten. Bei jeder Nutzung werden die gespeicherten Schlüssel-Scopes weiterhin mit den aktuellen Berechtigungen dieser Person geschnitten; ein Entzug ihrer Berechtigungen schränkt daher auch den Schlüssel ein. Legen Sie die kürzeste praktikable Laufzeit fest und vergeben Sie nur die Scopes, die der Client tatsächlich benötigt. Das Geheimnis wird nach der Erstellung genau einmal angezeigt. Danach speichert GovOPlaN nur einen Einweg-Hash und das sichtbare, nicht zur Anmeldung geeignete Präfix; eine spätere Anzeige oder Wiederherstellung ist nicht möglich. Übertragen Sie den Wert unmittelbar in einen freigegebenen externen Geheimnismanager und schließen Sie den Einmal-Dialog erst nach bestätigter Verwahrung. Ein Widerruf wirkt sofort und kann für diesen Schlüssel nicht rückgängig gemacht werden: Bestehende Clients verlieren den Zugriff und benötigen einen neu ausgegebenen Zugang. Detail- und Auditansichten zeigen Metadaten, Scopes, Zeitpunkte und das Präfix, aber niemals das Geheimnis.",
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/admin",
|
||||
"screen": "Tenant API keys",
|
||||
"help_contexts": [
|
||||
"access.admin.api-keys",
|
||||
"access.api-keys.action.reload",
|
||||
"access.api-keys.action.create",
|
||||
"access.api-keys.action.inspect",
|
||||
"access.api-keys.action.revoke",
|
||||
"access.api-keys.field.show-revoked",
|
||||
"access.api-keys.field.name",
|
||||
"access.api-keys.field.owner",
|
||||
"access.api-keys.field.expiry",
|
||||
"access.api-keys.field.scopes",
|
||||
"access.api-keys.secret",
|
||||
"access.api-keys.confirm-revoke",
|
||||
],
|
||||
"prerequisites": [
|
||||
"The tenant permits API credentials.",
|
||||
"The actor may create or revoke API keys and may delegate every selected scope.",
|
||||
"An approved external secret manager and accountable owner are known.",
|
||||
],
|
||||
"steps": [
|
||||
"Choose the accountable owner and the narrowest required scopes.",
|
||||
"Set the shortest practical expiry before creating the key.",
|
||||
"Transfer the one-time secret directly into the approved secret manager.",
|
||||
"Revoke the key when its client, owner, or purpose is no longer valid.",
|
||||
],
|
||||
"outcome": "The automation client has a time-bounded credential whose effective access cannot exceed either its stored scopes or its owner's current permissions.",
|
||||
"limitations": [
|
||||
"A one-time secret cannot be displayed or recovered after its creation dialog closes.",
|
||||
"Changing the owner, expiry, or scopes requires a replacement key.",
|
||||
"Revocation does not update external clients; operators must install a replacement where needed.",
|
||||
],
|
||||
"consequences": [
|
||||
"Revocation immediately rejects subsequent requests made with the key.",
|
||||
"Removing permissions from the owner immediately narrows effective key access.",
|
||||
],
|
||||
"verification": "Reload the key directory, verify owner, prefix, scopes, expiry, and status, then test the intended client without copying secret material into evidence.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.workflow.manage-reusable-credentials",
|
||||
title="Manage reusable credentials safely",
|
||||
summary="Reusable credential envelopes keep secrets write-only while administrators constrain which scopes, modules, and servers may use them.",
|
||||
body=(
|
||||
"A reusable credential envelope stores a secret behind the Access boundary and never returns the configured secret through the API. Choose the credential type before entering the secret; changing the type requires a replacement secret. When editing, an empty secret field retains the current value, while Remove configured secret clears it on save and leaves dependent connections unable to authenticate until a replacement is supplied. "
|
||||
"The module and server lists are restrictions: an empty list means every module or server already permitted by the selected scope. Visible to lower scopes makes the envelope selectable from child scopes but does not bypass its module, server, or authorization limits. Deactivating keeps the configuration for review but blocks authentication. Deleting is irreversible in GovOPlaN, cannot recover the secret, and causes every referencing connection to stop authenticating. Review dependent connections and record the external secret-manager owner before clearing or deleting a credential."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("system_admin", "tenant_admin", "access_admin", "operator"),
|
||||
order=32,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access",),
|
||||
any_scopes=(
|
||||
"access:system_credential:read",
|
||||
"access:system_credential:write",
|
||||
"access:credential:read",
|
||||
"access:credential:write",
|
||||
"access:credential:manage_own",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="System credentials", href="/admin?section=system-credentials", kind="runtime"),
|
||||
DocumentationLink(label="Tenant credentials", href="/admin?section=tenant-credentials", kind="runtime"),
|
||||
DocumentationLink(label="Personal credentials", href="/settings", kind="runtime"),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Wiederverwendbare Zugangsdaten sicher verwalten",
|
||||
"summary": "Wiederverwendbare Zugangsdaten geben Geheimnisse nicht wieder aus und begrenzen ihre Nutzung auf freigegebene Ebenen, Module und Server.",
|
||||
"body": "Ein Eintrag für wiederverwendbare Zugangsdaten speichert ein Geheimnis hinter der Access-Sicherheitsgrenze; das konfigurierte Geheimnis wird über die API niemals zurückgegeben. Wählen Sie den Zugangstyp vor der Eingabe. Eine Typänderung erfordert ein neues Geheimnis. Beim Bearbeiten behält ein leeres Geheimnisfeld den vorhandenen Wert. Mit „Konfiguriertes Geheimnis entfernen“ wird er beim Speichern gelöscht; abhängige Verbindungen können sich erst nach Hinterlegung eines Ersatzes wieder anmelden. Die Modul- und Serverlisten sind Einschränkungen: Eine leere Liste erlaubt alle Module beziehungsweise Server, die auf der gewählten Ebene bereits zulässig sind. „Für tiefere Ebenen sichtbar“ macht den Eintrag in Kindebenen auswählbar, umgeht aber weder Modul- und Servergrenzen noch Berechtigungen. Eine Deaktivierung erhält die Konfiguration zur Prüfung, verhindert jedoch die Anmeldung. Das Löschen kann in GovOPlaN nicht rückgängig gemacht werden, stellt das Geheimnis nicht wieder her und unterbricht die Anmeldung aller referenzierenden Verbindungen. Prüfen Sie deshalb vor dem Entfernen oder Löschen die abhängigen Verbindungen und die Zuständigkeit im externen Geheimnismanager.",
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/admin",
|
||||
"screen": "Reusable credentials",
|
||||
"help_contexts": [
|
||||
"access.admin.system-credentials",
|
||||
"access.admin.tenant-credentials",
|
||||
"access.admin.group-credentials",
|
||||
"access.admin.user-credentials",
|
||||
"access.settings.credentials",
|
||||
"access.credentials",
|
||||
"access.credentials.target",
|
||||
"access.credentials.editor",
|
||||
"access.credentials.action.reload",
|
||||
"access.credentials.action.create",
|
||||
"access.credentials.action.edit",
|
||||
"access.credentials.action.save",
|
||||
"access.credentials.action.delete",
|
||||
"access.credentials.field.name",
|
||||
"access.credentials.field.type",
|
||||
"access.credentials.field.description",
|
||||
"access.credentials.field.account-label",
|
||||
"access.credentials.field.secret",
|
||||
"access.credentials.field.clear-secret",
|
||||
"access.credentials.field.allowed-modules",
|
||||
"access.credentials.field.allowed-servers",
|
||||
"access.credentials.field.inherit-to-lower-scopes",
|
||||
"access.credentials.field.active",
|
||||
"access.credentials.confirm-delete",
|
||||
],
|
||||
"prerequisites": [
|
||||
"The intended credential owner is selected.",
|
||||
"The actor may read credentials and has write authority for mutations.",
|
||||
"The external secret-manager owner and dependent connections are known.",
|
||||
],
|
||||
"steps": [
|
||||
"Select the narrowest owning scope and credential type.",
|
||||
"Restrict modules and servers explicitly when broad use is not intended.",
|
||||
"Save a new or replacement secret without expecting it to be displayed again.",
|
||||
"Review dependent connections before deactivation, secret clearing, or deletion.",
|
||||
],
|
||||
"outcome": "The credential remains write-only and is usable only within its active scope, module, server, and authorization boundaries.",
|
||||
"limitations": [
|
||||
"GovOPlaN cannot display or recover a configured secret.",
|
||||
"An empty module or server restriction means every value permitted by scope.",
|
||||
"Deleting or clearing a secret does not rewrite dependent connection references.",
|
||||
],
|
||||
"verification": "Reload the credential list, confirm its scope and availability, then test each intended dependent connection without exposing the secret in evidence.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.workflow.manage-service-account-credentials",
|
||||
title="Manage service accounts and credentials",
|
||||
summary="Create non-login automation principals, set a current scope ceiling, and rotate their one-time credentials without granting human login access.",
|
||||
body=(
|
||||
"Service accounts are tenant-owned automation principals. The account itself has no password or interactive session. Administrators first define its scope ceiling, then create one or more independently revocable credentials. "
|
||||
"A credential secret is disclosed once and only its hash and prefix remain in GovOPlaN. Runtime authorization is always the intersection of the credential scopes and the service account's current ceiling, so lowering the ceiling or deactivating the account takes effect immediately. "
|
||||
"Rotation creates the replacement and revokes the previous credential in one transaction. Retirement disables the backing principal and revokes every active credential. Every credential mutation requires the current service-account revision; a stale browser must reload instead of overwriting a concurrent change."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
order=33,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access",),
|
||||
any_scopes=(
|
||||
"access:service_account:read",
|
||||
"access:service_account:write",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Service accounts", href="/admin?section=tenant-service-accounts", kind="runtime"),
|
||||
DocumentationLink(label="Service accounts API", href="/api/v1/admin/service-accounts", kind="api"),
|
||||
DocumentationLink(label="Credential lifecycle API", href="/api/v1/admin/service-accounts/{service_account_id}/credentials", kind="api"),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Dienstkonten und ihre Zugangsdaten verwalten",
|
||||
"summary": "Erstellen Sie nicht interaktive Automatisierungsidentitäten, begrenzen Sie deren aktuellen Berechtigungsrahmen und rotieren Sie einmal sichtbare Zugangsdaten ohne menschliche Anmeldung.",
|
||||
"body": "Dienstkonten sind mandanteneigene Automatisierungsidentitäten ohne Passwort und ohne interaktive Sitzung. Administrierende legen zuerst den Berechtigungsrahmen des Kontos fest und erstellen danach eine oder mehrere unabhängig widerrufbare Zugangsdaten. Jede Zugangsdaten-Berechtigung muss innerhalb dieses Rahmens liegen. Bei jeder Anfrage wird sie erneut mit dem aktuellen Rahmen geschnitten; eine Verkleinerung des Rahmens oder eine Deaktivierung wirkt deshalb sofort. Das Geheimnis wird nur bei Erstellung oder Rotation einmal angezeigt. GovOPlaN speichert anschließend ausschließlich einen Einweg-Hash und das sichtbare Präfix; das Geheimnis kann weder angezeigt noch wiederhergestellt werden. Eine Rotation erzeugt in einer Transaktion den Ersatz und widerruft die vorherigen Zugangsdaten. Ein Widerruf unterbricht bestehende Clients sofort. Die Deaktivierung stoppt alle Anmeldungen des Dienstkontos, kann aber wieder aufgehoben werden. Das endgültige Stilllegen deaktiviert die zugrunde liegende Identität und widerruft sämtliche aktiven Zugangsdaten. Jede Änderung verwendet die aktuelle Revision des Dienstkontos; bei einem Konflikt muss die Ansicht neu geladen werden, damit keine parallele Änderung überschrieben wird.",
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": [
|
||||
"access.admin.service-accounts",
|
||||
"access.service-accounts.action.reload",
|
||||
"access.service-accounts.action.create",
|
||||
"access.service-accounts.action.manage",
|
||||
"access.service-accounts.action.edit",
|
||||
"access.service-accounts.action.save",
|
||||
"access.service-accounts.action.activation",
|
||||
"access.service-accounts.action.retire",
|
||||
"access.service-accounts.account-editor",
|
||||
"access.service-accounts.field.name",
|
||||
"access.service-accounts.field.description",
|
||||
"access.service-accounts.field.scope-ceiling",
|
||||
"access.service-accounts.field.show-revoked",
|
||||
"access.service-accounts.credential-editor",
|
||||
"access.service-accounts.action.create-credential",
|
||||
"access.service-accounts.action.save-credential",
|
||||
"access.service-accounts.action.rotate-credential",
|
||||
"access.service-accounts.action.revoke-credential",
|
||||
"access.service-accounts.field.credential-name",
|
||||
"access.service-accounts.field.credential-expiry",
|
||||
"access.service-accounts.field.credential-scopes",
|
||||
"access.service-accounts.secret",
|
||||
"access.service-accounts.confirm-revoke-credential",
|
||||
"access.service-accounts.confirm-retire",
|
||||
],
|
||||
"prerequisites": [
|
||||
"The tenant permits API credentials.",
|
||||
"You have service-account write permission and may delegate every selected scope.",
|
||||
],
|
||||
"steps": [
|
||||
"Create a service account and define the narrowest useful scope ceiling.",
|
||||
"Open the account and create a credential with an equal or narrower scope grant.",
|
||||
"Record the one-time secret in an external secret manager.",
|
||||
"Rotate credentials before expiry and revoke credentials that are no longer used.",
|
||||
],
|
||||
"outcome": "The automation principal remains non-interactive and can authenticate only through an active credential whose grant is within the account's current scope ceiling.",
|
||||
"limitations": [
|
||||
"One-time credential secrets cannot be displayed or recovered after the creation dialog closes.",
|
||||
"Deactivation and a reduced scope ceiling affect clients immediately but do not rewrite their external configuration.",
|
||||
"Retirement revokes every active credential and requires a new service account for later reuse.",
|
||||
],
|
||||
"consequences": [
|
||||
"Rotation revokes the previous credential in the same transaction that creates its replacement.",
|
||||
"Credential revocation, account deactivation, and retirement immediately reject affected client requests.",
|
||||
"A stale revision is rejected so a concurrent administration change is not overwritten.",
|
||||
],
|
||||
"verification": "The administration table shows the expected active credential count, last-use timestamp, revision, and audit events without exposing secret material.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.reference.personal-navigation",
|
||||
title="Personalize the side rail",
|
||||
summary="Users can reorder or hide available navigation entries without changing access or other users' workspaces.",
|
||||
body=(
|
||||
"Open Settings and use the workspace navigation editor to move or show available entries. Personal order and visibility take precedence over tenant and system preferences. Entries locked by a system or tenant administrator remain visible, and a user cannot create a lock. Choosing the inherited order removes the personal layer. Module entitlement, View policy, and permissions continue to decide which destinations are available, so changing navigation never grants access."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("user", "admin"),
|
||||
audience=("user", "tenant_admin", "system_admin"),
|
||||
order=85,
|
||||
related_modules=("admin", "tenancy", "views"),
|
||||
links=(
|
||||
DocumentationLink(label="Workspace settings", href="/settings?section=workspace", kind="runtime"),
|
||||
),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": ["core.settings.workspace"],
|
||||
"outcome": "The user's side rail reflects the personal preference while locked and inaccessible entries remain governed by higher-level policy.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.workflow.manage-sessions",
|
||||
title="Review and revoke account sessions",
|
||||
summary="Inspect active browser sessions and revoke one or every other session without exposing credentials or network identifiers.",
|
||||
body=(
|
||||
"Settings > Sessions and devices marks the current browser session and shows only bounded client metadata plus creation, last-seen, and expiry times. "
|
||||
"Users can revoke another session or all other active sessions; the command session is protected and normal logout remains the way to end it. Revocation is idempotent and takes effect on the next authenticated request. "
|
||||
"Tenant administrators can inspect only sessions belonging to a membership in their governed tenant. Administrative revocation requires central membership-update permission and current-password re-authorization from an interactive session. "
|
||||
"Audit evidence records stable actors, targets, and counts without tokens, hashes, cookies, IP addresses, or client strings."
|
||||
),
|
||||
layer="always",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "tenant_admin", "access_admin", "operator"),
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access",),
|
||||
any_scopes=("access:session:manage_own",),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Sessions and devices", href="/settings?section=sessions", kind="runtime"),
|
||||
DocumentationLink(label="Own sessions API", href="/api/v1/auth/sessions", kind="api"),
|
||||
DocumentationLink(label="Session management reference", href="docs/SESSION_MANAGEMENT.md", kind="repository"),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Kontositzungen prüfen und widerrufen",
|
||||
"summary": "Aktive Browsersitzungen prüfen und einzelne oder alle anderen Sitzungen widerrufen, ohne Zugangsdaten oder Netzwerkkennungen offenzulegen.",
|
||||
"body": (
|
||||
"Einstellungen > Sitzungen und Geräte kennzeichnet die aktuelle Browsersitzung und zeigt nur begrenzte Clientmetadaten sowie Erstellungs-, Aktivitäts- und Ablaufzeitpunkte. "
|
||||
"Benutzende können eine andere oder alle anderen aktiven Sitzungen widerrufen; die ausführende Sitzung bleibt geschützt und wird regulär abgemeldet. Der Widerruf ist idempotent und gilt beim nächsten authentifizierten Aufruf. "
|
||||
"Mandantenadministrierende sehen nur Sitzungen einer Mitgliedschaft im verwalteten Mandanten. Der administrative Widerruf erfordert die zentrale Berechtigung zur Mitgliedschaftsänderung und eine erneute Passwortbestätigung in einer interaktiven Sitzung. "
|
||||
"Auditnachweise speichern stabile Akteure, Ziele und Anzahlen, aber keine Token, Hashes, Cookies, IP-Adressen oder Clienttexte."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": [
|
||||
"access.settings.sessions",
|
||||
"access.sessions.action.revoke",
|
||||
"access.sessions.action.revoke-others",
|
||||
"access.admin.user-sessions",
|
||||
],
|
||||
"api_paths": [
|
||||
"/api/v1/auth/sessions",
|
||||
"/api/v1/auth/sessions/{session_id}/revoke",
|
||||
"/api/v1/auth/sessions/revoke-others",
|
||||
"/api/v1/admin/users/{user_id}/sessions",
|
||||
"/api/v1/admin/users/{user_id}/sessions/{session_id}/revoke",
|
||||
],
|
||||
"sensitive_fields_never_returned": [
|
||||
"token",
|
||||
"token_hash",
|
||||
"csrf_token_hash",
|
||||
"cookie",
|
||||
"ip_address",
|
||||
],
|
||||
},
|
||||
order=33,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.reference.external-function-role-mappings",
|
||||
title="Organization function facts and access roles",
|
||||
@@ -412,13 +964,14 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
"Organizations owns the organization meta-model, concrete units, structures, and functions. IDM owns the fact that an identity, through one of its accounts, holds a function in an organization unit, including delegated and acting-for assignments. "
|
||||
"Access consumes those accepted IDM facts through the directory capability, validates function identifiers against Organizations, and turns them into rights only when an explicit external function role mapping connects the organization function ID to an assignable tenant role. "
|
||||
"The assignment itself does not grant rights. Removing the IDM assignment, disabling the Organizations function, or removing the Access mapping stops the derived role source from contributing effective permissions. "
|
||||
"A delegated assignment contributes under the delegate's own account. An acting-for assignment contributes only after an interactive session selects that exact current assignment; Access retains both the real and represented account, audits context changes, and rejects stale or mismatched selections. "
|
||||
"Tenant administrators inspect this from the user access explanation dialog: role sources link back to the Organizations function or unit that defines the fact and to the IDM assignment that produced it. "
|
||||
"The same explanation is exposed by the admin API, while mapping management remains under Admin > Function role mappings. This keeps the audit trail clear: Organizations records what can exist, IDM records who holds it, and Access records which accepted facts produce permissions."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
order=32,
|
||||
order=34,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access", "organizations"),
|
||||
@@ -431,13 +984,19 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
DocumentationLink(label="Effective user access explanation API", href="/api/v1/admin/users/{user_id}/access-explanation", kind="api"),
|
||||
DocumentationLink(label="Organizations functions", href="/organizations?section=functions", kind="runtime"),
|
||||
DocumentationLink(label="IDM assignments", href="/idm", kind="runtime"),
|
||||
DocumentationLink(label="Acting contexts API", href="/api/v1/auth/acting-contexts", kind="api"),
|
||||
),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
"access.admin.function-mappings",
|
||||
"access.explanation",
|
||||
],
|
||||
"route": "/admin",
|
||||
"api_path": "/api/v1/admin/external-function-role-mappings",
|
||||
"explanation_api_path": "/api/v1/admin/users/{user_id}/access-explanation",
|
||||
"runtime_routes": ["/admin?section=tenant-function-role-mappings", "/organizations?section=functions", "/idm"],
|
||||
"acting_context_api_paths": ["/api/v1/auth/acting-contexts", "/api/v1/auth/switch-acting-context"],
|
||||
"permission_scopes": ["access:function:write", "access:role:assign"],
|
||||
"responsibility_boundaries": {
|
||||
"organizations": "Defines organization units, structures, function types, and functions.",
|
||||
@@ -450,6 +1009,56 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.workflow.data-subject-request",
|
||||
title="Process a data-subject request",
|
||||
summary="Privacy officers search provider-owned data, export a coverage manifest, and execute only reviewed erasure actions while retaining required institutional evidence.",
|
||||
body=(
|
||||
"Create a request with at least one stable subject selector, run the cross-module search, and inspect provider coverage before treating the result as complete. "
|
||||
"For erasure requests, generate a plan and review every provider-owned action. Immutable role, function, and audit evidence remains present with its retention reason; global accounts and identities require system-level review because they may serve more than one tenant. "
|
||||
"Execution requires the dedicated erasure permission, the current resource revision, selected executable actions, and an exact confirmation phrase. Access anonymizes the tenant membership and revokes active API keys and sessions without deleting stable evidence identifiers."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("privacy_officer", "tenant_owner", "operator"),
|
||||
order=35,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access", "admin"),
|
||||
any_scopes=(
|
||||
"access:privacy:read",
|
||||
"access:privacy:manage",
|
||||
"access:privacy:export",
|
||||
"access:privacy:erase",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Data-subject requests", href="/admin?section=tenant-data-subject-requests", kind="runtime"),
|
||||
DocumentationLink(label="Data-subject request API", href="/api/v1/admin/privacy/data-subject-requests", kind="api"),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Betroffenenanfrage bearbeiten",
|
||||
"summary": "Datenschutzbeauftragte suchen modulspezifische Daten, exportieren einen Abdeckungsnachweis und führen nur geprüfte Löschaktionen aus; erforderliche institutionelle Nachweise bleiben erhalten.",
|
||||
"body": "Legen Sie eine Anfrage mit mindestens einem stabilen Merkmal der betroffenen Person an, führen Sie die modulübergreifende Suche aus und prüfen Sie die Anbieterabdeckung. Erstellen Sie bei Löschanfragen anschließend einen Plan und prüfen Sie jede Aktion. Unveränderliche Rollen-, Funktions- und Auditnachweise bleiben mit Begründung erhalten. Die Ausführung erfordert ein eigenes Recht, die aktuelle Revision, ausgewählte Aktionen und die exakte Bestätigung.",
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": ["admin.privacy.data-subject-requests"],
|
||||
"permission_scopes": [
|
||||
"access:privacy:read",
|
||||
"access:privacy:manage",
|
||||
"access:privacy:export",
|
||||
"access:privacy:erase",
|
||||
],
|
||||
"limitations": [
|
||||
"Modules without a DSAR provider are reported as coverage gaps.",
|
||||
"Global accounts and identities are not erased automatically.",
|
||||
],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -477,6 +1086,18 @@ def _api_principal_provider(context: ModuleContext) -> object:
|
||||
return AccessApiPrincipalProvider()
|
||||
|
||||
|
||||
def _automation_principal_provider(context: ModuleContext) -> object:
|
||||
from govoplan_access.backend.auth.dependencies import (
|
||||
AccessAutomationPrincipalProvider,
|
||||
)
|
||||
|
||||
return AccessAutomationPrincipalProvider(
|
||||
idm_directory=_optional_idm_directory(context),
|
||||
identity_directory=_optional_identity_directory(context),
|
||||
organization_directory=_optional_organization_directory(context),
|
||||
)
|
||||
|
||||
|
||||
def _tenant_context_switcher(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
|
||||
@@ -504,6 +1125,15 @@ def _access_semantic_directory(context: ModuleContext) -> object:
|
||||
)
|
||||
|
||||
|
||||
def _access_reference_options(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.reference_options import (
|
||||
SqlAccessReferenceOptionProvider,
|
||||
)
|
||||
|
||||
return SqlAccessReferenceOptionProvider()
|
||||
|
||||
|
||||
def _optional_identity_directory(context: ModuleContext) -> IdentityDirectory | None:
|
||||
if not context.registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
|
||||
return None
|
||||
@@ -560,6 +1190,13 @@ def _tenant_provisioner(context: ModuleContext) -> object:
|
||||
return LegacyTenantAccessProvisioner()
|
||||
|
||||
|
||||
def _first_admin_provisioner(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.tenancy.provisioning import LegacyFirstAdminProvisioner
|
||||
|
||||
return LegacyFirstAdminProvisioner()
|
||||
|
||||
|
||||
def _access_administration(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.administration import SqlAccessAdministration
|
||||
@@ -581,6 +1218,13 @@ def _configuration_provider(context: ModuleContext) -> object:
|
||||
return SqlAccessConfigurationProvider()
|
||||
|
||||
|
||||
def _dsar_provider(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.dsar_provider import AccessDsarProvider
|
||||
|
||||
return AccessDsarProvider()
|
||||
|
||||
|
||||
def _route_factory(context: ModuleContext):
|
||||
from fastapi import APIRouter
|
||||
|
||||
@@ -590,18 +1234,40 @@ def _route_factory(context: ModuleContext):
|
||||
|
||||
from govoplan_access.backend.api.v1.auth import router as auth_router
|
||||
from govoplan_access.backend.api.v1.routes import router as access_admin_router
|
||||
from govoplan_access.backend.api.v1.service_accounts import (
|
||||
router as service_account_router,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(auth_router)
|
||||
router.include_router(access_admin_router)
|
||||
router.include_router(service_account_router)
|
||||
return router
|
||||
|
||||
|
||||
def _people_search(context: ModuleContext) -> object:
|
||||
from govoplan_access.backend.people_search import people_search_capability
|
||||
|
||||
return people_search_capability(context)
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="access",
|
||||
name="Access",
|
||||
version="0.1.8",
|
||||
version="0.1.19",
|
||||
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
|
||||
ModuleInterfaceProvider(
|
||||
name=CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||
version="0.1.0",
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name="auth.automation_principal",
|
||||
version="0.2.0",
|
||||
),
|
||||
ModuleInterfaceProvider(name=ACCESS_DSAR_CAPABILITY, version="0.1.0"),
|
||||
),
|
||||
permissions=ACCESS_PERMISSIONS,
|
||||
role_templates=ACCESS_ROLE_TEMPLATES,
|
||||
route_factory=_route_factory,
|
||||
@@ -618,6 +1284,7 @@ manifest = ModuleManifest(
|
||||
access_models.User,
|
||||
access_models.Group,
|
||||
access_models.Role,
|
||||
access_models.ServiceAccount,
|
||||
access_models.OrganizationUnit,
|
||||
access_models.Function,
|
||||
access_models.FunctionRoleAssignment,
|
||||
@@ -639,9 +1306,28 @@ manifest = ModuleManifest(
|
||||
package_name="@govoplan/access-webui",
|
||||
routes=(FrontendRoute(path="/admin", component="AdminPage", required_any=ADMIN_READ_SCOPES, order=900),),
|
||||
nav_items=(NavItem(path="/admin", label="Admin", icon="admin", required_any=ADMIN_READ_SCOPES, order=900),),
|
||||
view_surfaces=(
|
||||
ViewSurface(id="access.admin.system-roles", module_id="access", kind="section", label="System roles", order=20),
|
||||
ViewSurface(id="access.admin.system-users", module_id="access", kind="section", label="System users", order=50),
|
||||
ViewSurface(id="access.admin.system-credentials", module_id="access", kind="section", label="System credentials", order=80),
|
||||
ViewSurface(id="access.admin.tenant-roles", module_id="access", kind="section", label="Tenant roles", order=10),
|
||||
ViewSurface(id="access.admin.tenant-function-mappings", module_id="access", kind="section", label="Function mappings", order=20),
|
||||
ViewSurface(id="access.admin.tenant-groups", module_id="access", kind="section", label="Tenant groups", order=30),
|
||||
ViewSurface(id="access.admin.tenant-users", module_id="access", kind="section", label="Tenant users", order=40),
|
||||
ViewSurface(id="access.admin.tenant-credentials", module_id="access", kind="section", label="Tenant credentials", order=70),
|
||||
ViewSurface(id="access.admin.tenant-api-keys", module_id="access", kind="section", label="Tenant API keys", order=80),
|
||||
ViewSurface(id="access.admin.tenant-service-accounts", module_id="access", kind="section", label="Service accounts", order=90),
|
||||
ViewSurface(id="access.admin.group-credentials", module_id="access", kind="section", label="Group credentials", order=30),
|
||||
ViewSurface(id="access.admin.user-credentials", module_id="access", kind="section", label="User credentials", order=30),
|
||||
ViewSurface(id="access.settings.credentials", module_id="access", kind="section", label="Personal credentials", order=30),
|
||||
ViewSurface(id="access.settings.sessions", module_id="access", kind="section", label="Sessions and devices", order=20),
|
||||
),
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER: _api_principal_provider,
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER: (
|
||||
_automation_principal_provider
|
||||
),
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER: _legacy_principal_resolver,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR: _legacy_permission_evaluator,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: _tenant_context_switcher,
|
||||
@@ -651,11 +1337,38 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY: _access_semantic_directory,
|
||||
CAPABILITY_ACCESS_EXPLANATION: _access_explanation_service,
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER: _tenant_provisioner,
|
||||
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER: _first_admin_provisioner,
|
||||
CAPABILITY_ACCESS_ADMINISTRATION: _access_administration,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1: _governance_materializer,
|
||||
CAPABILITY_ACCESS_PEOPLE_SEARCH: _people_search,
|
||||
CAPABILITY_ACCESS_REFERENCE_OPTIONS: _access_reference_options,
|
||||
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||
ACCESS_DSAR_CAPABILITY: _dsar_provider,
|
||||
},
|
||||
capability_documentation={
|
||||
ACCESS_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Access data-subject request provider",
|
||||
summary=(
|
||||
"Exports exact account, membership, group, role, session, API-key "
|
||||
"metadata, and attributable governance records without credential material."
|
||||
),
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
documentation=ACCESS_DOCUMENTATION,
|
||||
architecture=declared_module_architecture(
|
||||
layer="institutional_foundation",
|
||||
kind="foundation",
|
||||
maturity="vertical_slice",
|
||||
documentation_ref="docs/ACCESS_MODULE_BOUNDARY.md",
|
||||
test_ref="tests/test_login_security.py",
|
||||
known_limits=("Recovery and upgrade evidence is not yet complete enough for supported maturity.",),
|
||||
owned_concepts=("account authentication", "application role", "permission evaluation", "service account"),
|
||||
non_owned_concepts=("person identity", "organization structure", "function incumbency", "policy definition"),
|
||||
security_docs=("docs/ACCESS_MODULE_BOUNDARY.md",),
|
||||
operations_docs=("README.md",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""managed automation service accounts
|
||||
|
||||
Revision ID: b6d9f2a5c8e1
|
||||
Revises: 4a5b6c7d8e9f
|
||||
Create Date: 2026-07-29 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "b6d9f2a5c8e1"
|
||||
down_revision = "4a5b6c7d8e9f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if (
|
||||
"access_service_accounts"
|
||||
in sa.inspect(op.get_bind()).get_table_names()
|
||||
):
|
||||
return
|
||||
op.create_table(
|
||||
"access_service_accounts",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("membership_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("normalized_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("scope_ceiling", sa.JSON(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"created_by_account_id",
|
||||
sa.String(length=36),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_by_account_id",
|
||||
sa.String(length=36),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_account_id_access_accounts"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["created_by_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_created_by_account_id_"
|
||||
"access_accounts"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["membership_id"],
|
||||
["access_users.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_membership_id_access_users"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_tenant_id_core_scopes"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["updated_by_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_updated_by_account_id_"
|
||||
"access_accounts"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_access_service_accounts"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"account_id",
|
||||
name="uq_access_service_accounts_account",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"membership_id",
|
||||
name="uq_access_service_accounts_membership",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"normalized_name",
|
||||
name="uq_access_service_accounts_tenant_name",
|
||||
),
|
||||
)
|
||||
for name, columns in (
|
||||
(
|
||||
"ix_access_service_accounts_account_id",
|
||||
["account_id"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_is_active",
|
||||
["is_active"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_membership_id",
|
||||
["membership_id"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_retired_at",
|
||||
["retired_at"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_tenant_id",
|
||||
["tenant_id"],
|
||||
),
|
||||
):
|
||||
op.create_index(name, "access_service_accounts", columns)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if (
|
||||
"access_service_accounts"
|
||||
in sa.inspect(op.get_bind()).get_table_names()
|
||||
):
|
||||
op.drop_table("access_service_accounts")
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
"""managed automation service accounts
|
||||
|
||||
Revision ID: b6d9f2a5c8e1
|
||||
Revises: 4a5b6c7d8e9f
|
||||
Create Date: 2026-07-29 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "b6d9f2a5c8e1"
|
||||
down_revision = "4a5b6c7d8e9f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if (
|
||||
"access_service_accounts"
|
||||
in sa.inspect(op.get_bind()).get_table_names()
|
||||
):
|
||||
return
|
||||
op.create_table(
|
||||
"access_service_accounts",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("membership_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("normalized_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("scope_ceiling", sa.JSON(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"created_by_account_id",
|
||||
sa.String(length=36),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_by_account_id",
|
||||
sa.String(length=36),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_account_id_access_accounts"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["created_by_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_created_by_account_id_"
|
||||
"access_accounts"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["membership_id"],
|
||||
["access_users.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_membership_id_access_users"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_tenant_id_core_scopes"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["updated_by_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f(
|
||||
"fk_access_service_accounts_updated_by_account_id_"
|
||||
"access_accounts"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_access_service_accounts"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"account_id",
|
||||
name="uq_access_service_accounts_account",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"membership_id",
|
||||
name="uq_access_service_accounts_membership",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"normalized_name",
|
||||
name="uq_access_service_accounts_tenant_name",
|
||||
),
|
||||
)
|
||||
for name, columns in (
|
||||
(
|
||||
"ix_access_service_accounts_account_id",
|
||||
["account_id"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_is_active",
|
||||
["is_active"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_membership_id",
|
||||
["membership_id"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_retired_at",
|
||||
["retired_at"],
|
||||
),
|
||||
(
|
||||
"ix_access_service_accounts_tenant_id",
|
||||
["tenant_id"],
|
||||
),
|
||||
):
|
||||
op.create_index(name, "access_service_accounts", columns)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if (
|
||||
"access_service_accounts"
|
||||
in sa.inspect(op.get_bind()).get_table_names()
|
||||
):
|
||||
op.drop_table("access_service_accounts")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Persist explicit interactive acting-in-place context.
|
||||
|
||||
Revision ID: c7e0a3d6f9b2
|
||||
Revises: b6d9f2a5c8e1
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c7e0a3d6f9b2"
|
||||
down_revision = "b6d9f2a5c8e1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"access_auth_sessions",
|
||||
sa.Column("acting_assignment_id", sa.String(length=36), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"access_auth_sessions",
|
||||
sa.Column("acting_for_account_id", sa.String(length=36), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_access_auth_sessions_acting_assignment_id"),
|
||||
"access_auth_sessions",
|
||||
["acting_assignment_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_access_auth_sessions_acting_assignment_id"),
|
||||
table_name="access_auth_sessions",
|
||||
)
|
||||
op.drop_column("access_auth_sessions", "acting_for_account_id")
|
||||
op.drop_column("access_auth_sessions", "acting_assignment_id")
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, User
|
||||
from govoplan_core.core.people import (
|
||||
PeopleSearchError,
|
||||
PeopleSearchGroup,
|
||||
PersonSearchCandidate,
|
||||
person_selection_key,
|
||||
)
|
||||
|
||||
|
||||
def _principal_tenant_id(principal: object) -> str:
|
||||
try:
|
||||
tenant_id = getattr(principal, "tenant_id")
|
||||
except (AttributeError, RuntimeError) as exc:
|
||||
raise PeopleSearchError("People search requires an active tenant context.") from exc
|
||||
normalized = str(tenant_id or "").strip()
|
||||
if not normalized:
|
||||
raise PeopleSearchError("People search requires an active tenant context.")
|
||||
return normalized
|
||||
|
||||
|
||||
def _like_pattern(query: str) -> str:
|
||||
escaped = query.casefold().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
return f"%{escaped}%"
|
||||
|
||||
|
||||
class AccessPeopleSearchProvider:
|
||||
"""Search active accounts through their active tenant membership.
|
||||
|
||||
The active principal's tenant is the only accepted visibility boundary;
|
||||
global accounts and memberships of other tenants are never candidates.
|
||||
Feature routers authorize the surrounding task before invoking this
|
||||
capability.
|
||||
"""
|
||||
|
||||
def search_people(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str,
|
||||
limit: int = 25,
|
||||
) -> tuple[PeopleSearchGroup, ...]:
|
||||
if not isinstance(session, Session):
|
||||
raise PeopleSearchError("People search requires a database session.")
|
||||
tenant_id = _principal_tenant_id(principal)
|
||||
normalized_limit = max(1, min(int(limit), 100))
|
||||
account_query = (
|
||||
session.query(User, Account)
|
||||
.join(Account, Account.id == User.account_id)
|
||||
.filter(
|
||||
User.tenant_id == tenant_id,
|
||||
User.is_active.is_(True),
|
||||
Account.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
normalized_query = str(query or "").strip()
|
||||
if normalized_query:
|
||||
pattern = _like_pattern(normalized_query)
|
||||
account_query = account_query.filter(
|
||||
or_(
|
||||
func.lower(User.display_name).like(pattern, escape="\\"),
|
||||
func.lower(User.email).like(pattern, escape="\\"),
|
||||
func.lower(Account.display_name).like(pattern, escape="\\"),
|
||||
func.lower(Account.email).like(pattern, escape="\\"),
|
||||
)
|
||||
)
|
||||
|
||||
rows = (
|
||||
account_query
|
||||
.order_by(
|
||||
func.coalesce(User.display_name, Account.display_name, User.email, Account.email).asc(),
|
||||
Account.id.asc(),
|
||||
)
|
||||
.limit(normalized_limit)
|
||||
.all()
|
||||
)
|
||||
candidates = tuple(
|
||||
PersonSearchCandidate(
|
||||
selection_key=person_selection_key("account", account.id),
|
||||
kind="account",
|
||||
reference_id=account.id,
|
||||
display_name=user.display_name or account.display_name or user.email or account.email,
|
||||
email=user.email or account.email,
|
||||
source_module="access",
|
||||
source_label="Accounts",
|
||||
source_ref=f"access:account:{account.id}",
|
||||
)
|
||||
for user, account in rows
|
||||
)
|
||||
return (PeopleSearchGroup(key="accounts", label="Accounts", candidates=candidates),)
|
||||
|
||||
|
||||
def people_search_capability(_context: object) -> AccessPeopleSearchProvider:
|
||||
return AccessPeopleSearchProvider()
|
||||
|
||||
|
||||
__all__ = ["AccessPeopleSearchProvider", "people_search_capability"]
|
||||
@@ -3,71 +3,26 @@ from __future__ import annotations
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
from govoplan_access.backend.permissions.evaluator import scope_grants as _scope_grants
|
||||
from govoplan_access.backend.permissions.evaluator import scopes_grant as _scopes_grant
|
||||
from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate
|
||||
from govoplan_core.security.module_permissions import compatible_required_scopes
|
||||
from govoplan_core.security.permissions import ALL_PERMISSIONS as CORE_LEGACY_PERMISSION_DEFINITIONS
|
||||
from govoplan_core.security.permissions import PermissionDefinition as CoreLegacyPermissionDefinition
|
||||
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
|
||||
|
||||
|
||||
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
|
||||
"campaign:write": frozenset({
|
||||
"campaign:create",
|
||||
"campaign:update",
|
||||
"campaign:copy",
|
||||
"campaign:archive",
|
||||
"campaign:delete",
|
||||
"campaign:share",
|
||||
"recipients:read",
|
||||
"recipients:write",
|
||||
"recipients:import",
|
||||
}),
|
||||
"attachments:read": frozenset({"files:read", "files:download"}),
|
||||
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
|
||||
"admin:users": frozenset({
|
||||
"admin:users:read",
|
||||
"admin:users:create",
|
||||
"admin:users:update",
|
||||
"admin:users:suspend",
|
||||
"admin:groups:read",
|
||||
"admin:groups:write",
|
||||
"admin:groups:manage_members",
|
||||
"admin:roles:read",
|
||||
"admin:roles:write",
|
||||
"admin:roles:assign",
|
||||
}),
|
||||
"admin:users:write": frozenset({
|
||||
"admin:users:create",
|
||||
"admin:users:update",
|
||||
"admin:users:suspend",
|
||||
"admin:roles:assign",
|
||||
"admin:groups:manage_members",
|
||||
}),
|
||||
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
|
||||
"admin:settings": frozenset({
|
||||
"admin:settings:read",
|
||||
"admin:settings:write",
|
||||
"admin:api_keys:read",
|
||||
"admin:api_keys:create",
|
||||
"admin:api_keys:revoke",
|
||||
}),
|
||||
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
|
||||
"system:access:write": frozenset({
|
||||
"system:access:assign",
|
||||
"system:roles:assign",
|
||||
"system:accounts:create",
|
||||
"system:accounts:update",
|
||||
"system:accounts:suspend",
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str, category: str, level: PermissionLevel) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
def _legacy_permission(permission: CoreLegacyPermissionDefinition) -> PermissionDefinition:
|
||||
parts = permission.scope.split(":", 2)
|
||||
if len(parts) == 2:
|
||||
module_id, action = parts
|
||||
resource = module_id
|
||||
else:
|
||||
module_id, resource, action = parts
|
||||
return PermissionDefinition(
|
||||
scope=scope,
|
||||
label=label,
|
||||
description=description,
|
||||
category=category,
|
||||
level=level,
|
||||
scope=permission.scope,
|
||||
label=permission.label,
|
||||
description=permission.description,
|
||||
category=permission.category,
|
||||
level=permission.level, # type: ignore[arg-type]
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
action=action,
|
||||
@@ -75,43 +30,9 @@ def _permission(scope: str, label: str, description: str, category: str, level:
|
||||
)
|
||||
|
||||
|
||||
LEGACY_PERMISSION_DEFINITIONS: tuple[PermissionDefinition, ...] = (
|
||||
_permission("admin:users:read", "View users", "List tenant users and their effective access.", "Tenant administration", "tenant"),
|
||||
_permission("admin:users:create", "Create users", "Create tenant memberships for accounts.", "Tenant administration", "tenant"),
|
||||
_permission("admin:users:update", "Update users", "Edit non-access membership metadata.", "Tenant administration", "tenant"),
|
||||
_permission("admin:users:suspend", "Suspend users", "Activate or suspend tenant memberships.", "Tenant administration", "tenant"),
|
||||
_permission("admin:groups:read", "View groups", "List tenant groups, members and assigned roles.", "Tenant administration", "tenant"),
|
||||
_permission("admin:groups:write", "Manage groups", "Create or edit tenant group definitions.", "Tenant administration", "tenant"),
|
||||
_permission("admin:groups:manage_members", "Manage group members", "Add and remove users from tenant groups.", "Tenant administration", "tenant"),
|
||||
_permission("admin:roles:read", "View roles", "Inspect role definitions and the permission catalogue.", "Tenant administration", "tenant"),
|
||||
_permission("admin:roles:write", "Define roles", "Create and edit assignable tenant role definitions.", "Tenant administration", "tenant"),
|
||||
_permission("admin:roles:assign", "Assign roles", "Assign tenant roles to users or groups within delegation limits.", "Tenant administration", "tenant"),
|
||||
_permission("admin:api_keys:read", "View API keys", "List tenant API keys without revealing their secret.", "Tenant administration", "tenant"),
|
||||
_permission("admin:api_keys:create", "Create API keys", "Create tenant-local API keys within the owner's delegation limits.", "Tenant administration", "tenant"),
|
||||
_permission("admin:api_keys:revoke", "Revoke API keys", "Revoke tenant-local API keys.", "Tenant administration", "tenant"),
|
||||
_permission("admin:settings:read", "View tenant settings", "Read tenant defaults and non-policy settings.", "Tenant administration", "tenant"),
|
||||
_permission("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration", "tenant"),
|
||||
_permission("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration", "tenant"),
|
||||
_permission("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration", "tenant"),
|
||||
_permission("system:tenants:read", "View all tenants", "List tenants and system-wide membership counts.", "System administration", "system"),
|
||||
_permission("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"),
|
||||
_permission("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"),
|
||||
_permission("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"),
|
||||
_permission("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"),
|
||||
_permission("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
|
||||
_permission("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"),
|
||||
_permission("system:accounts:suspend", "Suspend accounts", "Activate or suspend global login accounts while preserving a system owner.", "System administration", "system"),
|
||||
_permission("system:roles:read", "View system roles", "Inspect instance-wide role definitions and their permissions.", "System administration", "system"),
|
||||
_permission("system:roles:write", "Define system roles", "Create and edit instance-wide role definitions within delegation limits.", "System administration", "system"),
|
||||
_permission("system:roles:assign", "Assign system roles", "Assign instance-wide roles to accounts while preserving a system owner.", "System administration", "system"),
|
||||
_permission("system:access:read", "View system access", "Compatibility scope for listing accounts with instance-wide roles.", "System administration", "system"),
|
||||
_permission("system:access:assign", "Assign system access", "Compatibility scope for assigning instance-wide roles.", "System administration", "system"),
|
||||
_permission("system:audit:read", "View system audit", "Read audit records across tenants.", "System administration", "system"),
|
||||
_permission("system:settings:read", "View system settings", "Read instance defaults and tenant-governance defaults.", "System administration", "system"),
|
||||
_permission("system:settings:write", "Manage system settings", "Change instance defaults and tenant-governance defaults.", "System administration", "system"),
|
||||
_permission("system:maintenance:access", "Access during maintenance", "Use the system while maintenance mode is active.", "System administration", "system"),
|
||||
_permission("system:governance:read", "View governance templates", "Inspect centrally managed group and role definitions.", "System administration", "system"),
|
||||
_permission("system:governance:write", "Manage governance templates", "Create and assign centrally managed group and role definitions.", "System administration", "system"),
|
||||
LEGACY_PERMISSION_DEFINITIONS: tuple[PermissionDefinition, ...] = tuple(
|
||||
_legacy_permission(permission)
|
||||
for permission in CORE_LEGACY_PERMISSION_DEFINITIONS
|
||||
)
|
||||
|
||||
|
||||
@@ -146,12 +67,12 @@ def role_templates_for_level(level: PermissionLevel) -> tuple[RoleTemplate, ...]
|
||||
return tuple(template for template in role_templates() if template.level == level)
|
||||
|
||||
|
||||
def scope_grants(granted: str, required: str) -> bool:
|
||||
catalog = permission_map(include_legacy=True)
|
||||
def scope_grants(granted: str, required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
|
||||
catalog = catalog if catalog is not None else permission_map(include_legacy=True)
|
||||
if _scope_grants(granted, required, catalog=catalog):
|
||||
return True
|
||||
for alias in LEGACY_SCOPE_ALIASES.get(granted, frozenset()):
|
||||
if scope_grants(alias, required):
|
||||
if scope_grants(alias, required, catalog=catalog):
|
||||
return True
|
||||
return any(
|
||||
_scope_grants(granted, alias, catalog=catalog)
|
||||
@@ -160,8 +81,9 @@ def scope_grants(granted: str, required: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def scopes_grant(scopes: Iterable[str], required: str) -> bool:
|
||||
return any(scope_grants(scope, required) for scope in scopes)
|
||||
def scopes_grant(scopes: Iterable[str], required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
|
||||
catalog = catalog if catalog is not None else permission_map(include_legacy=True)
|
||||
return any(scope_grants(scope, required, catalog=catalog) for scope in scopes)
|
||||
|
||||
|
||||
def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> list[str]:
|
||||
@@ -173,12 +95,17 @@ def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> lis
|
||||
expanded.add(scope)
|
||||
for alias in LEGACY_SCOPE_ALIASES.get(scope, frozenset()):
|
||||
expanded.add(alias)
|
||||
matched = {candidate for candidate in catalog if scope_grants(scope, candidate)}
|
||||
matched = {candidate for candidate in catalog if scope_grants(scope, candidate, catalog=catalog)}
|
||||
expanded.update(matched)
|
||||
for alias in compatible_required_scopes(scope):
|
||||
if alias != scope:
|
||||
expanded.add(alias)
|
||||
if include_unknown and not matched:
|
||||
# Preserve explicitly granted scopes in the presentation set. A
|
||||
# canonical module scope can still match its legacy compatibility
|
||||
# alias when the providing module is not loaded; treating that match
|
||||
# as proof that the original scope is known would otherwise replace
|
||||
# the canonical grant with only the deprecated alias.
|
||||
if include_unknown or scope in catalog:
|
||||
expanded.add(scope)
|
||||
return sorted(expanded)
|
||||
|
||||
@@ -186,7 +113,8 @@ def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> lis
|
||||
def effective_permission_scopes(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> set[str]:
|
||||
candidates = _effective_permission_candidates(level=level)
|
||||
granted = list(scopes)
|
||||
return {scope for scope in candidates if scopes_grant(granted, scope)}
|
||||
catalog = permission_map(include_legacy=True)
|
||||
return {scope for scope in candidates if scopes_grant(granted, scope, catalog=catalog)}
|
||||
|
||||
|
||||
def effective_permission_count(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> int:
|
||||
@@ -207,7 +135,11 @@ def _effective_permission_candidates(*, level: PermissionLevel | None = None) ->
|
||||
continue
|
||||
if level is not None and definition.level != level:
|
||||
continue
|
||||
if any(scope_grants(active_scope, scope) or scope_grants(scope, active_scope) for active_scope in active_scopes):
|
||||
if any(
|
||||
scope_grants(active_scope, scope, catalog=full_catalog)
|
||||
or scope_grants(scope, active_scope, catalog=full_catalog)
|
||||
for active_scope in active_scopes
|
||||
):
|
||||
continue
|
||||
candidates.add(scope)
|
||||
return candidates
|
||||
@@ -231,7 +163,7 @@ def validate_permissions(scopes: Iterable[str], *, level: PermissionLevel) -> li
|
||||
expanded.add(alias)
|
||||
continue
|
||||
if scope.endswith(":*"):
|
||||
matching = {candidate for candidate, definition in catalog.items() if definition.level == level and scope_grants(scope, candidate)}
|
||||
matching = {candidate for candidate, definition in catalog.items() if definition.level == level and scope_grants(scope, candidate, catalog=catalog)}
|
||||
if matching:
|
||||
expanded.add(scope)
|
||||
continue
|
||||
@@ -272,8 +204,9 @@ def delegateable_system_scopes(scopes: Iterable[str]) -> set[str]:
|
||||
def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[str]) -> list[str]:
|
||||
user = list(user_scopes)
|
||||
key = list(key_scopes)
|
||||
tenant_scopes = {scope for scope, definition in permission_map(include_legacy=True).items() if definition.level == "tenant"}
|
||||
allowed = {scope for scope in tenant_scopes if scopes_grant(user, scope) and scopes_grant(key, scope)}
|
||||
catalog = permission_map(include_legacy=True)
|
||||
tenant_scopes = {scope for scope, definition in catalog.items() if definition.level == "tenant"}
|
||||
allowed = {scope for scope in tenant_scopes if scopes_grant(user, scope, catalog=catalog) and scopes_grant(key, scope, catalog=catalog)}
|
||||
user_raw = set(expand_scopes(user))
|
||||
key_raw = set(expand_scopes(key))
|
||||
allowed.update(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from redis import Redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_REDIS_INCREMENT_SCRIPT = """
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
if count == 1 then
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
local ttl = redis.call('TTL', KEYS[1])
|
||||
return {count, ttl}
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AttemptBucket:
|
||||
count: int = 0
|
||||
retry_after_seconds: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LoginThrottleDecision:
|
||||
allowed: bool
|
||||
retry_after_seconds: int = 0
|
||||
|
||||
|
||||
class LoginAttemptStore(Protocol):
|
||||
def read(self, key: str) -> AttemptBucket: ...
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket: ...
|
||||
|
||||
def delete(self, key: str) -> None: ...
|
||||
|
||||
|
||||
class InMemoryLoginAttemptStore:
|
||||
"""Bounded process-local fallback for development and Redis outages."""
|
||||
|
||||
def __init__(self, *, max_entries: int = 10_000) -> None:
|
||||
self._entries: dict[str, tuple[int, float]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._max_entries = max(2, max_entries)
|
||||
|
||||
def read(self, key: str) -> AttemptBucket:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
entry = self._active_entry(key, now=now)
|
||||
if entry is None:
|
||||
return AttemptBucket()
|
||||
count, expires_at = entry
|
||||
return AttemptBucket(
|
||||
count=count,
|
||||
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||
)
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
entry = self._active_entry(key, now=now)
|
||||
if entry is None:
|
||||
self._make_room(now=now, incoming_key=key)
|
||||
count = 1
|
||||
expires_at = now + window_seconds
|
||||
else:
|
||||
count = entry[0] + 1
|
||||
expires_at = entry[1]
|
||||
self._entries[key] = (count, expires_at)
|
||||
return AttemptBucket(
|
||||
count=count,
|
||||
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||
)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
with self._lock:
|
||||
self._entries.pop(key, None)
|
||||
|
||||
def _active_entry(self, key: str, *, now: float) -> tuple[int, float] | None:
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if entry[1] <= now:
|
||||
self._entries.pop(key, None)
|
||||
return None
|
||||
return entry
|
||||
|
||||
def _make_room(self, *, now: float, incoming_key: str) -> None:
|
||||
if incoming_key in self._entries or len(self._entries) < self._max_entries:
|
||||
return
|
||||
expired = [key for key, (_, expires_at) in self._entries.items() if expires_at <= now]
|
||||
for key in expired:
|
||||
self._entries.pop(key, None)
|
||||
while len(self._entries) >= self._max_entries:
|
||||
self._entries.pop(next(iter(self._entries)))
|
||||
|
||||
|
||||
class RedisLoginAttemptStore:
|
||||
"""Redis-backed fixed-window counters shared by all API workers."""
|
||||
|
||||
def __init__(self, redis_url: str) -> None:
|
||||
self._client = Redis.from_url(
|
||||
redis_url,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=0.25,
|
||||
socket_timeout=0.25,
|
||||
health_check_interval=30,
|
||||
)
|
||||
|
||||
def read(self, key: str) -> AttemptBucket:
|
||||
pipeline = self._client.pipeline(transaction=False)
|
||||
pipeline.get(key)
|
||||
pipeline.ttl(key)
|
||||
raw_count, raw_ttl = pipeline.execute()
|
||||
count = int(raw_count or 0)
|
||||
ttl = int(raw_ttl or 0)
|
||||
return AttemptBucket(count=count, retry_after_seconds=max(0, ttl))
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket:
|
||||
result = self._client.eval(_REDIS_INCREMENT_SCRIPT, 1, key, window_seconds)
|
||||
if not isinstance(result, (list, tuple)) or len(result) != 2:
|
||||
raise RedisError("Unexpected login throttle response from Redis")
|
||||
return AttemptBucket(
|
||||
count=int(result[0]),
|
||||
retry_after_seconds=max(1, int(result[1])),
|
||||
)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self._client.delete(key)
|
||||
|
||||
|
||||
class ResilientLoginAttemptStore:
|
||||
"""Prefer the distributed store and fail safely to a local bounded store."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
primary: LoginAttemptStore | None,
|
||||
fallback: LoginAttemptStore,
|
||||
*,
|
||||
retry_seconds: int = 30,
|
||||
) -> None:
|
||||
self._primary = primary
|
||||
self._fallback = fallback
|
||||
self._retry_seconds = max(1, retry_seconds)
|
||||
self._primary_unavailable_until = 0.0
|
||||
self._state_lock = threading.Lock()
|
||||
|
||||
def read(self, key: str) -> AttemptBucket:
|
||||
fallback_result = self._fallback.read(key)
|
||||
primary = self._available_primary()
|
||||
if primary is None:
|
||||
return fallback_result
|
||||
try:
|
||||
return _stricter_bucket(primary.read(key), fallback_result)
|
||||
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||
self._mark_primary_unavailable(exc)
|
||||
return fallback_result
|
||||
|
||||
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket:
|
||||
primary = self._available_primary()
|
||||
if primary is not None:
|
||||
fallback_result = self._fallback.increment(key, window_seconds=window_seconds)
|
||||
try:
|
||||
# Mirror the active process's failures so a later Redis outage
|
||||
# or recovery cannot restart its protection window from zero.
|
||||
primary_result = primary.increment(key, window_seconds=window_seconds)
|
||||
return _stricter_bucket(primary_result, fallback_result)
|
||||
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||
self._mark_primary_unavailable(exc)
|
||||
return fallback_result
|
||||
return self._fallback.increment(key, window_seconds=window_seconds)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self._fallback.delete(key)
|
||||
primary = self._available_primary()
|
||||
if primary is None:
|
||||
return
|
||||
try:
|
||||
primary.delete(key)
|
||||
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||
self._mark_primary_unavailable(exc)
|
||||
|
||||
def _available_primary(self) -> LoginAttemptStore | None:
|
||||
if self._primary is None:
|
||||
return None
|
||||
with self._state_lock:
|
||||
if time.monotonic() < self._primary_unavailable_until:
|
||||
return None
|
||||
return self._primary
|
||||
|
||||
def _mark_primary_unavailable(self, exc: Exception) -> None:
|
||||
should_log = False
|
||||
with self._state_lock:
|
||||
now = time.monotonic()
|
||||
if now >= self._primary_unavailable_until:
|
||||
should_log = True
|
||||
self._primary_unavailable_until = now + self._retry_seconds
|
||||
if should_log:
|
||||
logger.warning(
|
||||
"Redis login throttling is unavailable; using the process-local fallback (%s)",
|
||||
type(exc).__name__,
|
||||
)
|
||||
|
||||
|
||||
def _stricter_bucket(first: AttemptBucket, second: AttemptBucket) -> AttemptBucket:
|
||||
return AttemptBucket(
|
||||
count=max(first.count, second.count),
|
||||
retry_after_seconds=max(first.retry_after_seconds, second.retry_after_seconds),
|
||||
)
|
||||
|
||||
|
||||
class LoginThrottle:
|
||||
def __init__(
|
||||
self,
|
||||
store: LoginAttemptStore,
|
||||
*,
|
||||
identity_limit: int,
|
||||
client_limit: int,
|
||||
window_seconds: int,
|
||||
key_prefix: str = "govoplan:access:login:v1",
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._identity_limit = max(1, identity_limit)
|
||||
self._client_limit = max(1, client_limit)
|
||||
self._window_seconds = max(1, window_seconds)
|
||||
self._key_prefix = key_prefix.rstrip(":")
|
||||
|
||||
def check(
|
||||
self,
|
||||
*,
|
||||
normalized_email: str,
|
||||
tenant_slug: str | None,
|
||||
client_address: str | None,
|
||||
) -> LoginThrottleDecision:
|
||||
return self._decision(
|
||||
self._buckets(
|
||||
normalized_email=normalized_email,
|
||||
tenant_slug=tenant_slug,
|
||||
client_address=client_address,
|
||||
),
|
||||
increment=False,
|
||||
)
|
||||
|
||||
def record_failure(
|
||||
self,
|
||||
*,
|
||||
normalized_email: str,
|
||||
tenant_slug: str | None,
|
||||
client_address: str | None,
|
||||
) -> LoginThrottleDecision:
|
||||
return self._decision(
|
||||
self._buckets(
|
||||
normalized_email=normalized_email,
|
||||
tenant_slug=tenant_slug,
|
||||
client_address=client_address,
|
||||
),
|
||||
increment=True,
|
||||
)
|
||||
|
||||
def record_success(
|
||||
self,
|
||||
*,
|
||||
normalized_email: str,
|
||||
tenant_slug: str | None,
|
||||
) -> None:
|
||||
del tenant_slug
|
||||
identity_key, _ = self._keys(
|
||||
normalized_email=normalized_email,
|
||||
client_address=None,
|
||||
)
|
||||
self._store.delete(identity_key)
|
||||
|
||||
def _decision(
|
||||
self,
|
||||
buckets: tuple[tuple[str, int], ...],
|
||||
*,
|
||||
increment: bool,
|
||||
) -> LoginThrottleDecision:
|
||||
blocked_retry_after = 0
|
||||
for key, limit in buckets:
|
||||
state = (
|
||||
self._store.increment(key, window_seconds=self._window_seconds)
|
||||
if increment
|
||||
else self._store.read(key)
|
||||
)
|
||||
if state.count >= limit:
|
||||
blocked_retry_after = max(
|
||||
blocked_retry_after,
|
||||
max(1, state.retry_after_seconds),
|
||||
)
|
||||
return LoginThrottleDecision(
|
||||
allowed=blocked_retry_after == 0,
|
||||
retry_after_seconds=blocked_retry_after,
|
||||
)
|
||||
|
||||
def _buckets(
|
||||
self,
|
||||
*,
|
||||
normalized_email: str,
|
||||
tenant_slug: str | None,
|
||||
client_address: str | None,
|
||||
) -> tuple[tuple[str, int], ...]:
|
||||
# Accounts and their passwords are global login identities. Do not put
|
||||
# the caller-supplied tenant slug into the identity key: rotating fake
|
||||
# slugs must not bypass the account-level limit.
|
||||
del tenant_slug
|
||||
identity_key, client_key = self._keys(
|
||||
normalized_email=normalized_email,
|
||||
client_address=client_address,
|
||||
)
|
||||
buckets = [(identity_key, self._identity_limit)]
|
||||
if client_key is not None:
|
||||
buckets.append((client_key, self._client_limit))
|
||||
return tuple(buckets)
|
||||
|
||||
def _keys(
|
||||
self,
|
||||
*,
|
||||
normalized_email: str,
|
||||
client_address: str | None,
|
||||
) -> tuple[str, str | None]:
|
||||
identity = normalized_email.strip().casefold()
|
||||
identity_digest = hashlib.sha256(identity.encode()).hexdigest()
|
||||
identity_key = f"{self._key_prefix}:identity:{identity_digest}"
|
||||
if not client_address:
|
||||
return identity_key, None
|
||||
client_digest = hashlib.sha256(client_address.strip().casefold().encode()).hexdigest()
|
||||
return identity_key, f"{self._key_prefix}:client:{client_digest}"
|
||||
|
||||
|
||||
def build_login_throttle(
|
||||
*,
|
||||
redis_url: str | None,
|
||||
identity_limit: int,
|
||||
client_limit: int,
|
||||
window_seconds: int,
|
||||
redis_retry_seconds: int,
|
||||
) -> LoginThrottle:
|
||||
redis_store = RedisLoginAttemptStore(redis_url) if redis_url and redis_url.strip() else None
|
||||
resilient_store = ResilientLoginAttemptStore(
|
||||
redis_store,
|
||||
InMemoryLoginAttemptStore(),
|
||||
retry_seconds=redis_retry_seconds,
|
||||
)
|
||||
return LoginThrottle(
|
||||
resilient_store,
|
||||
identity_limit=identity_limit,
|
||||
client_limit=client_limit,
|
||||
window_seconds=window_seconds,
|
||||
)
|
||||
@@ -9,6 +9,14 @@ _ALGORITHM = "pbkdf2_sha256"
|
||||
_DEFAULT_ITERATIONS = 260_000
|
||||
_SALT_BYTES = 16
|
||||
|
||||
# A valid, fixed-cost hash used when a login identity has no local password hash.
|
||||
# Its plaintext value is intentionally irrelevant; the hash only keeps failed
|
||||
# login attempts on the same verification path as existing local accounts.
|
||||
DUMMY_PASSWORD_HASH = (
|
||||
"pbkdf2_sha256$260000$Z292b3BsYW4tZHVtbXktdjE=" # noqa: S105 # nosec B105 - non-account timing equalizer.
|
||||
"$uWgE7ht8wO6cotOqNKK2yNomPt57gstVss5ben5gTbw="
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password: str, *, iterations: int = _DEFAULT_ITERATIONS) -> str:
|
||||
salt = os.urandom(_SALT_BYTES)
|
||||
@@ -37,4 +45,3 @@ def verify_password(password: str, encoded: str | None) -> bool:
|
||||
return False
|
||||
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
||||
return hmac.compare_digest(actual, expected)
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
|
||||
from govoplan_access.backend.db.models import (
|
||||
@@ -18,8 +19,8 @@ from govoplan_access.backend.db.models import (
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_access.backend.semantic import collect_function_roles
|
||||
from govoplan_access.backend.permissions.catalog import expand_scopes
|
||||
from govoplan_access.backend.semantic import collect_function_authorization_context, collect_function_roles
|
||||
from govoplan_access.backend.permissions.catalog import expand_scopes, role_templates_for_level
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
|
||||
SESSION_RANDOM_BYTES = 32
|
||||
@@ -33,6 +34,16 @@ class CreatedSession:
|
||||
csrf_token: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserAuthorizationContext:
|
||||
tenant_roles: list[Role]
|
||||
system_roles: list[Role]
|
||||
groups: list[Group]
|
||||
function_assignment_ids: tuple[str, ...]
|
||||
function_delegation_ids: tuple[str, ...]
|
||||
scopes: list[str]
|
||||
|
||||
|
||||
def generate_session_token() -> str:
|
||||
return generate_secret("ms_", random_bytes=SESSION_RANDOM_BYTES)
|
||||
|
||||
@@ -93,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
|
||||
@@ -132,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()
|
||||
@@ -172,6 +207,8 @@ def collect_user_roles(session: Session, user: User) -> list[Role]:
|
||||
roles_by_id[role.id] = role
|
||||
for role in collect_function_roles(session, user):
|
||||
roles_by_id[role.id] = role
|
||||
for role in _materialized_default_authenticated_roles(session, user):
|
||||
roles_by_id[role.id] = role
|
||||
return list(roles_by_id.values())
|
||||
|
||||
|
||||
@@ -199,9 +236,71 @@ def collect_user_groups(session: Session, user: User) -> list[Group]:
|
||||
)
|
||||
|
||||
|
||||
def collect_user_authorization_context(
|
||||
session: Session,
|
||||
user: User,
|
||||
*,
|
||||
account: Account | None = None,
|
||||
include_system: bool = True,
|
||||
extra_roles: Iterable[Role] = (),
|
||||
) -> UserAuthorizationContext:
|
||||
account = account or user.account
|
||||
roles_by_id: dict[str, Role] = {role.id: role for role in collect_direct_user_roles(session, user)}
|
||||
groups = collect_user_groups(session, user)
|
||||
group_ids = [group.id for group in groups]
|
||||
if group_ids:
|
||||
group_roles = (
|
||||
session.query(Role)
|
||||
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
GroupRoleAssignment.tenant_id == user.tenant_id,
|
||||
GroupRoleAssignment.group_id.in_(sorted(group_ids)),
|
||||
Role.tenant_id == user.tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
for role in group_roles:
|
||||
roles_by_id[role.id] = role
|
||||
|
||||
function_context = collect_function_authorization_context(session, user)
|
||||
for role in function_context.roles:
|
||||
roles_by_id[role.id] = role
|
||||
for role in extra_roles:
|
||||
roles_by_id[role.id] = role
|
||||
for role in _materialized_default_authenticated_roles(session, user):
|
||||
roles_by_id[role.id] = role
|
||||
|
||||
tenant_roles = list(roles_by_id.values())
|
||||
system_roles = collect_system_roles(session, account) if include_system and account is not None else []
|
||||
default_slugs = _default_authenticated_slugs()
|
||||
scopes = {
|
||||
scope
|
||||
for role in tenant_roles
|
||||
if role.slug not in default_slugs
|
||||
for scope in (role.permissions or [])
|
||||
}
|
||||
scopes.update(
|
||||
scope
|
||||
for role in system_roles
|
||||
for scope in (role.permissions or [])
|
||||
)
|
||||
scopes.update(_default_authenticated_scopes())
|
||||
return UserAuthorizationContext(
|
||||
tenant_roles=tenant_roles,
|
||||
system_roles=system_roles,
|
||||
groups=groups,
|
||||
function_assignment_ids=function_context.assignment_ids,
|
||||
function_delegation_ids=function_context.delegation_ids,
|
||||
scopes=expand_scopes(scopes),
|
||||
)
|
||||
|
||||
|
||||
def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]:
|
||||
scopes: set[str] = set()
|
||||
scopes = _default_authenticated_scopes()
|
||||
default_slugs = _default_authenticated_slugs()
|
||||
for role in collect_user_roles(session, user):
|
||||
if role.slug not in default_slugs:
|
||||
scopes.update(role.permissions or [])
|
||||
if include_system and user.account:
|
||||
for role in collect_system_roles(session, user.account):
|
||||
@@ -209,6 +308,43 @@ def collect_user_scopes(session: Session, user: User, *, include_system: bool =
|
||||
return expand_scopes(scopes)
|
||||
|
||||
|
||||
def _default_authenticated_slugs() -> set[str]:
|
||||
return {
|
||||
template.slug
|
||||
for template in role_templates_for_level("tenant")
|
||||
if template.default_authenticated
|
||||
}
|
||||
|
||||
|
||||
def _default_authenticated_scopes() -> set[str]:
|
||||
return {
|
||||
scope
|
||||
for template in role_templates_for_level("tenant")
|
||||
if template.default_authenticated
|
||||
for scope in template.permissions
|
||||
}
|
||||
|
||||
|
||||
def _materialized_default_authenticated_roles(
|
||||
session: Session,
|
||||
user: User,
|
||||
) -> list[Role]:
|
||||
"""Return the optional database projection without mutating auth reads."""
|
||||
|
||||
default_slugs = _default_authenticated_slugs()
|
||||
if not default_slugs:
|
||||
return []
|
||||
return (
|
||||
session.query(Role)
|
||||
.filter(
|
||||
Role.tenant_id == user.tenant_id,
|
||||
Role.slug.in_(default_slugs),
|
||||
)
|
||||
.order_by(Role.name.asc(), Role.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def collect_tenant_memberships(session: Session, account: Account) -> list[tuple[User, Tenant]]:
|
||||
return (
|
||||
session.query(User, Tenant)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -23,6 +24,13 @@ from govoplan_core.core.organizations import ORGANIZATIONS_MODULE_ID, Organizati
|
||||
from govoplan_core.security.time import utc_now
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FunctionAuthorizationContext:
|
||||
assignment_ids: tuple[str, ...]
|
||||
delegation_ids: tuple[str, ...]
|
||||
roles: tuple[Role, ...]
|
||||
|
||||
|
||||
def primary_identity_for_account(session: Session, account_id: str) -> Identity | None:
|
||||
return (
|
||||
session.query(Identity)
|
||||
@@ -169,6 +177,56 @@ def collect_function_roles(session: Session, user: User) -> list[Role]:
|
||||
)
|
||||
|
||||
|
||||
def collect_function_authorization_context(session: Session, user: User) -> FunctionAuthorizationContext:
|
||||
assignments = active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)
|
||||
delegations = active_function_delegations_for_account(
|
||||
session,
|
||||
user.account_id,
|
||||
tenant_id=user.tenant_id,
|
||||
modes=("delegate", "act_in_place"),
|
||||
)
|
||||
direct_assignment_ids = {assignment.id for assignment in assignments}
|
||||
delegated_role_assignment_ids = {
|
||||
delegation.function_assignment_id
|
||||
for delegation in delegations
|
||||
if delegation.mode == "delegate"
|
||||
}
|
||||
role_assignment_ids = direct_assignment_ids | delegated_role_assignment_ids
|
||||
function_ids = {assignment.function_id for assignment in assignments}
|
||||
|
||||
missing_role_assignment_ids = delegated_role_assignment_ids - direct_assignment_ids
|
||||
if missing_role_assignment_ids:
|
||||
function_ids.update(
|
||||
row[0]
|
||||
for row in session.query(FunctionAssignment.function_id)
|
||||
.filter(
|
||||
FunctionAssignment.tenant_id == user.tenant_id,
|
||||
FunctionAssignment.id.in_(sorted(missing_role_assignment_ids)),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
roles: tuple[Role, ...] = ()
|
||||
if function_ids:
|
||||
roles = tuple(
|
||||
session.query(Role)
|
||||
.join(FunctionRoleAssignment, FunctionRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
FunctionRoleAssignment.tenant_id == user.tenant_id,
|
||||
FunctionRoleAssignment.function_id.in_(sorted(function_ids)),
|
||||
Role.tenant_id == user.tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return FunctionAuthorizationContext(
|
||||
assignment_ids=tuple(sorted(role_assignment_ids)),
|
||||
delegation_ids=tuple(sorted(delegation.id for delegation in delegations)),
|
||||
roles=roles,
|
||||
)
|
||||
|
||||
|
||||
def collect_external_function_roles(
|
||||
session: Session,
|
||||
user: User,
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.base import utcnow
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
ServiceAccount,
|
||||
Tenant,
|
||||
User,
|
||||
new_uuid,
|
||||
)
|
||||
from govoplan_access.backend.permissions.catalog import scopes_grant
|
||||
from govoplan_access.backend.security.api_keys import (
|
||||
CreatedApiKey,
|
||||
create_api_key,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
|
||||
|
||||
class ServiceAccountError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceAccountNotFoundError(ServiceAccountError):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceAccountConflictError(ServiceAccountError):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceAccountCredentialNotFoundError(ServiceAccountError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ServiceAccountCredentialSummary:
|
||||
credential_count: int = 0
|
||||
active_credential_count: int = 0
|
||||
last_credential_used_at: datetime | None = None
|
||||
|
||||
|
||||
def list_service_accounts(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> list[ServiceAccount]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(ServiceAccount)
|
||||
.where(ServiceAccount.tenant_id == tenant_id)
|
||||
.order_by(
|
||||
ServiceAccount.normalized_name,
|
||||
ServiceAccount.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def service_account_credential_summaries(
|
||||
session: Session,
|
||||
*,
|
||||
service_accounts: Iterable[ServiceAccount],
|
||||
) -> dict[str, ServiceAccountCredentialSummary]:
|
||||
items = tuple(service_accounts)
|
||||
by_membership = {item.membership_id: item.id for item in items}
|
||||
usable_accounts = {
|
||||
item.id
|
||||
for item in items
|
||||
if item.is_active and item.retired_at is None
|
||||
}
|
||||
summaries = {
|
||||
item.id: ServiceAccountCredentialSummary()
|
||||
for item in items
|
||||
}
|
||||
if not by_membership:
|
||||
return summaries
|
||||
now = utc_now()
|
||||
totals: dict[str, int] = {}
|
||||
active: dict[str, int] = {}
|
||||
last_used: dict[str, datetime | None] = {}
|
||||
credentials = session.scalars(
|
||||
select(ApiKey).where(ApiKey.user_id.in_(by_membership))
|
||||
)
|
||||
for credential in credentials:
|
||||
service_account_id = by_membership[credential.user_id]
|
||||
totals[service_account_id] = totals.get(service_account_id, 0) + 1
|
||||
expires_at = ensure_aware_utc(credential.expires_at)
|
||||
if (
|
||||
service_account_id in usable_accounts
|
||||
and
|
||||
credential.revoked_at is None
|
||||
and (expires_at is None or expires_at > now)
|
||||
):
|
||||
active[service_account_id] = (
|
||||
active.get(service_account_id, 0) + 1
|
||||
)
|
||||
used_at = ensure_aware_utc(credential.last_used_at)
|
||||
if used_at is not None and (
|
||||
last_used.get(service_account_id) is None
|
||||
or used_at > last_used[service_account_id]
|
||||
):
|
||||
last_used[service_account_id] = used_at
|
||||
return {
|
||||
item.id: ServiceAccountCredentialSummary(
|
||||
credential_count=totals.get(item.id, 0),
|
||||
active_credential_count=active.get(item.id, 0),
|
||||
last_credential_used_at=last_used.get(item.id),
|
||||
)
|
||||
for item in items
|
||||
}
|
||||
|
||||
|
||||
def list_service_account_credentials(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
include_revoked: bool = True,
|
||||
) -> tuple[ServiceAccount, list[ApiKey]]:
|
||||
item = get_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
)
|
||||
query = select(ApiKey).where(
|
||||
ApiKey.tenant_id == tenant_id,
|
||||
ApiKey.user_id == item.membership_id,
|
||||
)
|
||||
if not include_revoked:
|
||||
query = query.where(ApiKey.revoked_at.is_(None))
|
||||
credentials = list(
|
||||
session.scalars(
|
||||
query.order_by(ApiKey.created_at.desc(), ApiKey.id)
|
||||
)
|
||||
)
|
||||
return item, credentials
|
||||
|
||||
|
||||
def create_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
name: str,
|
||||
scopes: Iterable[str],
|
||||
expires_at: datetime | None,
|
||||
) -> tuple[ServiceAccount, CreatedApiKey]:
|
||||
item = _locked_service_account_for_credential_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
user = _active_service_account_membership(session, item)
|
||||
credential_scopes = _service_account_credential_scopes(
|
||||
principal,
|
||||
item,
|
||||
scopes,
|
||||
)
|
||||
created = create_api_key(
|
||||
session,
|
||||
user=user,
|
||||
name=_credential_name(name),
|
||||
scopes=list(credential_scopes),
|
||||
expires_at=_future_expiry(expires_at),
|
||||
)
|
||||
_touch_service_account(item, principal)
|
||||
session.flush()
|
||||
return item, created
|
||||
|
||||
|
||||
def rotate_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
name: str | None,
|
||||
scopes: Iterable[str] | None,
|
||||
expires_at: datetime | None,
|
||||
) -> tuple[ServiceAccount, ApiKey, CreatedApiKey]:
|
||||
item = _locked_service_account_for_credential_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
user = _active_service_account_membership(session, item)
|
||||
previous = _locked_service_account_credential(
|
||||
session,
|
||||
item=item,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
if previous.revoked_at is not None:
|
||||
raise ServiceAccountConflictError(
|
||||
"The credential is already revoked; reload before rotating"
|
||||
)
|
||||
requested_scopes = previous.scopes if scopes is None else scopes
|
||||
credential_scopes = _service_account_credential_scopes(
|
||||
principal,
|
||||
item,
|
||||
requested_scopes,
|
||||
)
|
||||
created = create_api_key(
|
||||
session,
|
||||
user=user,
|
||||
name=_credential_name(name or previous.name),
|
||||
scopes=list(credential_scopes),
|
||||
expires_at=_future_expiry(expires_at),
|
||||
)
|
||||
previous.revoked_at = utc_now()
|
||||
_touch_service_account(item, principal)
|
||||
session.flush()
|
||||
return item, previous, created
|
||||
|
||||
|
||||
def revoke_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
) -> tuple[ServiceAccount, ApiKey]:
|
||||
item = _locked_service_account_for_credential_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
credential = _locked_service_account_credential(
|
||||
session,
|
||||
item=item,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
if credential.revoked_at is None:
|
||||
credential.revoked_at = utc_now()
|
||||
_touch_service_account(item, principal)
|
||||
session.flush()
|
||||
return item, credential
|
||||
|
||||
|
||||
def get_service_account(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
lock: bool = False,
|
||||
) -> ServiceAccount:
|
||||
query = select(ServiceAccount).where(
|
||||
ServiceAccount.id == service_account_id,
|
||||
ServiceAccount.tenant_id == tenant_id,
|
||||
)
|
||||
if lock:
|
||||
query = query.with_for_update()
|
||||
item = session.scalar(query)
|
||||
if item is None:
|
||||
raise ServiceAccountNotFoundError(
|
||||
"Service account was not found"
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
def create_service_account(
|
||||
session: Session,
|
||||
*,
|
||||
tenant: Tenant,
|
||||
principal: ApiPrincipal,
|
||||
name: str,
|
||||
description: str | None,
|
||||
scope_ceiling: Iterable[str],
|
||||
) -> ServiceAccount:
|
||||
clean_name = _service_account_name(name)
|
||||
scopes = _service_account_scopes(
|
||||
principal,
|
||||
scope_ceiling,
|
||||
)
|
||||
service_account_id = new_uuid()
|
||||
internal_email = (
|
||||
f"service-account-{service_account_id}@govoplan.invalid"
|
||||
)
|
||||
account = Account(
|
||||
id=new_uuid(),
|
||||
email=internal_email,
|
||||
normalized_email=internal_email,
|
||||
display_name=clean_name,
|
||||
is_active=True,
|
||||
auth_provider="service_account",
|
||||
password_hash=None,
|
||||
password_reset_required=False,
|
||||
)
|
||||
membership = User(
|
||||
id=new_uuid(),
|
||||
tenant_id=tenant.id,
|
||||
account=account,
|
||||
email=internal_email,
|
||||
display_name=clean_name,
|
||||
is_active=True,
|
||||
is_tenant_admin=False,
|
||||
auth_provider="service_account",
|
||||
password_hash=None,
|
||||
settings={"managed_service_account": service_account_id},
|
||||
)
|
||||
item = ServiceAccount(
|
||||
id=service_account_id,
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
membership_id=membership.id,
|
||||
name=clean_name,
|
||||
normalized_name=_normalized_name(clean_name),
|
||||
description=_optional_text(description),
|
||||
scope_ceiling=list(scopes),
|
||||
is_active=True,
|
||||
revision=1,
|
||||
created_by_account_id=principal.account_id,
|
||||
updated_by_account_id=principal.account_id,
|
||||
settings={},
|
||||
)
|
||||
session.add_all((account, membership, item))
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError as exc:
|
||||
raise ServiceAccountConflictError(
|
||||
"A service account with this name already exists"
|
||||
) from exc
|
||||
return item
|
||||
|
||||
|
||||
def update_service_account(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
changes: Mapping[str, object],
|
||||
) -> ServiceAccount:
|
||||
item = get_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
lock=True,
|
||||
)
|
||||
if item.revision != expected_revision:
|
||||
raise ServiceAccountConflictError(
|
||||
"Service account changed on the server; reload before saving"
|
||||
)
|
||||
account = session.get(Account, item.account_id)
|
||||
membership = session.get(User, item.membership_id)
|
||||
if account is None or membership is None:
|
||||
raise ServiceAccountConflictError(
|
||||
"Service account backing identity is missing"
|
||||
)
|
||||
if "name" in changes:
|
||||
clean_name = _service_account_name(str(changes["name"]))
|
||||
item.name = clean_name
|
||||
item.normalized_name = _normalized_name(clean_name)
|
||||
account.display_name = clean_name
|
||||
membership.display_name = clean_name
|
||||
if "description" in changes:
|
||||
value = changes["description"]
|
||||
item.description = _optional_text(
|
||||
str(value) if value is not None else None
|
||||
)
|
||||
if "scope_ceiling" in changes:
|
||||
raw_scopes = changes["scope_ceiling"]
|
||||
if not isinstance(raw_scopes, Iterable) or isinstance(
|
||||
raw_scopes,
|
||||
(str, bytes),
|
||||
):
|
||||
raise ServiceAccountError(
|
||||
"Service account scope ceiling is invalid"
|
||||
)
|
||||
item.scope_ceiling = list(
|
||||
_service_account_scopes(
|
||||
principal,
|
||||
(str(scope) for scope in raw_scopes),
|
||||
)
|
||||
)
|
||||
if "is_active" in changes:
|
||||
active = bool(changes["is_active"])
|
||||
item.is_active = active
|
||||
account.is_active = active
|
||||
membership.is_active = active
|
||||
item.retired_at = None if active else utcnow()
|
||||
item.revision += 1
|
||||
item.updated_by_account_id = principal.account_id
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError as exc:
|
||||
raise ServiceAccountConflictError(
|
||||
"A service account with this name already exists"
|
||||
) from exc
|
||||
return item
|
||||
|
||||
|
||||
def retire_service_account(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
) -> ServiceAccount:
|
||||
item = update_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
principal=principal,
|
||||
expected_revision=expected_revision,
|
||||
changes={"is_active": False},
|
||||
)
|
||||
now = utc_now()
|
||||
credentials = session.scalars(
|
||||
select(ApiKey).where(
|
||||
ApiKey.tenant_id == tenant_id,
|
||||
ApiKey.user_id == item.membership_id,
|
||||
ApiKey.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
for credential in credentials:
|
||||
credential.revoked_at = now
|
||||
session.flush()
|
||||
return item
|
||||
|
||||
|
||||
def _locked_service_account_for_credential_change(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
expected_revision: int,
|
||||
) -> ServiceAccount:
|
||||
item = get_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
lock=True,
|
||||
)
|
||||
if item.revision != expected_revision:
|
||||
raise ServiceAccountConflictError(
|
||||
"Service account changed on the server; reload before changing credentials"
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
def _locked_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
item: ServiceAccount,
|
||||
credential_id: str,
|
||||
) -> ApiKey:
|
||||
credential = session.scalar(
|
||||
select(ApiKey)
|
||||
.where(
|
||||
ApiKey.id == credential_id,
|
||||
ApiKey.tenant_id == item.tenant_id,
|
||||
ApiKey.user_id == item.membership_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if credential is None:
|
||||
raise ServiceAccountCredentialNotFoundError(
|
||||
"Service-account credential was not found"
|
||||
)
|
||||
return credential
|
||||
|
||||
|
||||
def _active_service_account_membership(
|
||||
session: Session,
|
||||
item: ServiceAccount,
|
||||
) -> User:
|
||||
user = session.get(User, item.membership_id)
|
||||
account = session.get(Account, item.account_id)
|
||||
if (
|
||||
not item.is_active
|
||||
or item.retired_at is not None
|
||||
or user is None
|
||||
or account is None
|
||||
or not user.is_active
|
||||
or not account.is_active
|
||||
):
|
||||
raise ServiceAccountConflictError(
|
||||
"Activate the service account before creating or rotating credentials"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def _service_account_credential_scopes(
|
||||
principal: ApiPrincipal,
|
||||
item: ServiceAccount,
|
||||
values: Iterable[str],
|
||||
) -> tuple[str, ...]:
|
||||
scopes = tuple(
|
||||
sorted(
|
||||
{
|
||||
str(value).strip()
|
||||
for value in values
|
||||
if str(value).strip()
|
||||
}
|
||||
)
|
||||
)
|
||||
if not scopes:
|
||||
raise ServiceAccountError(
|
||||
"A service-account credential requires at least one scope"
|
||||
)
|
||||
if len(scopes) > 200:
|
||||
raise ServiceAccountError(
|
||||
"Service-account credentials support at most 200 scopes"
|
||||
)
|
||||
denied_by_ceiling = tuple(
|
||||
scope
|
||||
for scope in scopes
|
||||
if not scopes_grant(item.scope_ceiling, scope)
|
||||
)
|
||||
if denied_by_ceiling:
|
||||
raise PermissionError(
|
||||
"Credential scopes exceed the service-account scope ceiling: "
|
||||
+ ", ".join(denied_by_ceiling)
|
||||
)
|
||||
denied_by_actor = tuple(
|
||||
scope for scope in scopes if not principal.has(scope)
|
||||
)
|
||||
if denied_by_actor:
|
||||
raise PermissionError(
|
||||
"Credential scopes exceed the current administrator authority: "
|
||||
+ ", ".join(denied_by_actor)
|
||||
)
|
||||
return scopes
|
||||
|
||||
|
||||
def _credential_name(value: str) -> str:
|
||||
clean = " ".join(value.split())
|
||||
if not 1 <= len(clean) <= 255:
|
||||
raise ServiceAccountError(
|
||||
"Credential name must contain between 1 and 255 characters"
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
def _future_expiry(value: datetime | None) -> datetime | None:
|
||||
expires_at = ensure_aware_utc(value)
|
||||
if expires_at is not None and expires_at <= utc_now():
|
||||
raise ServiceAccountError(
|
||||
"Credential expiry must be in the future"
|
||||
)
|
||||
return expires_at
|
||||
|
||||
|
||||
def _touch_service_account(
|
||||
item: ServiceAccount,
|
||||
principal: ApiPrincipal,
|
||||
) -> None:
|
||||
item.revision += 1
|
||||
item.updated_by_account_id = principal.account_id
|
||||
|
||||
|
||||
def _service_account_name(value: str) -> str:
|
||||
clean = " ".join(value.split())
|
||||
if not 1 <= len(clean) <= 255:
|
||||
raise ServiceAccountError(
|
||||
"Service account name must contain between 1 and 255 characters"
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
def _normalized_name(value: str) -> str:
|
||||
return value.casefold()
|
||||
|
||||
|
||||
def _optional_text(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
clean = value.strip()
|
||||
if len(clean) > 4000:
|
||||
raise ServiceAccountError(
|
||||
"Service account description is too long"
|
||||
)
|
||||
return clean or None
|
||||
|
||||
|
||||
def _service_account_scopes(
|
||||
principal: ApiPrincipal,
|
||||
values: Iterable[str],
|
||||
) -> tuple[str, ...]:
|
||||
scopes = tuple(
|
||||
sorted(
|
||||
{
|
||||
str(value).strip()
|
||||
for value in values
|
||||
if str(value).strip()
|
||||
}
|
||||
)
|
||||
)
|
||||
if len(scopes) > 200:
|
||||
raise ServiceAccountError(
|
||||
"Service accounts support at most 200 scope grants"
|
||||
)
|
||||
denied = tuple(
|
||||
scope for scope in scopes
|
||||
if not principal.has(scope)
|
||||
)
|
||||
if denied:
|
||||
raise PermissionError(
|
||||
"Cannot grant service-account scopes outside the current "
|
||||
f"administrator authority: {', '.join(denied)}"
|
||||
)
|
||||
return scopes
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ServiceAccountConflictError",
|
||||
"ServiceAccountCredentialNotFoundError",
|
||||
"ServiceAccountCredentialSummary",
|
||||
"ServiceAccountError",
|
||||
"ServiceAccountNotFoundError",
|
||||
"create_service_account",
|
||||
"create_service_account_credential",
|
||||
"get_service_account",
|
||||
"list_service_accounts",
|
||||
"list_service_account_credentials",
|
||||
"revoke_service_account_credential",
|
||||
"retire_service_account",
|
||||
"rotate_service_account_credential",
|
||||
"service_account_credential_summaries",
|
||||
"update_service_account",
|
||||
]
|
||||
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import AuthSession
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
|
||||
|
||||
MAX_SESSION_LIST_ITEMS = 100
|
||||
MAX_CLIENT_LABEL_LENGTH = 160
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionSummary:
|
||||
id: str
|
||||
tenant_id: str
|
||||
current: bool
|
||||
status: str
|
||||
created_at: datetime
|
||||
last_seen_at: datetime | None
|
||||
expires_at: datetime
|
||||
revoked_at: datetime | None
|
||||
client: str | None
|
||||
|
||||
|
||||
def session_summary(
|
||||
item: AuthSession,
|
||||
*,
|
||||
current_session_id: str | None,
|
||||
now: datetime | None = None,
|
||||
) -> SessionSummary:
|
||||
effective_at = now or utc_now()
|
||||
expires_at = ensure_aware_utc(item.expires_at)
|
||||
revoked_at = ensure_aware_utc(item.revoked_at)
|
||||
if revoked_at is not None:
|
||||
status = "revoked"
|
||||
elif expires_at is None or expires_at <= effective_at:
|
||||
status = "expired"
|
||||
else:
|
||||
status = "active"
|
||||
return SessionSummary(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
current=item.id == current_session_id,
|
||||
status=status,
|
||||
created_at=item.created_at,
|
||||
last_seen_at=item.last_seen_at,
|
||||
expires_at=item.expires_at,
|
||||
revoked_at=item.revoked_at,
|
||||
client=_bounded_client(item.user_agent),
|
||||
)
|
||||
|
||||
|
||||
def list_account_sessions(
|
||||
session: Session,
|
||||
*,
|
||||
account_id: str,
|
||||
current_session_id: str | None,
|
||||
tenant_id: str | None = None,
|
||||
include_inactive: bool = False,
|
||||
limit: int = MAX_SESSION_LIST_ITEMS,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[SessionSummary, ...]:
|
||||
effective_at = now or utc_now()
|
||||
query = session.query(AuthSession).filter(AuthSession.account_id == account_id)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(AuthSession.tenant_id == tenant_id)
|
||||
rows = query.order_by(AuthSession.created_at.desc(), AuthSession.id.asc()).all()
|
||||
summaries = tuple(
|
||||
session_summary(
|
||||
item,
|
||||
current_session_id=current_session_id,
|
||||
now=effective_at,
|
||||
)
|
||||
for item in rows
|
||||
)
|
||||
if not include_inactive:
|
||||
summaries = tuple(item for item in summaries if item.status == "active")
|
||||
return summaries[: max(1, min(limit, MAX_SESSION_LIST_ITEMS))]
|
||||
|
||||
|
||||
def revoke_account_session(
|
||||
session: Session,
|
||||
*,
|
||||
account_id: str,
|
||||
session_id: str,
|
||||
tenant_id: str | None = None,
|
||||
protected_session_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[AuthSession | None, bool]:
|
||||
query = session.query(AuthSession).filter(
|
||||
AuthSession.id == session_id,
|
||||
AuthSession.account_id == account_id,
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(AuthSession.tenant_id == tenant_id)
|
||||
item = query.one_or_none()
|
||||
if item is None:
|
||||
return None, False
|
||||
if protected_session_id is not None and item.id == protected_session_id:
|
||||
raise ValueError("The current session cannot be revoked through session management.")
|
||||
if item.revoked_at is not None:
|
||||
return item, False
|
||||
item.revoked_at = now or utc_now()
|
||||
session.add(item)
|
||||
return item, True
|
||||
|
||||
|
||||
def revoke_other_account_sessions(
|
||||
session: Session,
|
||||
*,
|
||||
account_id: str,
|
||||
current_session_id: str,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
effective_at = now or utc_now()
|
||||
rows = (
|
||||
session.query(AuthSession)
|
||||
.filter(
|
||||
AuthSession.account_id == account_id,
|
||||
AuthSession.id != current_session_id,
|
||||
AuthSession.revoked_at.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
revoked: list[str] = []
|
||||
for item in rows:
|
||||
expires_at = ensure_aware_utc(item.expires_at)
|
||||
if expires_at is None or expires_at <= effective_at:
|
||||
continue
|
||||
item.revoked_at = effective_at
|
||||
session.add(item)
|
||||
revoked.append(item.id)
|
||||
return tuple(sorted(revoked))
|
||||
|
||||
|
||||
def _bounded_client(value: str | None) -> str | None:
|
||||
normalized = " ".join(str(value or "").split())
|
||||
if not normalized:
|
||||
return None
|
||||
return normalized[:MAX_CLIENT_LABEL_LENGTH]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_CLIENT_LABEL_LENGTH",
|
||||
"MAX_SESSION_LIST_ITEMS",
|
||||
"SessionSummary",
|
||||
"list_account_sessions",
|
||||
"revoke_account_session",
|
||||
"revoke_other_account_sessions",
|
||||
"session_summary",
|
||||
]
|
||||
@@ -5,11 +5,21 @@ from collections.abc import Mapping, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from 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,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.administration import SqlAccessAdministration
|
||||
from govoplan_access.backend.api.v1.admin_common import (
|
||||
_accounts_by_user_id,
|
||||
_group_member_ids_by_group_id,
|
||||
_groups_by_user_id,
|
||||
_roles_by_group_id,
|
||||
_roles_by_user_id,
|
||||
_tenant_role_assignment_counts,
|
||||
)
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class AdminBatchHelperTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_access_admin_batch_maps_related_rows(self) -> None:
|
||||
session = self.session
|
||||
account = Account(id="account-1", email="ada@example.test", normalized_email="ada@example.test")
|
||||
user = User(id="user-1", tenant_id="tenant-1", account_id=account.id, email=account.email)
|
||||
group = Group(id="group-1", tenant_id="tenant-1", slug="clerks", name="Clerks")
|
||||
role = Role(id="role-1", tenant_id="tenant-1", slug="reader", name="Reader", permissions=["admin:users:read"])
|
||||
session.add_all([account, user, group, role])
|
||||
session.flush()
|
||||
session.add(UserGroupMembership(tenant_id="tenant-1", user_id=user.id, group_id=group.id))
|
||||
session.add(UserRoleAssignment(tenant_id="tenant-1", user_id=user.id, role_id=role.id))
|
||||
session.add(GroupRoleAssignment(tenant_id="tenant-1", group_id=group.id, role_id=role.id))
|
||||
session.commit()
|
||||
|
||||
accounts_by_user = _accounts_by_user_id(session, [user.id])
|
||||
group_member_ids = _group_member_ids_by_group_id(session, tenant_id="tenant-1", group_ids=[group.id])
|
||||
groups_by_user = _groups_by_user_id(session, tenant_id="tenant-1", user_ids=[user.id])
|
||||
roles_by_group = _roles_by_group_id(session, tenant_id="tenant-1", group_ids=[group.id])
|
||||
roles_by_user = _roles_by_user_id(session, tenant_id="tenant-1", user_ids=[user.id])
|
||||
role_counts = _tenant_role_assignment_counts(session, [role.id])
|
||||
|
||||
self.assertEqual(accounts_by_user[user.id].id, account.id)
|
||||
self.assertEqual(group_member_ids, {group.id: [user.id]})
|
||||
self.assertEqual([item.id for item in groups_by_user[user.id]], [group.id])
|
||||
self.assertEqual([item.id for item in roles_by_group[group.id]], [role.id])
|
||||
self.assertEqual([item.id for item in roles_by_user[user.id]], [role.id])
|
||||
self.assertEqual(role_counts, {role.id: (1, 1)})
|
||||
|
||||
def test_tenant_counts_many_uses_three_grouped_queries(self) -> None:
|
||||
accounts = [
|
||||
Account(
|
||||
id=f"account-{index}",
|
||||
email=f"user-{index}@example.test",
|
||||
normalized_email=f"user-{index}@example.test",
|
||||
)
|
||||
for index in range(3)
|
||||
]
|
||||
users = [
|
||||
User(
|
||||
id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id=accounts[0].id,
|
||||
email=accounts[0].email,
|
||||
),
|
||||
User(
|
||||
id="user-2",
|
||||
tenant_id="tenant-1",
|
||||
account_id=accounts[1].id,
|
||||
email=accounts[1].email,
|
||||
is_active=False,
|
||||
),
|
||||
User(
|
||||
id="user-3",
|
||||
tenant_id="tenant-2",
|
||||
account_id=accounts[2].id,
|
||||
email=accounts[2].email,
|
||||
),
|
||||
]
|
||||
self.session.add_all(
|
||||
[
|
||||
*accounts,
|
||||
*users,
|
||||
Group(id="group-1", tenant_id="tenant-1", slug="one", name="One"),
|
||||
Group(id="group-2", tenant_id="tenant-2", slug="two", name="Two"),
|
||||
ApiKey(
|
||||
id="key-1",
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
name="Active",
|
||||
prefix="active",
|
||||
key_hash="hash-1",
|
||||
),
|
||||
ApiKey(
|
||||
id="key-2",
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-2",
|
||||
name="Revoked",
|
||||
prefix="revoked",
|
||||
key_hash="hash-2",
|
||||
revoked_at=datetime.now(UTC),
|
||||
),
|
||||
]
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
query_count = 0
|
||||
|
||||
def count_query(*_args: object) -> None:
|
||||
nonlocal query_count
|
||||
query_count += 1
|
||||
|
||||
event.listen(self.engine, "before_cursor_execute", count_query)
|
||||
try:
|
||||
counts = SqlAccessAdministration().tenant_counts_many(
|
||||
self.session,
|
||||
["tenant-1", "tenant-2", "tenant-empty"],
|
||||
)
|
||||
finally:
|
||||
event.remove(self.engine, "before_cursor_execute", count_query)
|
||||
|
||||
self.assertEqual(3, query_count)
|
||||
self.assertEqual(
|
||||
{
|
||||
"users": 2,
|
||||
"active_users": 1,
|
||||
"groups": 1,
|
||||
"api_keys": 2,
|
||||
"active_api_keys": 1,
|
||||
},
|
||||
counts["tenant-1"],
|
||||
)
|
||||
self.assertEqual(1, counts["tenant-2"]["users"])
|
||||
self.assertEqual(0, counts["tenant-empty"]["users"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_access.backend.api.v1.routes import (
|
||||
_decode_full_delta_cursor,
|
||||
_encode_full_delta_cursor,
|
||||
_full_delta_page,
|
||||
_page_query,
|
||||
)
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, rows: list[int]) -> None:
|
||||
self._rows = rows
|
||||
self._offset = 0
|
||||
self._limit: int | None = None
|
||||
|
||||
def order_by(self, *_args: object) -> FakeQuery:
|
||||
return self
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self._rows)
|
||||
|
||||
def offset(self, value: int) -> FakeQuery:
|
||||
clone = FakeQuery(self._rows)
|
||||
clone._offset = value
|
||||
clone._limit = self._limit
|
||||
return clone
|
||||
|
||||
def limit(self, value: int) -> FakeQuery:
|
||||
clone = FakeQuery(self._rows)
|
||||
clone._offset = self._offset
|
||||
clone._limit = value
|
||||
return clone
|
||||
|
||||
def all(self) -> list[int]:
|
||||
end = None if self._limit is None else self._offset + self._limit
|
||||
return self._rows[self._offset:end]
|
||||
|
||||
|
||||
class AdminPaginationTests(unittest.TestCase):
|
||||
def test_page_query_returns_page_and_metadata(self) -> None:
|
||||
rows, metadata = _page_query(FakeQuery(list(range(7))), page=2, page_size=3)
|
||||
|
||||
self.assertEqual(rows, [3, 4, 5])
|
||||
self.assertEqual(metadata, {"total": 7, "page": 2, "page_size": 3, "pages": 3})
|
||||
|
||||
def test_full_delta_page_returns_cursor_until_last_page(self) -> None:
|
||||
rows, metadata, watermark, has_more = _full_delta_page(
|
||||
FakeQuery(list(range(7))),
|
||||
page=2,
|
||||
page_size=3,
|
||||
scope="users",
|
||||
snapshot_sequence=42,
|
||||
)
|
||||
|
||||
self.assertEqual(rows, [3, 4, 5])
|
||||
self.assertEqual(metadata, {"total": 7, "page": 2, "page_size": 3, "pages": 3})
|
||||
self.assertEqual(watermark, "full:users:3:42")
|
||||
self.assertTrue(has_more)
|
||||
|
||||
def test_full_delta_page_returns_sequence_watermark_on_last_page(self) -> None:
|
||||
rows, metadata, watermark, has_more = _full_delta_page(
|
||||
FakeQuery(list(range(7))),
|
||||
page=3,
|
||||
page_size=3,
|
||||
scope="users",
|
||||
snapshot_sequence=42,
|
||||
)
|
||||
|
||||
self.assertEqual(rows, [6])
|
||||
self.assertEqual(metadata, {"total": 7, "page": 3, "page_size": 3, "pages": 3})
|
||||
self.assertEqual(watermark, "seq:42")
|
||||
self.assertFalse(has_more)
|
||||
|
||||
def test_full_delta_cursor_round_trips_and_rejects_wrong_scope(self) -> None:
|
||||
cursor = _encode_full_delta_cursor("users", page=3, snapshot_sequence=42)
|
||||
|
||||
self.assertEqual(_decode_full_delta_cursor(cursor, scope="users"), (3, 42))
|
||||
self.assertIsNone(_decode_full_delta_cursor("seq:42", scope="users"))
|
||||
with self.assertRaises(HTTPException):
|
||||
_decode_full_delta_cursor(cursor, scope="groups")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest import TestCase
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||
from govoplan_access.backend.security.sessions import authenticate_session_token
|
||||
|
||||
|
||||
class AuthenticationActivityTouchTests(TestCase):
|
||||
def test_session_activity_is_touched_only_after_the_interval(self) -> None:
|
||||
now = datetime(2026, 7, 29, 10, 0, tzinfo=timezone.utc)
|
||||
for age_seconds, should_touch in ((60, False), (301, True)):
|
||||
with self.subTest(age_seconds=age_seconds):
|
||||
model = SimpleNamespace(
|
||||
expires_at=now + timedelta(hours=1),
|
||||
last_seen_at=now - timedelta(seconds=age_seconds),
|
||||
)
|
||||
session = MagicMock()
|
||||
(
|
||||
session.query.return_value.options.return_value
|
||||
.filter.return_value.one_or_none
|
||||
).return_value = model
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_access.backend.security.sessions.hash_session_token",
|
||||
return_value="hashed",
|
||||
),
|
||||
patch(
|
||||
"govoplan_access.backend.security.sessions.utc_now",
|
||||
return_value=now,
|
||||
),
|
||||
):
|
||||
result = authenticate_session_token(
|
||||
session,
|
||||
"token",
|
||||
touch_interval_seconds=300,
|
||||
)
|
||||
|
||||
self.assertIs(model, result)
|
||||
if should_touch:
|
||||
self.assertEqual(now, model.last_seen_at)
|
||||
session.add.assert_called_once_with(model)
|
||||
else:
|
||||
self.assertEqual(
|
||||
now - timedelta(seconds=age_seconds),
|
||||
model.last_seen_at,
|
||||
)
|
||||
session.add.assert_not_called()
|
||||
|
||||
def test_api_key_activity_is_touched_only_after_the_interval(self) -> None:
|
||||
now = datetime(2026, 7, 29, 10, 0, tzinfo=timezone.utc)
|
||||
for age_seconds, should_touch in ((60, False), (301, True)):
|
||||
with self.subTest(age_seconds=age_seconds):
|
||||
model = SimpleNamespace(
|
||||
expires_at=None,
|
||||
last_used_at=now - timedelta(seconds=age_seconds),
|
||||
key_hash="hashed",
|
||||
)
|
||||
session = MagicMock()
|
||||
(
|
||||
session.query.return_value.options.return_value
|
||||
.filter.return_value.all
|
||||
).return_value = [model]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_access.backend.security.api_keys.verify_api_key",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"govoplan_access.backend.security.api_keys.utc_now",
|
||||
return_value=now,
|
||||
),
|
||||
):
|
||||
result = authenticate_api_key(
|
||||
session,
|
||||
"mm_test-token",
|
||||
touch_interval_seconds=300,
|
||||
)
|
||||
|
||||
self.assertIs(model, result)
|
||||
if should_touch:
|
||||
self.assertEqual(now, model.last_used_at)
|
||||
session.add.assert_called_once_with(model)
|
||||
else:
|
||||
self.assertEqual(
|
||||
now - timedelta(seconds=age_seconds),
|
||||
model.last_used_at,
|
||||
)
|
||||
session.add.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import unittest
|
||||
|
||||
unittest.main()
|
||||
@@ -0,0 +1,265 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from typing import Iterable
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from starlette.requests import Request
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import (
|
||||
_extract_token,
|
||||
_principal_idm_context,
|
||||
_requires_csrf,
|
||||
_resolve_legacy_principal_context,
|
||||
_resolve_legacy_principal_ref,
|
||||
)
|
||||
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||
from govoplan_access.backend.auth.tokens import hash_secret
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import Account, AuthSession, Role, User, UserRoleAssignment
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||
|
||||
|
||||
def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ()) -> Request:
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": method,
|
||||
"path": "/",
|
||||
"headers": [(key.lower().encode("latin-1"), value.encode("latin-1")) for key, value in headers],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AuthDependencyTests(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
principal_summary_cache.clear()
|
||||
|
||||
def test_extract_token_prefers_explicit_api_key(self) -> None:
|
||||
request = request_for(headers=[("authorization", "Bearer session-token")])
|
||||
|
||||
self.assertEqual(_extract_token(request, "Bearer session-token", "api-key-token"), ("api-key-token", "api_key"))
|
||||
|
||||
def test_extract_token_supports_bearer_and_cookie_sources(self) -> None:
|
||||
self.assertEqual(_extract_token(request_for(), "Bearer session-token", None), ("session-token", "bearer"))
|
||||
|
||||
cookie_request = request_for(headers=[("cookie", f"{settings.auth_session_cookie_name}=cookie-token")])
|
||||
self.assertEqual(_extract_token(cookie_request, None, None), ("cookie-token", "cookie"))
|
||||
|
||||
def test_requires_csrf_only_for_mutating_methods(self) -> None:
|
||||
self.assertFalse(_requires_csrf(request_for(method="GET")))
|
||||
self.assertTrue(_requires_csrf(request_for(method="POST")))
|
||||
|
||||
def test_legacy_principal_resolver_rejects_missing_token_before_db_lookup(self) -> None:
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
_resolve_legacy_principal_ref(request_for(), None, authorization=None, x_api_key=None) # type: ignore[arg-type]
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 401)
|
||||
self.assertEqual(raised.exception.detail, "Missing API key or session token")
|
||||
|
||||
def test_permission_revision_invalidates_cached_principal(self) -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(engine)
|
||||
AccessBase.metadata.create_all(bind=engine)
|
||||
Base.metadata.create_all(
|
||||
bind=engine,
|
||||
tables=[
|
||||
ChangeSequenceEntry.__table__,
|
||||
ChangeSequenceRetentionFloor.__table__,
|
||||
],
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
try:
|
||||
with SessionLocal() as session:
|
||||
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||
account = Account(
|
||||
id="account-1",
|
||||
email="owner@example.test",
|
||||
normalized_email="owner@example.test",
|
||||
)
|
||||
user = User(
|
||||
id="user-1",
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
)
|
||||
role = Role(
|
||||
id="role-1",
|
||||
tenant_id=tenant.id,
|
||||
slug="reader",
|
||||
name="Reader",
|
||||
permissions=["files:file:read"],
|
||||
)
|
||||
assignment = UserRoleAssignment(
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
)
|
||||
token = "ms_test-session-token"
|
||||
auth_session = AuthSession(
|
||||
id="session-1",
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
token_hash=hash_secret(token),
|
||||
expires_at=utc_now() + timedelta(hours=1),
|
||||
)
|
||||
session.add_all(
|
||||
[tenant, account, user, role, assignment, auth_session]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
request = request_for()
|
||||
first = _resolve_legacy_principal_context(
|
||||
request,
|
||||
session,
|
||||
authorization=f"Bearer {token}",
|
||||
x_api_key=None,
|
||||
)
|
||||
self.assertIn("files:file:read", first.principal.scopes)
|
||||
|
||||
role.permissions = ["files:file:write"]
|
||||
session.add(role)
|
||||
invalidate_auth_principals(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
source_module="access",
|
||||
resource_type="role",
|
||||
resource_id=role.id,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
second = _resolve_legacy_principal_context(
|
||||
request,
|
||||
session,
|
||||
authorization=f"Bearer {token}",
|
||||
x_api_key=None,
|
||||
)
|
||||
self.assertNotIn("files:file:read", second.principal.scopes)
|
||||
self.assertIn("files:file:write", second.principal.scopes)
|
||||
finally:
|
||||
AccessBase.metadata.drop_all(bind=engine)
|
||||
scope_registry.metadata.drop_all(bind=engine)
|
||||
Base.metadata.drop_all(
|
||||
bind=engine,
|
||||
tables=[
|
||||
ChangeSequenceEntry.__table__,
|
||||
ChangeSequenceRetentionFloor.__table__,
|
||||
],
|
||||
)
|
||||
engine.dispose()
|
||||
|
||||
def test_acting_assignment_requires_exact_session_selection(self) -> None:
|
||||
class Directory:
|
||||
def organization_function_assignments_for_account(
|
||||
self,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at=None,
|
||||
):
|
||||
del account_id, effective_at
|
||||
return (
|
||||
OrganizationFunctionAssignmentRef(
|
||||
id="direct-1",
|
||||
tenant_id=str(tenant_id),
|
||||
identity_id="identity-1",
|
||||
account_id="account-1",
|
||||
function_id="function-direct",
|
||||
organization_unit_id="unit-1",
|
||||
),
|
||||
OrganizationFunctionAssignmentRef(
|
||||
id="acting-1",
|
||||
tenant_id=str(tenant_id),
|
||||
identity_id="identity-1",
|
||||
account_id="account-1",
|
||||
function_id="function-acting",
|
||||
organization_unit_id="unit-1",
|
||||
source="acting_for",
|
||||
acting_for_account_id="represented-1",
|
||||
),
|
||||
)
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(engine)
|
||||
AccessBase.metadata.create_all(bind=engine)
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
try:
|
||||
with SessionLocal() as session:
|
||||
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||
account = Account(
|
||||
id="account-1",
|
||||
email="actor@example.test",
|
||||
normalized_email="actor@example.test",
|
||||
)
|
||||
user = User(
|
||||
id="user-1",
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
)
|
||||
auth_session = AuthSession(
|
||||
id="session-1",
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
token_hash="token-hash",
|
||||
expires_at=utc_now() + timedelta(hours=1),
|
||||
)
|
||||
session.add_all((tenant, account, user, auth_session))
|
||||
session.flush()
|
||||
|
||||
ordinary, _ = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=tenant.id,
|
||||
idm_directory=Directory(), # type: ignore[arg-type]
|
||||
organization_directory=None,
|
||||
)
|
||||
self.assertEqual([item.id for item in ordinary], ["direct-1"])
|
||||
|
||||
auth_session.acting_assignment_id = "acting-1"
|
||||
auth_session.acting_for_account_id = "represented-1"
|
||||
selected, _ = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=tenant.id,
|
||||
idm_directory=Directory(), # type: ignore[arg-type]
|
||||
organization_directory=None,
|
||||
auth_session=auth_session,
|
||||
)
|
||||
self.assertEqual(
|
||||
[item.id for item in selected],
|
||||
["direct-1", "acting-1"],
|
||||
)
|
||||
|
||||
auth_session.acting_for_account_id = "wrong-account"
|
||||
mismatched, _ = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=tenant.id,
|
||||
idm_directory=Directory(), # type: ignore[arg-type]
|
||||
organization_directory=None,
|
||||
auth_session=auth_session,
|
||||
)
|
||||
self.assertEqual([item.id for item in mismatched], ["direct-1"])
|
||||
finally:
|
||||
AccessBase.metadata.drop_all(bind=engine)
|
||||
scope_registry.metadata.drop_all(bind=engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,295 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import (
|
||||
AccessAutomationPrincipalProvider,
|
||||
)
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ServiceAccount,
|
||||
User,
|
||||
)
|
||||
from govoplan_access.backend.manifest import manifest
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
UserAuthorizationContext,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
)
|
||||
from govoplan_core.core.automation import AutomationPrincipalRequest
|
||||
from govoplan_core.tenancy.scope import (
|
||||
Tenant,
|
||||
create_scope_tables,
|
||||
scope_registry,
|
||||
)
|
||||
|
||||
|
||||
class AutomationPrincipalTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
AccessBase.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.account = Account(
|
||||
id="account-1",
|
||||
email="owner@example.test",
|
||||
normalized_email="owner@example.test",
|
||||
)
|
||||
self.tenant = Tenant(
|
||||
id="tenant-1",
|
||||
slug="tenant-1",
|
||||
name="Tenant 1",
|
||||
)
|
||||
self.user = User(
|
||||
id="user-1",
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=self.account.id,
|
||||
email=self.account.email,
|
||||
)
|
||||
self.session.add_all([self.tenant, self.account, self.user])
|
||||
self.session.commit()
|
||||
self.provider = AccessAutomationPrincipalProvider()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
AccessBase.metadata.drop_all(bind=self.engine)
|
||||
scope_registry.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def _request(self) -> AutomationPrincipalRequest:
|
||||
return AutomationPrincipalRequest(
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=self.account.id,
|
||||
membership_id=self.user.id,
|
||||
authorization_ref="dataflow-trigger:1",
|
||||
grant_scopes=(
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
),
|
||||
context={
|
||||
"trigger_ref": "dataflow-trigger:1",
|
||||
"delivery_ref": "dataflow-delivery:1",
|
||||
"event_actor": {
|
||||
"type": "user",
|
||||
"id": "event-user-1",
|
||||
},
|
||||
"operator_override": {
|
||||
"type": "user",
|
||||
"id": "operator-1",
|
||||
"reason": "approved replay",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def test_resolution_intersects_trigger_grant_with_current_scopes(self) -> None:
|
||||
context = UserAuthorizationContext(
|
||||
tenant_roles=[],
|
||||
system_roles=[],
|
||||
groups=[],
|
||||
function_assignment_ids=(),
|
||||
function_delegation_ids=(),
|
||||
scopes=[
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
"system:settings:write",
|
||||
],
|
||||
)
|
||||
with patch(
|
||||
"govoplan_access.backend.auth.dependencies."
|
||||
"collect_user_authorization_context",
|
||||
return_value=context,
|
||||
):
|
||||
result = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=self._request(),
|
||||
)
|
||||
|
||||
self.assertTrue(result.allowed)
|
||||
self.assertIsInstance(result.principal, ApiPrincipal)
|
||||
self.assertEqual(
|
||||
frozenset(
|
||||
{
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
}
|
||||
),
|
||||
result.principal.scopes,
|
||||
)
|
||||
self.assertIsNone(
|
||||
result.principal.principal.service_account_id
|
||||
)
|
||||
self.assertEqual(
|
||||
self.account.id,
|
||||
result.principal.principal.acting_for_account_id,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"system:settings:write",
|
||||
result.principal.scopes,
|
||||
)
|
||||
self.assertEqual(
|
||||
"delegated_user",
|
||||
result.provenance["trigger_owner"]["kind"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"event-user-1",
|
||||
result.provenance["event_actor"]["id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"operator-1",
|
||||
result.provenance["operator_override"]["id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.account.id,
|
||||
result.provenance[
|
||||
"current_automation_principal"
|
||||
]["account_id"],
|
||||
)
|
||||
|
||||
def test_revoked_scope_and_suspended_owner_fail_closed(self) -> None:
|
||||
context = UserAuthorizationContext(
|
||||
tenant_roles=[],
|
||||
system_roles=[],
|
||||
groups=[],
|
||||
function_assignment_ids=(),
|
||||
function_delegation_ids=(),
|
||||
scopes=["dataflow:pipeline:run"],
|
||||
)
|
||||
with patch(
|
||||
"govoplan_access.backend.auth.dependencies."
|
||||
"collect_user_authorization_context",
|
||||
return_value=context,
|
||||
):
|
||||
result = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=self._request(),
|
||||
)
|
||||
self.assertFalse(result.allowed)
|
||||
self.assertEqual(
|
||||
("datasources:catalogue:read",),
|
||||
result.missing_scopes,
|
||||
)
|
||||
|
||||
self.account.is_active = False
|
||||
self.session.flush()
|
||||
suspended = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=self._request(),
|
||||
)
|
||||
self.assertFalse(suspended.allowed)
|
||||
self.assertEqual(
|
||||
"inactive_or_inconsistent",
|
||||
suspended.provenance["status"],
|
||||
)
|
||||
|
||||
def test_service_account_resolution_uses_current_scope_ceiling(self) -> None:
|
||||
account = Account(
|
||||
id="service-account-backing",
|
||||
email="service@example.invalid",
|
||||
normalized_email="service@example.invalid",
|
||||
display_name="Import worker",
|
||||
auth_provider="service_account",
|
||||
)
|
||||
membership = User(
|
||||
id="service-membership",
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
auth_provider="service_account",
|
||||
)
|
||||
service_account = ServiceAccount(
|
||||
id="service-1",
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=account.id,
|
||||
membership_id=membership.id,
|
||||
name="Import worker",
|
||||
normalized_name="import worker",
|
||||
scope_ceiling=[
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
"system:settings:write",
|
||||
],
|
||||
is_active=True,
|
||||
revision=1,
|
||||
settings={},
|
||||
)
|
||||
self.session.add_all(
|
||||
(account, membership, service_account)
|
||||
)
|
||||
self.session.flush()
|
||||
request = AutomationPrincipalRequest.service_account(
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=service_account.id,
|
||||
authorization_ref="dataflow-trigger:service",
|
||||
grant_scopes=(
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
),
|
||||
)
|
||||
|
||||
result = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=request,
|
||||
)
|
||||
|
||||
self.assertTrue(result.allowed)
|
||||
self.assertEqual(
|
||||
service_account.id,
|
||||
result.principal.principal.service_account_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
frozenset(request.grant_scopes),
|
||||
result.principal.scopes,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"system:settings:write",
|
||||
result.principal.scopes,
|
||||
)
|
||||
self.assertEqual(
|
||||
"service_account",
|
||||
result.provenance["trigger_owner"]["kind"],
|
||||
)
|
||||
|
||||
service_account.scope_ceiling = [
|
||||
"dataflow:pipeline:run"
|
||||
]
|
||||
self.session.flush()
|
||||
reduced = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=request,
|
||||
)
|
||||
self.assertFalse(reduced.allowed)
|
||||
self.assertEqual(
|
||||
("datasources:catalogue:read",),
|
||||
reduced.missing_scopes,
|
||||
)
|
||||
|
||||
service_account.is_active = False
|
||||
self.session.flush()
|
||||
inactive = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=request,
|
||||
)
|
||||
self.assertFalse(inactive.allowed)
|
||||
self.assertEqual(
|
||||
"inactive_or_inconsistent",
|
||||
inactive.provenance["status"],
|
||||
)
|
||||
|
||||
def test_manifest_registers_automation_resolution(self) -> None:
|
||||
self.assertIn(
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
manifest.capability_factories,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,155 @@
|
||||
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.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_context_carries_operator_scopes_and_validated_infrastructure_receipt(self) -> None:
|
||||
receipt = SimpleNamespace(installation_id="deployment-1")
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
scopes=frozenset({"system:settings:write"}),
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"govoplan_access.backend.api.v1.routes.load_infrastructure_capability_receipt",
|
||||
return_value=receipt,
|
||||
),
|
||||
):
|
||||
context = _configuration_context(principal)
|
||||
|
||||
self.assertIs(receipt, context.infrastructure_receipt)
|
||||
self.assertEqual(
|
||||
frozenset({"system:settings:write"}),
|
||||
context.operator_scopes,
|
||||
)
|
||||
|
||||
def test_context_preserves_invalid_receipt_as_fail_closed_provider_state(self) -> None:
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
scopes=frozenset(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"govoplan_access.backend.api.v1.routes.load_infrastructure_capability_receipt",
|
||||
side_effect=InfrastructureCapabilityReceiptError("invalid receipt"),
|
||||
),
|
||||
):
|
||||
context = _configuration_context(principal)
|
||||
|
||||
self.assertIsNone(context.infrastructure_receipt)
|
||||
self.assertEqual("invalid receipt", context.infrastructure_receipt_error)
|
||||
|
||||
def test_context_projects_installed_external_provider_declarations(self) -> None:
|
||||
declaration = SimpleNamespace(
|
||||
id="connectors.example",
|
||||
to_dict=lambda: {
|
||||
"id": "connectors.example",
|
||||
"maturity": "read",
|
||||
"authority_modes": ["external_mirror"],
|
||||
},
|
||||
)
|
||||
registry = SimpleNamespace(
|
||||
manifests=lambda: (
|
||||
SimpleNamespace(id="access", version="0.1.14"),
|
||||
SimpleNamespace(id="connectors", version="0.1.14"),
|
||||
),
|
||||
capability_names=lambda: ("connectors.profiles",),
|
||||
external_provider_declarations=lambda: (declaration,),
|
||||
)
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||
return_value=registry,
|
||||
):
|
||||
context = _configuration_context(principal)
|
||||
|
||||
self.assertEqual("0.1.14", context.installed_modules["connectors"])
|
||||
self.assertIn("connectors.profiles", context.capabilities)
|
||||
self.assertEqual(
|
||||
"external_mirror",
|
||||
context.external_provider_declarations["connectors.example"][
|
||||
"authority_modes"
|
||||
][0],
|
||||
)
|
||||
|
||||
def test_context_projects_tenant_runtime_provider_state(self) -> None:
|
||||
declaration = SimpleNamespace(
|
||||
id="calendar.caldav_sync",
|
||||
to_dict=lambda: {
|
||||
"id": "calendar.caldav_sync",
|
||||
"maturity": "synchronize",
|
||||
"authority_modes": ["governed_sync"],
|
||||
},
|
||||
)
|
||||
registration = ExternalProviderStateProviderRegistration(
|
||||
module_id="calendar",
|
||||
provider_id="calendar.caldav_sync",
|
||||
provider=lambda context: (
|
||||
ExternalProviderRuntimeState(
|
||||
provider_id="calendar.caldav_sync",
|
||||
binding_ref="calendar:sync-source:one",
|
||||
authority_mode="governed_sync",
|
||||
observed_at=datetime(2026, 8, 1, 12, 0, tzinfo=UTC),
|
||||
configured=True,
|
||||
active=True,
|
||||
health="healthy",
|
||||
freshness="current",
|
||||
conflict="clear",
|
||||
recovery="ready",
|
||||
metrics={"tenant_matches": context.tenant_id == "tenant-1"},
|
||||
),
|
||||
),
|
||||
)
|
||||
registry = SimpleNamespace(
|
||||
manifests=lambda: (SimpleNamespace(id="calendar", version="0.1.8"),),
|
||||
capability_names=lambda: (),
|
||||
external_provider_declarations=lambda: (declaration,),
|
||||
external_provider_state_providers=lambda: (registration,),
|
||||
)
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||
return_value=registry,
|
||||
):
|
||||
context = _configuration_context(principal, session=object())
|
||||
|
||||
state = context.external_provider_states["calendar.caldav_sync"]
|
||||
self.assertEqual("healthy", state["health"])
|
||||
self.assertEqual("calendar:sync-source:one", state["binding_ref"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.admin.service import ensure_default_roles, set_user_roles
|
||||
from govoplan_access.backend.db.models import Account, Role, User, UserRoleAssignment
|
||||
from govoplan_access.backend.security.sessions import collect_user_authorization_context
|
||||
from govoplan_core.core.modules import RoleTemplate
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||
|
||||
|
||||
DOCS_READER = RoleTemplate(
|
||||
slug="docs_reader",
|
||||
name="Documentation reader",
|
||||
description="Authenticated documentation baseline.",
|
||||
permissions=("docs:documentation:read",),
|
||||
default_authenticated=True,
|
||||
)
|
||||
|
||||
|
||||
class DefaultAuthenticatedRoleTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||
self.session.add(self.tenant)
|
||||
self.session.flush()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
scope_registry.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def _add_user(self, suffix: str) -> User:
|
||||
account = Account(
|
||||
id=f"account-{suffix}",
|
||||
email=f"{suffix}@example.test",
|
||||
normalized_email=f"{suffix}@example.test",
|
||||
)
|
||||
user = User(
|
||||
id=f"user-{suffix}",
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
)
|
||||
self.session.add_all([account, user])
|
||||
self.session.flush()
|
||||
return user
|
||||
|
||||
def test_materialized_default_role_is_implicit_and_cannot_be_assigned_or_removed(self) -> None:
|
||||
first = self._add_user("first")
|
||||
self._add_user("second")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_access.backend.admin.service.role_templates_for_level",
|
||||
return_value=(DOCS_READER,),
|
||||
),
|
||||
patch(
|
||||
"govoplan_access.backend.security.sessions.role_templates_for_level",
|
||||
return_value=(DOCS_READER,),
|
||||
),
|
||||
):
|
||||
roles = ensure_default_roles(self.session, self.tenant)
|
||||
default_role = roles["docs_reader"]
|
||||
self.assertFalse(default_role.is_assignable)
|
||||
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
|
||||
|
||||
# The database row is only an administration projection. Even if
|
||||
# stale or tampered, it must never broaden the manifest baseline.
|
||||
default_role.permissions = ["mail:profile:write"]
|
||||
self.session.flush()
|
||||
|
||||
set_user_roles(self.session, user=first, role_ids=[])
|
||||
context = collect_user_authorization_context(
|
||||
self.session,
|
||||
first,
|
||||
account=first.account,
|
||||
include_system=False,
|
||||
)
|
||||
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
|
||||
self.assertIn("docs:documentation:read", context.scopes)
|
||||
self.assertNotIn("mail:profile:write", context.scopes)
|
||||
self.assertEqual([role.slug for role in context.tenant_roles], ["docs_reader"])
|
||||
|
||||
def test_first_authorized_request_grants_default_without_database_mutation(self) -> None:
|
||||
user = self._add_user("new")
|
||||
|
||||
with patch(
|
||||
"govoplan_access.backend.security.sessions.role_templates_for_level",
|
||||
return_value=(DOCS_READER,),
|
||||
):
|
||||
context = collect_user_authorization_context(
|
||||
self.session,
|
||||
user,
|
||||
account=user.account,
|
||||
include_system=False,
|
||||
)
|
||||
|
||||
self.assertIn("docs:documentation:read", context.scopes)
|
||||
self.assertEqual(context.tenant_roles, [])
|
||||
self.assertEqual(self.session.query(Role).count(), 0)
|
||||
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
|
||||
self.assertFalse(self.session.new)
|
||||
self.assertFalse(self.session.dirty)
|
||||
|
||||
user_id = user.id
|
||||
self.session.commit()
|
||||
self.session.close()
|
||||
self.session = self.Session()
|
||||
persisted_user = self.session.get(User, user_id)
|
||||
with patch(
|
||||
"govoplan_access.backend.security.sessions.role_templates_for_level",
|
||||
return_value=(DOCS_READER,),
|
||||
):
|
||||
reopened_context = collect_user_authorization_context(
|
||||
self.session,
|
||||
persisted_user,
|
||||
account=persisted_user.account,
|
||||
include_system=False,
|
||||
)
|
||||
self.assertIn("docs:documentation:read", reopened_context.scopes)
|
||||
self.assertEqual(self.session.query(Role).count(), 0)
|
||||
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, User
|
||||
from govoplan_access.backend.dsar_provider import AccessDsarProvider
|
||||
from govoplan_core.core.dsar import DsarSubjectRef
|
||||
|
||||
|
||||
class AccessDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine(
|
||||
"sqlite+pysqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
AccessBase.metadata.create_all(bind=self.engine)
|
||||
self.session = sessionmaker(bind=self.engine, expire_on_commit=False)()
|
||||
self.account = Account(
|
||||
id="account-1",
|
||||
email="ada@example.test",
|
||||
normalized_email="ada@example.test",
|
||||
display_name="Ada Example",
|
||||
password_hash="secret-hash",
|
||||
)
|
||||
self.user = User(
|
||||
id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id=self.account.id,
|
||||
email="ada@example.test",
|
||||
display_name="Ada Example",
|
||||
password_hash="tenant-secret-hash",
|
||||
settings={"locale": "de"},
|
||||
mail_profile_policy={"profile": "one"},
|
||||
)
|
||||
self.key = ApiKey(
|
||||
id="key-1",
|
||||
tenant_id="tenant-1",
|
||||
user_id=self.user.id,
|
||||
name="Automation",
|
||||
prefix="gpn_example",
|
||||
key_hash="do-not-export",
|
||||
scopes=["files:read"],
|
||||
)
|
||||
self.auth_session = AuthSession(
|
||||
id="session-1",
|
||||
tenant_id="tenant-1",
|
||||
user_id=self.user.id,
|
||||
account_id=self.account.id,
|
||||
token_hash="do-not-export",
|
||||
csrf_token_hash="do-not-export",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
user_agent="Browser fingerprint",
|
||||
ip_address="192.0.2.10",
|
||||
)
|
||||
self.session.add_all([self.account, self.user, self.key, self.auth_session])
|
||||
self.session.commit()
|
||||
self.provider = AccessDsarProvider()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
AccessBase.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_search_omits_secret_and_client_fingerprint_material(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(email="ADA@example.test"),
|
||||
)
|
||||
serialized = repr([record.to_dict() for record in records])
|
||||
self.assertIn("membership-1", serialized)
|
||||
self.assertNotIn("do-not-export", serialized)
|
||||
self.assertNotIn("Browser fingerprint", serialized)
|
||||
self.assertNotIn("192.0.2.10", serialized)
|
||||
|
||||
def test_multiple_subject_selectors_must_identify_the_same_membership(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
membership_id=self.user.id,
|
||||
email="different@example.test",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual((), records)
|
||||
|
||||
def test_plan_and_execution_anonymize_membership_and_revoke_credentials(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id=self.account.id),
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id=self.account.id),
|
||||
records=records,
|
||||
)
|
||||
executable = tuple(action for action in actions if action.executable)
|
||||
self.assertEqual(3, len(executable))
|
||||
self.assertTrue(any(action.kind == "manual_review" for action in actions))
|
||||
|
||||
results = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id=self.account.id),
|
||||
actions=executable,
|
||||
request_id="dsar-1",
|
||||
)
|
||||
self.assertEqual({"executed"}, {result.status for result in results})
|
||||
self.assertTrue(self.user.email.endswith("@invalid.govoplan"))
|
||||
self.assertFalse(self.user.is_active)
|
||||
self.assertEqual({}, self.user.settings)
|
||||
self.assertIsNotNone(self.key.revoked_at)
|
||||
self.assertIsNotNone(self.auth_session.revoked_at)
|
||||
self.assertIsNone(self.auth_session.user_agent)
|
||||
self.assertIsNone(self.auth_session.ip_address)
|
||||
self.assertEqual("ada@example.test", self.account.email)
|
||||
|
||||
repeated = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id=self.account.id),
|
||||
actions=executable,
|
||||
request_id="dsar-1",
|
||||
)
|
||||
self.assertEqual({"unchanged"}, {result.status for result in repeated})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,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,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_access.backend.manifest import manifest
|
||||
|
||||
|
||||
class InterfaceDocumentationContractTests(unittest.TestCase):
|
||||
def test_access_admin_topics_publish_stable_help_contexts(self) -> None:
|
||||
topics = {topic.id: topic for topic in manifest.documentation}
|
||||
|
||||
expected_contexts = {
|
||||
"access.workflow.grant-user-access": {
|
||||
"access.admin.users",
|
||||
"access.admin.groups",
|
||||
"access.admin.roles",
|
||||
"access.admin.blocked",
|
||||
},
|
||||
"access.reference.admin-access-fields": {
|
||||
"access.admin.system-users",
|
||||
"access.admin.system-roles",
|
||||
"access.admin.tenant-users",
|
||||
"access.admin.tenant-groups",
|
||||
"access.admin.tenant-roles",
|
||||
},
|
||||
"access.workflow.manage-api-keys": {
|
||||
"access.admin.api-keys",
|
||||
"access.api-keys.action.create",
|
||||
"access.api-keys.action.revoke",
|
||||
"access.api-keys.field.owner",
|
||||
"access.api-keys.field.expiry",
|
||||
"access.api-keys.field.scopes",
|
||||
"access.api-keys.secret",
|
||||
"access.api-keys.confirm-revoke",
|
||||
},
|
||||
"access.workflow.manage-reusable-credentials": {
|
||||
"access.admin.system-credentials",
|
||||
"access.admin.tenant-credentials",
|
||||
"access.admin.group-credentials",
|
||||
"access.admin.user-credentials",
|
||||
"access.settings.credentials",
|
||||
"access.credentials",
|
||||
"access.credentials.field.secret",
|
||||
"access.credentials.field.clear-secret",
|
||||
"access.credentials.field.inherit-to-lower-scopes",
|
||||
"access.credentials.action.delete",
|
||||
"access.credentials.confirm-delete",
|
||||
},
|
||||
"access.reference.external-function-role-mappings": {
|
||||
"access.admin.function-mappings",
|
||||
"access.explanation",
|
||||
},
|
||||
"access.workflow.manage-service-account-credentials": {
|
||||
"access.admin.service-accounts",
|
||||
"access.service-accounts.action.create",
|
||||
"access.service-accounts.action.activation",
|
||||
"access.service-accounts.action.retire",
|
||||
"access.service-accounts.field.scope-ceiling",
|
||||
"access.service-accounts.action.rotate-credential",
|
||||
"access.service-accounts.action.revoke-credential",
|
||||
"access.service-accounts.field.credential-expiry",
|
||||
"access.service-accounts.field.credential-scopes",
|
||||
"access.service-accounts.secret",
|
||||
"access.service-accounts.confirm-retire",
|
||||
},
|
||||
"access.workflow.manage-sessions": {
|
||||
"access.settings.sessions",
|
||||
"access.sessions.action.revoke",
|
||||
"access.sessions.action.revoke-others",
|
||||
"access.admin.user-sessions",
|
||||
},
|
||||
}
|
||||
|
||||
for topic_id, expected in expected_contexts.items():
|
||||
self.assertIn(topic_id, topics)
|
||||
metadata = topics[topic_id].metadata or {}
|
||||
self.assertTrue(
|
||||
expected.issubset(set(metadata.get("help_contexts", ()))),
|
||||
topic_id,
|
||||
)
|
||||
|
||||
credential_topic = topics["access.workflow.manage-reusable-credentials"]
|
||||
self.assertEqual(
|
||||
{"title", "summary", "body"},
|
||||
set(credential_topic.translations["de"]),
|
||||
)
|
||||
self.assertIn(
|
||||
"nicht rückgängig gemacht",
|
||||
credential_topic.translations["de"]["body"],
|
||||
)
|
||||
|
||||
api_key_topic = topics["access.workflow.manage-api-keys"]
|
||||
self.assertEqual(
|
||||
{"title", "summary", "body"},
|
||||
set(api_key_topic.translations["de"]),
|
||||
)
|
||||
self.assertIn(
|
||||
"sofort und kann für diesen Schlüssel nicht rückgängig gemacht werden",
|
||||
api_key_topic.translations["de"]["body"],
|
||||
)
|
||||
|
||||
service_account_topic = topics[
|
||||
"access.workflow.manage-service-account-credentials"
|
||||
]
|
||||
self.assertEqual(
|
||||
{"title", "summary", "body"},
|
||||
set(service_account_topic.translations["de"]),
|
||||
)
|
||||
self.assertIn(
|
||||
"widerruft sämtliche aktiven Zugangsdaten",
|
||||
service_account_topic.translations["de"]["body"],
|
||||
)
|
||||
|
||||
def test_access_admin_surfaces_remain_declared(self) -> None:
|
||||
surface_ids = {
|
||||
surface.id for surface in manifest.frontend.view_surfaces
|
||||
}
|
||||
self.assertTrue(
|
||||
{
|
||||
"access.admin.system-roles",
|
||||
"access.admin.system-users",
|
||||
"access.admin.system-credentials",
|
||||
"access.admin.tenant-roles",
|
||||
"access.admin.tenant-function-mappings",
|
||||
"access.admin.tenant-groups",
|
||||
"access.admin.tenant-users",
|
||||
"access.admin.tenant-credentials",
|
||||
"access.admin.tenant-api-keys",
|
||||
"access.admin.tenant-service-accounts",
|
||||
"access.admin.group-credentials",
|
||||
"access.admin.user-credentials",
|
||||
"access.settings.credentials",
|
||||
"access.settings.sessions",
|
||||
}.issubset(surface_ids)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_access.backend.api.v1 import auth
|
||||
from govoplan_access.backend.security.passwords import (
|
||||
DUMMY_PASSWORD_HASH,
|
||||
verify_password,
|
||||
)
|
||||
from govoplan_core.api.v1.schemas import LoginRequest
|
||||
|
||||
|
||||
class LoginSecurityTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _session_with_account(account: object | None) -> MagicMock:
|
||||
session = MagicMock()
|
||||
session.query.return_value.filter.return_value.one_or_none.return_value = (
|
||||
account
|
||||
)
|
||||
return session
|
||||
|
||||
def test_absent_identity_verifies_a_valid_dummy_hash_before_generic_failure(
|
||||
self,
|
||||
) -> None:
|
||||
self.assertTrue(verify_password("not-a-user-password", DUMMY_PASSWORD_HASH))
|
||||
payload = LoginRequest(
|
||||
email="missing@example.test", password="attempted-password"
|
||||
)
|
||||
|
||||
with patch.object(auth, "verify_password", return_value=True) as verifier:
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
auth._resolve_login_user(self._session_with_account(None), payload)
|
||||
|
||||
verifier.assert_called_once_with(payload.password, DUMMY_PASSWORD_HASH)
|
||||
self.assertEqual(raised.exception.status_code, 401)
|
||||
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||
|
||||
def test_present_identity_verifies_account_hash_before_same_generic_failure(
|
||||
self,
|
||||
) -> None:
|
||||
account_hash = "pbkdf2_sha256$260000$account-salt$account-digest"
|
||||
account = SimpleNamespace(password_hash=account_hash)
|
||||
payload = LoginRequest(email="known@example.test", password="wrong-password")
|
||||
|
||||
with patch.object(auth, "verify_password", return_value=False) as verifier:
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
auth._resolve_login_user(self._session_with_account(account), payload) # type: ignore[arg-type]
|
||||
|
||||
verifier.assert_called_once_with(payload.password, account_hash)
|
||||
self.assertEqual(raised.exception.status_code, 401)
|
||||
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||
|
||||
def test_passwordless_account_cannot_authenticate_with_dummy_password(self) -> None:
|
||||
account = SimpleNamespace(password_hash=None)
|
||||
payload = LoginRequest(
|
||||
email="passwordless@example.test", password="not-a-user-password"
|
||||
)
|
||||
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
auth._resolve_login_user(self._session_with_account(account), payload) # type: ignore[arg-type]
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 401)
|
||||
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||
|
||||
def test_account_without_active_membership_uses_same_generic_failure(self) -> None:
|
||||
account = SimpleNamespace(
|
||||
id="account-1",
|
||||
password_hash="pbkdf2_sha256$260000$account-salt$account-digest",
|
||||
)
|
||||
session = self._session_with_account(account)
|
||||
session.query.return_value.join.return_value.filter.return_value.order_by.return_value.first.return_value = None
|
||||
payload = LoginRequest(email="known@example.test", password="correct-password")
|
||||
|
||||
with patch.object(auth, "verify_password", return_value=True):
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
auth._resolve_login_user(session, payload) # type: ignore[arg-type]
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 401)
|
||||
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,219 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from govoplan_access.backend.api.v1 import auth
|
||||
from govoplan_access.backend.security.login_throttle import (
|
||||
AttemptBucket,
|
||||
InMemoryLoginAttemptStore,
|
||||
LoginThrottle,
|
||||
LoginThrottleDecision,
|
||||
ResilientLoginAttemptStore,
|
||||
)
|
||||
from govoplan_core.api.v1.schemas import LoginRequest
|
||||
|
||||
|
||||
class LoginThrottleTests(unittest.TestCase):
|
||||
def test_identity_and_client_buckets_enforce_independent_limits(self) -> None:
|
||||
throttle = LoginThrottle(
|
||||
InMemoryLoginAttemptStore(),
|
||||
identity_limit=2,
|
||||
client_limit=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
context = {
|
||||
"normalized_email": "person@example.test",
|
||||
"tenant_slug": "tenant-a",
|
||||
"client_address": "192.0.2.4",
|
||||
}
|
||||
|
||||
self.assertTrue(throttle.record_failure(**context).allowed)
|
||||
self.assertFalse(throttle.record_failure(**context).allowed)
|
||||
|
||||
other_identity = {**context, "normalized_email": "other@example.test"}
|
||||
self.assertFalse(throttle.record_failure(**other_identity).allowed)
|
||||
|
||||
def test_success_clears_identity_bucket_without_erasing_client_failures(self) -> None:
|
||||
throttle = LoginThrottle(
|
||||
InMemoryLoginAttemptStore(),
|
||||
identity_limit=2,
|
||||
client_limit=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
context = {
|
||||
"normalized_email": "person@example.test",
|
||||
"tenant_slug": "tenant-a",
|
||||
"client_address": "192.0.2.8",
|
||||
}
|
||||
self.assertTrue(throttle.record_failure(**context).allowed)
|
||||
|
||||
throttle.record_success(
|
||||
normalized_email=context["normalized_email"],
|
||||
tenant_slug=context["tenant_slug"],
|
||||
)
|
||||
|
||||
self.assertTrue(throttle.check(**context).allowed)
|
||||
other_identity = {**context, "normalized_email": "other@example.test"}
|
||||
self.assertTrue(throttle.record_failure(**other_identity).allowed)
|
||||
self.assertFalse(throttle.record_failure(**other_identity).allowed)
|
||||
|
||||
def test_rotating_untrusted_tenant_slug_does_not_bypass_identity_limit(self) -> None:
|
||||
throttle = LoginThrottle(
|
||||
InMemoryLoginAttemptStore(),
|
||||
identity_limit=2,
|
||||
client_limit=100,
|
||||
window_seconds=60,
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
throttle.record_failure(
|
||||
normalized_email="person@example.test",
|
||||
tenant_slug="tenant-a",
|
||||
client_address="192.0.2.1",
|
||||
).allowed
|
||||
)
|
||||
self.assertFalse(
|
||||
throttle.record_failure(
|
||||
normalized_email="person@example.test",
|
||||
tenant_slug="made-up-tenant",
|
||||
client_address="198.51.100.9",
|
||||
).allowed
|
||||
)
|
||||
|
||||
def test_bucket_keys_do_not_contain_identity_or_client_data(self) -> None:
|
||||
store = MagicMock()
|
||||
store.increment.return_value = AttemptBucket(count=1, retry_after_seconds=60)
|
||||
throttle = LoginThrottle(
|
||||
store,
|
||||
identity_limit=10,
|
||||
client_limit=100,
|
||||
window_seconds=60,
|
||||
)
|
||||
|
||||
throttle.record_failure(
|
||||
normalized_email="private.person@example.test",
|
||||
tenant_slug="private-tenant",
|
||||
client_address="192.0.2.9",
|
||||
)
|
||||
|
||||
keys = [call.args[0] for call in store.increment.call_args_list]
|
||||
self.assertEqual(len(keys), 2)
|
||||
for key in keys:
|
||||
self.assertNotIn("private", key)
|
||||
self.assertNotIn("example", key)
|
||||
self.assertNotIn("192.0.2.9", key)
|
||||
|
||||
def test_redis_failure_uses_local_store_during_retry_window(self) -> None:
|
||||
primary = MagicMock()
|
||||
primary.read.side_effect = RedisError("not available")
|
||||
fallback = InMemoryLoginAttemptStore()
|
||||
store = ResilientLoginAttemptStore(primary, fallback, retry_seconds=60)
|
||||
|
||||
with self.assertLogs(
|
||||
"govoplan_access.backend.security.login_throttle",
|
||||
level="WARNING",
|
||||
) as captured:
|
||||
self.assertEqual(store.read("bucket"), AttemptBucket())
|
||||
result = store.increment("bucket", window_seconds=60)
|
||||
|
||||
self.assertEqual(result.count, 1)
|
||||
self.assertEqual(primary.read.call_count, 1)
|
||||
primary.increment.assert_not_called()
|
||||
self.assertIn("process-local fallback", captured.output[0])
|
||||
|
||||
def test_redis_recovery_does_not_erase_failures_counted_by_the_fallback(self) -> None:
|
||||
primary = MagicMock()
|
||||
primary.read.side_effect = [RedisError("not available"), AttemptBucket(count=1, retry_after_seconds=30)]
|
||||
primary.increment.return_value = AttemptBucket(count=2, retry_after_seconds=30)
|
||||
fallback = InMemoryLoginAttemptStore()
|
||||
store = ResilientLoginAttemptStore(primary, fallback, retry_seconds=60)
|
||||
|
||||
with self.assertLogs("govoplan_access.backend.security.login_throttle", level="WARNING"):
|
||||
store.read("bucket")
|
||||
store.increment("bucket", window_seconds=60)
|
||||
store.increment("bucket", window_seconds=60)
|
||||
|
||||
store._primary_unavailable_until = 0 # Simulate the next Redis retry window.
|
||||
recovered = store.read("bucket")
|
||||
incremented = store.increment("bucket", window_seconds=60)
|
||||
|
||||
self.assertEqual(recovered.count, 2)
|
||||
self.assertEqual(incremented.count, 3)
|
||||
|
||||
def test_in_memory_store_is_bounded(self) -> None:
|
||||
store = InMemoryLoginAttemptStore(max_entries=2)
|
||||
for key in ("one", "two", "three"):
|
||||
store.increment(key, window_seconds=60)
|
||||
|
||||
active = sum(store.read(key).count > 0 for key in ("one", "two", "three"))
|
||||
self.assertEqual(active, 2)
|
||||
|
||||
|
||||
class LoginThrottleRouteTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.payload = LoginRequest(
|
||||
email="Person@Example.Test",
|
||||
password="attempt",
|
||||
tenant_slug="tenant-a",
|
||||
)
|
||||
self.request = SimpleNamespace(client=SimpleNamespace(host="192.0.2.10"))
|
||||
|
||||
def test_throttled_identity_gets_same_generic_failure_detail(self) -> None:
|
||||
throttle = MagicMock()
|
||||
throttle.check.return_value = LoginThrottleDecision(False, 42)
|
||||
|
||||
with patch.object(auth, "_configured_login_throttle", return_value=throttle):
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 429)
|
||||
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||
self.assertEqual(raised.exception.headers, {"Retry-After": "42"})
|
||||
|
||||
def test_failed_login_is_counted_and_threshold_response_stays_generic(self) -> None:
|
||||
throttle = MagicMock()
|
||||
throttle.check.return_value = LoginThrottleDecision(True)
|
||||
throttle.record_failure.return_value = LoginThrottleDecision(False, 60)
|
||||
generic_failure = HTTPException(status_code=401, detail="Invalid login")
|
||||
|
||||
with (
|
||||
patch.object(auth, "_configured_login_throttle", return_value=throttle),
|
||||
patch.object(auth, "_resolve_login_user", side_effect=generic_failure),
|
||||
):
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 429)
|
||||
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||
throttle.record_failure.assert_called_once_with(
|
||||
normalized_email="person@example.test",
|
||||
tenant_slug="tenant-a",
|
||||
client_address="192.0.2.10",
|
||||
)
|
||||
|
||||
def test_success_clears_only_the_identity_bucket(self) -> None:
|
||||
throttle = MagicMock()
|
||||
throttle.check.return_value = LoginThrottleDecision(True)
|
||||
resolved = (object(), object(), object())
|
||||
|
||||
with (
|
||||
patch.object(auth, "_configured_login_throttle", return_value=throttle),
|
||||
patch.object(auth, "_resolve_login_user", return_value=resolved),
|
||||
):
|
||||
result = auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
|
||||
|
||||
self.assertEqual(result, resolved)
|
||||
throttle.record_failure.assert_not_called()
|
||||
throttle.record_success.assert_called_once_with(
|
||||
normalized_email="person@example.test",
|
||||
tenant_slug="tenant-a",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -5,18 +5,31 @@ 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.6", dependencies)
|
||||
self.assertNotIn("govoplan-tenancy>=0.1.6", dependencies)
|
||||
self.assertTrue(
|
||||
any(item.startswith("govoplan-core>=") for item in dependencies)
|
||||
)
|
||||
self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies)
|
||||
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))
|
||||
|
||||
def test_tenancy_is_declared_as_optional_module_integration(self) -> None:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.models import Account, User
|
||||
from govoplan_access.backend.manifest import manifest
|
||||
from govoplan_access.backend.people_search import AccessPeopleSearchProvider
|
||||
from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH, PeopleSearchError, PeopleSearchProvider
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class AccessPeopleSearchTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.provider = AccessPeopleSearchProvider()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def _add_user(
|
||||
self,
|
||||
suffix: str,
|
||||
*,
|
||||
tenant_id: str = "tenant-1",
|
||||
display_name: str | None = None,
|
||||
user_active: bool = True,
|
||||
account_active: bool = True,
|
||||
) -> None:
|
||||
email = f"{suffix}@example.test"
|
||||
account = Account(
|
||||
id=f"account-{suffix}",
|
||||
email=email,
|
||||
normalized_email=email,
|
||||
display_name=display_name,
|
||||
is_active=account_active,
|
||||
)
|
||||
self.session.add_all([
|
||||
account,
|
||||
User(
|
||||
id=f"user-{suffix}",
|
||||
tenant_id=tenant_id,
|
||||
account_id=account.id,
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
is_active=user_active,
|
||||
),
|
||||
])
|
||||
self.session.flush()
|
||||
|
||||
def test_search_is_tenant_bounded_and_excludes_inactive_records(self) -> None:
|
||||
self._add_user("ada", display_name="Ada Lovelace")
|
||||
self._add_user("other", tenant_id="tenant-2", display_name="Ada Other Tenant")
|
||||
self._add_user("inactive-user", display_name="Ada Inactive User", user_active=False)
|
||||
self._add_user("inactive-account", display_name="Ada Inactive Account", account_active=False)
|
||||
|
||||
groups = self.provider.search_people(
|
||||
self.session,
|
||||
SimpleNamespace(tenant_id="tenant-1"),
|
||||
query="ada",
|
||||
)
|
||||
|
||||
self.assertEqual([group.key for group in groups], ["accounts"])
|
||||
self.assertEqual([item.reference_id for item in groups[0].candidates], ["account-ada"])
|
||||
self.assertEqual(groups[0].candidates[0].email, "ada@example.test")
|
||||
self.assertNotIn("tenant_id", groups[0].candidates[0].metadata)
|
||||
|
||||
def test_search_escapes_like_wildcards_and_requires_tenant_context(self) -> None:
|
||||
self._add_user("ada", display_name="Ada Lovelace")
|
||||
|
||||
groups = self.provider.search_people(
|
||||
self.session,
|
||||
SimpleNamespace(tenant_id="tenant-1"),
|
||||
query="%",
|
||||
)
|
||||
self.assertEqual(groups[0].candidates, ())
|
||||
with self.assertRaises(PeopleSearchError):
|
||||
self.provider.search_people(self.session, SimpleNamespace(tenant_id=None), query="ada")
|
||||
|
||||
def test_manifest_exposes_the_shared_interface(self) -> None:
|
||||
self.assertIn(CAPABILITY_ACCESS_PEOPLE_SEARCH, manifest.capability_factories)
|
||||
self.assertIn(CAPABILITY_ACCESS_PEOPLE_SEARCH, {item.name for item in manifest.provides_interfaces})
|
||||
self.assertIsInstance(self.provider, PeopleSearchProvider)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_access.backend.permissions import catalog as access_catalog
|
||||
from govoplan_core.security import permissions as core_permissions
|
||||
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
|
||||
|
||||
|
||||
class PermissionCatalogContractTests(unittest.TestCase):
|
||||
def test_access_reuses_core_legacy_scope_aliases(self) -> None:
|
||||
self.assertIs(access_catalog.LEGACY_SCOPE_ALIASES, LEGACY_SCOPE_ALIASES)
|
||||
self.assertIs(core_permissions.LEGACY_SCOPE_ALIASES, LEGACY_SCOPE_ALIASES)
|
||||
|
||||
def test_legacy_alias_grants_match_in_core_and_access(self) -> None:
|
||||
for legacy_scope, aliases in LEGACY_SCOPE_ALIASES.items():
|
||||
for alias in aliases:
|
||||
self.assertTrue(core_permissions.scope_grants(legacy_scope, alias))
|
||||
self.assertTrue(access_catalog.scope_grants(legacy_scope, alias))
|
||||
|
||||
def test_access_legacy_catalog_includes_non_access_platform_scopes(self) -> None:
|
||||
scopes = {permission.scope for permission in access_catalog.permission_catalog(include_legacy=True)}
|
||||
self.assertIn("campaign:queue", scopes)
|
||||
self.assertIn("files:upload", scopes)
|
||||
self.assertIn("mail_servers:test", scopes)
|
||||
|
||||
def test_expand_scopes_preserves_explicit_canonical_scope_with_legacy_alias(self) -> None:
|
||||
scopes = access_catalog.expand_scopes(("files:file:read",))
|
||||
|
||||
self.assertIn("files:file:read", scopes)
|
||||
self.assertIn("files:read", scopes)
|
||||
|
||||
|
||||
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,247 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import Account, AuthSession, User
|
||||
from govoplan_access.backend.security.sessions import authenticate_session_token, hash_session_token
|
||||
from govoplan_access.backend.security.passwords import hash_password
|
||||
from govoplan_access.backend.api.v1.admin_schemas import AdminSessionItem
|
||||
from govoplan_access.backend.api.v1.auth import AccountSessionInfo
|
||||
from govoplan_access.backend.api.v1.routes import _require_session_admin_reauthorization
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from fastapi import HTTPException
|
||||
from govoplan_access.backend.session_management import (
|
||||
MAX_CLIENT_LABEL_LENGTH,
|
||||
list_account_sessions,
|
||||
revoke_account_session,
|
||||
revoke_other_account_sessions,
|
||||
)
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||
|
||||
|
||||
class SessionManagementTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
AccessBase.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.now = datetime(2026, 8, 19, 20, 0, tzinfo=timezone.utc)
|
||||
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||
other_tenant = Tenant(id="tenant-2", slug="tenant-2", name="Tenant 2")
|
||||
self.account = Account(
|
||||
id="account-1",
|
||||
email="person@example.test",
|
||||
normalized_email="person@example.test",
|
||||
)
|
||||
other_account = Account(
|
||||
id="account-2",
|
||||
email="other@example.test",
|
||||
normalized_email="other@example.test",
|
||||
)
|
||||
user = User(
|
||||
id="user-1",
|
||||
tenant_id=tenant.id,
|
||||
account_id=self.account.id,
|
||||
email=self.account.email,
|
||||
)
|
||||
other_user = User(
|
||||
id="user-2",
|
||||
tenant_id=tenant.id,
|
||||
account_id=other_account.id,
|
||||
email=other_account.email,
|
||||
)
|
||||
self.session.add_all((tenant, other_tenant, self.account, other_account, user, other_user))
|
||||
self.session.flush()
|
||||
self.tokens = {
|
||||
"current": "ms_current",
|
||||
"other": "ms_other",
|
||||
"expired": "ms_expired",
|
||||
"revoked": "ms_revoked",
|
||||
"other-account": "ms_other_account",
|
||||
}
|
||||
self.session.add_all(
|
||||
(
|
||||
self._auth_session("current", "tenant-1", "user-1", "account-1"),
|
||||
self._auth_session("other", "tenant-2", "user-1", "account-1"),
|
||||
self._auth_session("expired", "tenant-1", "user-1", "account-1", expires=-1),
|
||||
self._auth_session("revoked", "tenant-1", "user-1", "account-1", revoked=True),
|
||||
self._auth_session("other-account", "tenant-1", "user-2", "account-2"),
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
AccessBase.metadata.drop_all(bind=self.engine)
|
||||
scope_registry.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def _auth_session(
|
||||
self,
|
||||
name: str,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
account_id: str,
|
||||
*,
|
||||
expires: int = 2,
|
||||
revoked: bool = False,
|
||||
) -> AuthSession:
|
||||
return AuthSession(
|
||||
id=f"session-{name}",
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
token_hash=hash_session_token(self.tokens[name]),
|
||||
expires_at=self.now + timedelta(hours=expires),
|
||||
last_seen_at=self.now - timedelta(minutes=5),
|
||||
revoked_at=self.now - timedelta(minutes=1) if revoked else None,
|
||||
user_agent="Browser " + ("x" * 500),
|
||||
ip_address="192.0.2.55",
|
||||
)
|
||||
|
||||
def test_listing_is_account_scoped_bounded_and_redacted(self) -> None:
|
||||
sensitive = {
|
||||
"token",
|
||||
"token_hash",
|
||||
"csrf_token_hash",
|
||||
"cookie",
|
||||
"ip_address",
|
||||
}
|
||||
self.assertTrue(sensitive.isdisjoint(AccountSessionInfo.model_fields))
|
||||
self.assertTrue(sensitive.isdisjoint(AdminSessionItem.model_fields))
|
||||
active = list_account_sessions(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
current_session_id="session-current",
|
||||
now=self.now,
|
||||
)
|
||||
self.assertEqual({"session-current", "session-other"}, {item.id for item in active})
|
||||
self.assertTrue(next(item for item in active if item.id == "session-current").current)
|
||||
self.assertTrue(all(len(item.client or "") <= MAX_CLIENT_LABEL_LENGTH for item in active))
|
||||
self.assertNotIn("192.0.2.55", repr(active))
|
||||
self.assertNotIn("token_hash", repr(active))
|
||||
|
||||
all_states = list_account_sessions(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
current_session_id="session-current",
|
||||
include_inactive=True,
|
||||
now=self.now,
|
||||
)
|
||||
self.assertEqual(
|
||||
{"active", "expired", "revoked"},
|
||||
{item.status for item in all_states},
|
||||
)
|
||||
tenant_only = list_account_sessions(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
current_session_id="session-current",
|
||||
tenant_id="tenant-1",
|
||||
include_inactive=True,
|
||||
now=self.now,
|
||||
)
|
||||
self.assertNotIn("session-other", {item.id for item in tenant_only})
|
||||
|
||||
def test_single_revocation_is_idempotent_and_effective_on_next_request(self) -> None:
|
||||
item, changed = revoke_account_session(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
session_id="session-other",
|
||||
protected_session_id="session-current",
|
||||
now=self.now,
|
||||
)
|
||||
self.assertTrue(changed)
|
||||
self.session.commit()
|
||||
self.assertIsNotNone(item)
|
||||
self.assertIsNone(
|
||||
authenticate_session_token(self.session, self.tokens["other"])
|
||||
)
|
||||
|
||||
repeated, changed = revoke_account_session(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
session_id="session-other",
|
||||
protected_session_id="session-current",
|
||||
now=self.now,
|
||||
)
|
||||
self.assertIs(item, repeated)
|
||||
self.assertFalse(changed)
|
||||
hidden, changed = revoke_account_session(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
session_id="session-other-account",
|
||||
now=self.now,
|
||||
)
|
||||
self.assertIsNone(hidden)
|
||||
self.assertFalse(changed)
|
||||
|
||||
def test_current_session_is_protected_and_revoke_others_skips_expired(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "current session"):
|
||||
revoke_account_session(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
session_id="session-current",
|
||||
protected_session_id="session-current",
|
||||
now=self.now,
|
||||
)
|
||||
revoked = revoke_other_account_sessions(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
current_session_id="session-current",
|
||||
now=self.now,
|
||||
)
|
||||
self.assertEqual(("session-other",), revoked)
|
||||
self.assertIsNone(self.session.get(AuthSession, "session-current").revoked_at)
|
||||
self.assertIsNone(self.session.get(AuthSession, "session-expired").revoked_at)
|
||||
self.assertEqual(
|
||||
(),
|
||||
revoke_other_account_sessions(
|
||||
self.session,
|
||||
account_id=self.account.id,
|
||||
current_session_id="session-current",
|
||||
now=self.now,
|
||||
),
|
||||
)
|
||||
|
||||
def test_administrative_revocation_requires_session_and_current_password(self) -> None:
|
||||
self.account.password_hash = hash_password("correct horse")
|
||||
membership = self.session.get(User, "user-1")
|
||||
current = self.session.get(AuthSession, "session-current")
|
||||
principal_ref = PrincipalRef(
|
||||
account_id=self.account.id,
|
||||
membership_id=membership.id,
|
||||
tenant_id=membership.tenant_id,
|
||||
scopes=frozenset({"access:membership:update"}),
|
||||
auth_method="session",
|
||||
session_id=current.id,
|
||||
)
|
||||
without_session = ApiPrincipal(
|
||||
principal=principal_ref,
|
||||
account=self.account,
|
||||
user=membership,
|
||||
)
|
||||
with self.assertRaises(HTTPException) as missing:
|
||||
_require_session_admin_reauthorization(without_session, "correct horse")
|
||||
self.assertEqual(403, missing.exception.status_code)
|
||||
|
||||
principal = ApiPrincipal(
|
||||
principal=principal_ref,
|
||||
account=self.account,
|
||||
user=membership,
|
||||
auth_session=current,
|
||||
)
|
||||
with self.assertRaises(HTTPException) as incorrect:
|
||||
_require_session_admin_reauthorization(principal, "incorrect")
|
||||
self.assertEqual(403, incorrect.exception.status_code)
|
||||
_require_session_admin_reauthorization(principal, "correct horse")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+8
-5
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.19",
|
||||
"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.8",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"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 })
|
||||
});
|
||||
}
|
||||
+146
-207
@@ -1,53 +1,24 @@
|
||||
import type { ApiSettings, DeltaDeletedItem } from "@govoplan/core-webui";
|
||||
import { apiFetch } from "@govoplan/core-webui";
|
||||
|
||||
export type PermissionItem = {
|
||||
scope: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: string;
|
||||
level: "tenant" | "system";
|
||||
system_template_id?: string | null;
|
||||
system_required?: boolean;
|
||||
};
|
||||
|
||||
export type AdminOverview = {
|
||||
active_tenant_id: string;
|
||||
active_tenant_name: string;
|
||||
tenant_count?: number | null;
|
||||
system_account_count?: number | null;
|
||||
system_group_template_count?: number | null;
|
||||
system_role_template_count?: number | null;
|
||||
user_count: number;
|
||||
active_user_count: number;
|
||||
group_count: number;
|
||||
role_count: number;
|
||||
active_api_key_count: number;
|
||||
capabilities: string[];
|
||||
};
|
||||
|
||||
export type TenantAdminItem = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
default_locale: string;
|
||||
settings: Record<string, unknown>;
|
||||
allow_custom_groups?: boolean | null;
|
||||
allow_custom_roles?: boolean | null;
|
||||
allow_api_keys?: boolean | null;
|
||||
effective_governance: Record<string, boolean>;
|
||||
is_active: boolean;
|
||||
counts: Record<string, number>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type TenantOwnerCandidate = {
|
||||
account_id: string;
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
};
|
||||
import type {
|
||||
AccessDecisionProvenanceItem as CoreAccessDecisionProvenanceItem,
|
||||
ApiSettings,
|
||||
DeltaDeletedItem,
|
||||
PrivacyRetentionPolicy,
|
||||
ResourceAccessExplanationOptions,
|
||||
ResourceAccessExplanationResponse as CoreResourceAccessExplanationResponse,
|
||||
TenantAdminItem
|
||||
} from "@govoplan/core-webui";
|
||||
import { apiFetch, apiGetList, apiPath, apiQuery, fetchResourceAccessExplanation as fetchCoreResourceAccessExplanation } from "@govoplan/core-webui";
|
||||
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
|
||||
export type {
|
||||
AdminOverview,
|
||||
PrivacyRetentionLimitPermissionPatch,
|
||||
PrivacyRetentionLimitPermissions,
|
||||
PrivacyRetentionPolicy,
|
||||
PrivacyRetentionPolicyFieldKey,
|
||||
PrivacyRetentionPolicyPatch,
|
||||
PermissionItem,
|
||||
TenantAdminItem
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
export type RoleSummary = {
|
||||
id: string;
|
||||
@@ -132,12 +103,7 @@ export type AccessScopeExplanationItem = {
|
||||
sources: AccessRoleSourceItem[];
|
||||
};
|
||||
|
||||
export type AccessDecisionProvenanceItem = {
|
||||
kind: string;
|
||||
id?: string | null;
|
||||
label?: string | null;
|
||||
tenant_id?: string | null;
|
||||
source?: string | null;
|
||||
export type AccessDecisionProvenanceItem = Omit<CoreAccessDecisionProvenanceItem, "details"> & {
|
||||
details: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -167,13 +133,7 @@ export type UserAccessExplanationResponse = {
|
||||
function_facts: FunctionFactExplanationItem[];
|
||||
};
|
||||
|
||||
export type ResourceAccessExplanationResponse = {
|
||||
user: UserAdminItem;
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
action: string;
|
||||
provenance: AccessDecisionProvenanceItem[];
|
||||
};
|
||||
export type ResourceAccessExplanationResponse = CoreResourceAccessExplanationResponse<UserAdminItem, AccessDecisionProvenanceItem>;
|
||||
|
||||
export type SystemAccountItem = {
|
||||
account_id: string;
|
||||
@@ -195,28 +155,6 @@ export type SystemMembershipDraft = {
|
||||
is_last_active_owner?: boolean;
|
||||
};
|
||||
|
||||
export type PrivacyRetentionPolicyFieldKey =
|
||||
| "store_raw_campaign_json"
|
||||
| "raw_campaign_json_retention_days"
|
||||
| "generated_eml_retention_days"
|
||||
| "stored_report_detail_retention_days"
|
||||
| "mock_mailbox_retention_days"
|
||||
| "audit_detail_retention_days"
|
||||
| "audit_detail_level";
|
||||
|
||||
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
|
||||
|
||||
export type PrivacyRetentionPolicy = {
|
||||
store_raw_campaign_json: boolean;
|
||||
raw_campaign_json_retention_days?: number | null;
|
||||
generated_eml_retention_days?: number | null;
|
||||
stored_report_detail_retention_days?: number | null;
|
||||
mock_mailbox_retention_days?: number | null;
|
||||
audit_detail_retention_days?: number | null;
|
||||
audit_detail_level: "full" | "redacted" | "minimal";
|
||||
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
|
||||
};
|
||||
|
||||
export type SystemSettingsItem = {
|
||||
default_locale: string;
|
||||
allow_tenant_custom_groups: boolean;
|
||||
@@ -234,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";
|
||||
@@ -284,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;
|
||||
@@ -307,89 +264,15 @@ 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 {
|
||||
const params = new URLSearchParams();
|
||||
if (options.since) params.set("since", options.since);
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
return params.toString() ? `?${params.toString()}` : "";
|
||||
return apiQuery(options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
|
||||
return apiFetch(settings, "/api/v1/admin/overview");
|
||||
}
|
||||
|
||||
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
|
||||
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
|
||||
return response.permissions;
|
||||
}
|
||||
|
||||
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
|
||||
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
|
||||
return response.tenants;
|
||||
}
|
||||
|
||||
export 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[]> {
|
||||
const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates");
|
||||
return response.accounts;
|
||||
}
|
||||
|
||||
export function createTenant(settings: ApiSettings, payload: {
|
||||
slug: string;
|
||||
name: string;
|
||||
owner_account_id?: string | null;
|
||||
description?: string | null;
|
||||
default_locale?: string;
|
||||
settings?: Record<string, unknown>;
|
||||
allow_custom_groups?: boolean | null;
|
||||
allow_custom_roles?: boolean | null;
|
||||
allow_api_keys?: boolean | null;
|
||||
}): Promise<TenantAdminItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/tenants", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateTenant(settings: ApiSettings, tenantId: string, payload: Partial<{
|
||||
name: string;
|
||||
description: string | null;
|
||||
default_locale: string;
|
||||
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;
|
||||
@@ -427,21 +310,13 @@ export function fetchUserAccessExplanation(settings: ApiSettings, userId: string
|
||||
|
||||
export function fetchResourceAccessExplanation(
|
||||
settings: ApiSettings,
|
||||
options: { userId: string; resourceType: string; resourceId: string; action: string; tenantId?: string | null }
|
||||
options: ResourceAccessExplanationOptions
|
||||
): Promise<ResourceAccessExplanationResponse> {
|
||||
const params = new URLSearchParams({
|
||||
user_id: options.userId,
|
||||
resource_type: options.resourceType,
|
||||
resource_id: options.resourceId,
|
||||
action: options.action
|
||||
});
|
||||
if (options.tenantId) params.set("tenant_id", options.tenantId);
|
||||
return apiFetch(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`);
|
||||
return fetchCoreResourceAccessExplanation<UserAdminItem, AccessDecisionProvenanceItem>(settings, options);
|
||||
}
|
||||
|
||||
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
|
||||
const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups");
|
||||
return response.groups;
|
||||
return apiGetList<GroupSummary, "groups">(settings, "/api/v1/admin/groups", "groups");
|
||||
}
|
||||
|
||||
export function fetchGroupsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GroupListDeltaResponse> {
|
||||
@@ -471,8 +346,7 @@ export function updateGroup(settings: ApiSettings, groupId: string, payload: Par
|
||||
}
|
||||
|
||||
export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> {
|
||||
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles");
|
||||
return response.roles;
|
||||
return apiGetList<RoleSummary, "roles">(settings, "/api/v1/admin/roles", "roles");
|
||||
}
|
||||
|
||||
export function fetchRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
|
||||
@@ -528,8 +402,7 @@ export function deleteExternalFunctionRoleMapping(settings: ApiSettings, mapping
|
||||
}
|
||||
|
||||
export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> {
|
||||
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles");
|
||||
return response.roles;
|
||||
return apiGetList<RoleSummary, "roles">(settings, "/api/v1/admin/system/roles", "roles");
|
||||
}
|
||||
|
||||
export function fetchSystemRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
|
||||
@@ -587,20 +460,15 @@ export function updateSystemAccountRoles(settings: ApiSettings, accountId: strin
|
||||
}
|
||||
|
||||
export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (includeRevoked) params.set("include_revoked", "true");
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`);
|
||||
return response.api_keys;
|
||||
return apiGetList<ApiKeyAdminItem, "api_keys">(settings, "/api/v1/admin/api-keys", "api_keys", { include_revoked: includeRevoked ? true : undefined });
|
||||
}
|
||||
|
||||
export function fetchApiKeysDelta(settings: ApiSettings, includeRevoked = false, options: { since?: string | null; limit?: number } = {}): Promise<ApiKeyListDeltaResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (includeRevoked) params.set("include_revoked", "true");
|
||||
if (options.since) params.set("since", options.since);
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch(settings, `/api/v1/admin/api-keys/delta${suffix}`);
|
||||
return apiFetch(settings, apiPath("/api/v1/admin/api-keys/delta", {
|
||||
include_revoked: includeRevoked ? true : undefined,
|
||||
since: options.since,
|
||||
limit: options.limit
|
||||
}));
|
||||
}
|
||||
|
||||
export function createApiKey(settings: ApiSettings, payload: {
|
||||
@@ -616,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;
|
||||
@@ -653,9 +594,7 @@ export function updateSystemSettings(settings: ApiSettings, payload: SystemSetti
|
||||
}
|
||||
|
||||
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
|
||||
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
|
||||
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
|
||||
return response.templates;
|
||||
return apiGetList<GovernanceTemplateItem, "templates">(settings, "/api/v1/admin/system/governance-templates", "templates", { kind });
|
||||
}
|
||||
|
||||
export function fetchGovernanceTemplatesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GovernanceTemplateListDeltaResponse> {
|
||||
|
||||
@@ -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,33 +1,60 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useSearchParams } from "react-router";
|
||||
import type {
|
||||
AdminSectionContribution,
|
||||
AdminSectionsUiCapability,
|
||||
ApiSettings,
|
||||
AuthInfo,
|
||||
AuthUpdate,
|
||||
FilesConnectorsUiCapability,
|
||||
MailProfilesUiCapability,
|
||||
OrganizationFunctionPickerUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import { fetchMe } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
|
||||
import { fetchShellAuth } from "@govoplan/core-webui";
|
||||
import { ActionBlockerHint } from "@govoplan/core-webui";
|
||||
import { PageLayout, WorkspaceLayout } from "@govoplan/core-webui";
|
||||
import { PageScrollViewport } from "@govoplan/core-webui";
|
||||
import {
|
||||
TreeSubnav,
|
||||
type TreeSubnavNode
|
||||
} from "@govoplan/core-webui";
|
||||
import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
|
||||
import SystemUsersPanel from "./SystemUsersPanel";
|
||||
import TenantSettingsPanel from "./TenantSettingsPanel";
|
||||
import SystemRolesPanel from "./SystemRolesPanel";
|
||||
import TenantsPanel from "./TenantsPanel";
|
||||
import UsersPanel from "./UsersPanel";
|
||||
import GroupsPanel from "./GroupsPanel";
|
||||
import RolesPanel from "./RolesPanel";
|
||||
import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPanel";
|
||||
import ApiKeysPanel from "./ApiKeysPanel";
|
||||
import 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",
|
||||
@@ -35,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,
|
||||
@@ -63,20 +133,29 @@ export default function AdminPage({
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
}) {
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||
const organizationFunctionPicker = usePlatformUiCapability<OrganizationFunctionPickerUiCapability>("organizations.functionPicker");
|
||||
const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
|
||||
const effectiveView = useEffectiveView();
|
||||
const viewSurfaces = useViewSurfaces();
|
||||
const mailProfilesAvailable = Boolean(mailProfilesUi);
|
||||
const 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>();
|
||||
@@ -94,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");
|
||||
@@ -102,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");
|
||||
@@ -111,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");
|
||||
@@ -131,28 +225,39 @@ export default function AdminPage({
|
||||
}, [requestedSection, available, fallbackSection]);
|
||||
|
||||
async function refreshAuth() {
|
||||
onAuthChange(await fetchMe(settings));
|
||||
onAuthChange(await fetchShellAuth(settings));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAuth().catch(() => undefined);
|
||||
}, [settings.accessToken, settings.apiBaseUrl]);
|
||||
|
||||
if (!hasAnyScope(auth, adminReadScopes)) {
|
||||
return (
|
||||
<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),
|
||||
@@ -160,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
|
||||
@@ -242,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -273,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,6 +1,7 @@
|
||||
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createApiKey, fetchApiKeysDelta, fetchPermissionCatalog, fetchUsersDelta, revokeApiKey, type ApiKeyAdminItem, type PermissionItem, type UserAdminItem } from "../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
@@ -8,10 +9,12 @@ import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { DateTimeField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, 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: "" };
|
||||
@@ -99,11 +102,10 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
|
||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.revoked_at ? "revoked" : "active", render: (row) => <StatusBadge status={row.revoked_at ? "revoked" : "active"} /> },
|
||||
{ id: "last_used", header: "i18n:govoplan-access.last_used.f1109d3d", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_used_at || "", render: (row) => formatDateTime(row.last_used_at) },
|
||||
{ id: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa" },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} disabled />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setRevoking(row)} disabled={!canRevoke || Boolean(row.revoked_at)} />
|
||||
</div> }],
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 108, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, helpContextId: "access.api-keys.action.inspect", helpModuleId: "access", onClick: () => setViewing(row) },
|
||||
{ id: "revoke", label: i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name }), icon: <Trash2 />, variant: "danger", helpContextId: "access.api-keys.action.revoke", helpModuleId: "access", applicable: !row.revoked_at, disabled: !canRevoke, disabledReason: row.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : !canRevoke ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => setRevoking(row) }
|
||||
]} /> }],
|
||||
[canRevoke]);
|
||||
|
||||
function openCreate() {
|
||||
@@ -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={<><label className="admin-inline-check"><input type="checkbox" checked={showRevoked} onChange={(event) => setShowRevoked(event.target.checked)} /> i18n:govoplan-access.show_revoked.b4265807</label><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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo, OrganizationFunctionPickerUiCapability, OrganizationFunctionSelection } from "@govoplan/core-webui";
|
||||
import type { FormGrid, ApiSettings, AuthInfo, OrganizationFunctionPickerUiCapability, OrganizationFunctionSelection } from "@govoplan/core-webui";
|
||||
import {
|
||||
createExternalFunctionRoleMapping,
|
||||
deleteExternalFunctionRoleMapping,
|
||||
@@ -15,9 +15,10 @@ 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, 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",
|
||||
@@ -257,12 +258,10 @@ export default function ExternalFunctionRoleMappingsPanel({
|
||||
sticky: "end",
|
||||
resizable: false,
|
||||
align: "right",
|
||||
render: (row) => (
|
||||
<div className="admin-icon-actions">
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.function_id })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} />
|
||||
</div>
|
||||
)
|
||||
render: (row) => <TableActionGroup actions={[
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id }), icon: <Pencil />, disabled: !canWrite, disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.function_id }), icon: <Trash2 />, variant: "danger", disabled: !canWrite, disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => setDeleting(row) }
|
||||
]} />
|
||||
}
|
||||
],
|
||||
[auth, canWrite, functionPicker, roleById, settings]
|
||||
@@ -278,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} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -295,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,
|
||||
@@ -327,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,6 +1,7 @@
|
||||
import { ContentGrid, DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createGroup, fetchGroupsDelta, fetchRolesDelta, fetchUsersDelta, updateGroup, type GroupSummary, type RoleSummary, type UserAdminItem } from "../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
@@ -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, 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[] };
|
||||
|
||||
@@ -127,45 +133,45 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<GroupSummary>[]>(() => [
|
||||
{ id: "group", header: "i18n:govoplan-access.group.171a0606", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">i18n:govoplan-access.system.bc0792d8{row.system_required ? "i18n:govoplan-access.required.7c65879a" : ""}</span>}</div></div> },
|
||||
{ id: "group", header: "i18n:govoplan-access.group.171a0606", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <StatusBadge status={row.system_required ? "warning" : "inactive"} label={`i18n:govoplan-access.system.bc0792d8${row.system_required ? " · i18n:govoplan-access.required.7c65879a" : ""}`} />}</div></div> },
|
||||
{ id: "members", header: "i18n:govoplan-access.members.1cb449c1", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count },
|
||||
{ id: "roles", header: "i18n:govoplan-access.inherited_roles.8def9f05", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canDefine || canManageMembers || canAssignRoles)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canDefine || !row.is_active || Boolean(row.system_required)} />
|
||||
</div> }],
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !(canDefine || canManageMembers || canAssignRoles), disabledReason: !(canDefine || canManageMembers || canAssignRoles) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canDefine || Boolean(row.system_required), disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canDefine ? ACCESS_INTERFACE_I18N.writePermissionRequired : row.system_required ? ACCESS_INTERFACE_I18N.systemManagedObject : undefined, onClick: () => setDeactivating(row) }
|
||||
]} /> }],
|
||||
[canAssignRoles, canDefine, canManageMembers]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="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,6 +1,7 @@
|
||||
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createRole, deleteRole, fetchPermissionCatalog, fetchRolesDelta, updateRole, type PermissionItem, type RoleSummary } from "../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
@@ -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, 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 };
|
||||
|
||||
@@ -78,10 +80,12 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
}
|
||||
function togglePermission(scope: string, checked: boolean) {
|
||||
const next = new Set(draft.permissions);
|
||||
if (checked) next.add(scope);else next.delete(scope);
|
||||
setDraft({ ...draft, permissions: Array.from(next) });
|
||||
function setPermissionGroup(scopes: string[], selected: string[]) {
|
||||
const groupScopes = new Set(scopes);
|
||||
setDraft({
|
||||
...draft,
|
||||
permissions: [...draft.permissions.filter((scope) => !groupScopes.has(scope)), ...selected]
|
||||
});
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
@@ -118,39 +122,42 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
|
||||
{ id: "role", header: "i18n:govoplan-access.role.c3f104d1", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">i18n:govoplan-access.system.bc0792d8{row.system_required ? "i18n:govoplan-access.required.7c65879a" : ""}</span>}</div></div> },
|
||||
{ id: "role", header: "i18n:govoplan-access.role.c3f104d1", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <StatusBadge status={row.system_required ? "warning" : "inactive"} label={`i18n:govoplan-access.system.bc0792d8${row.system_required ? " · i18n:govoplan-access.required.7c65879a" : ""}`} />}</div></div> },
|
||||
{ id: "permissions", header: "i18n:govoplan-access.permissions.d06d5557", width: 170, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.effective_permission_count, render: (row) => hasTenantWildcard(row.permissions) ? `${row.effective_permission_count} (tenant:*)` : String(row.effective_permission_count) },
|
||||
{ id: "assignments", header: "i18n:govoplan-access.assignments.057d58c7", width: 220, minWidth: 170, maxWidth: 420, resizable: true, fill: true, sortable: true, value: (row) => row.user_assignments + row.group_assignments, render: (row) => `${row.user_assignments} users / ${row.group_assignments} groups` },
|
||||
{ id: "type", header: "i18n:govoplan-access.type.3deb7456", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : row.system_template_id ? "i18n:govoplan-access.system.bc0792d8" : "i18n:govoplan-access.custom.081ae3fd"} /> },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id) || row.user_assignments + row.group_assignments > 0} />
|
||||
</div> }],
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine, disabledReason: row.is_builtin || row.system_template_id ? ACCESS_INTERFACE_I18N.systemManagedObject : !canDefine ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine || row.user_assignments + row.group_assignments > 0, disabledReason: row.is_builtin || row.system_template_id ? ACCESS_INTERFACE_I18N.systemManagedObject : !canDefine ? ACCESS_INTERFACE_I18N.writePermissionRequired : row.user_assignments + row.group_assignments > 0 ? ACCESS_INTERFACE_I18N.assignedObjectCannotBeDeleted : undefined, onClick: () => setDeleting(row) }
|
||||
]} /> }],
|
||||
[canDefine, permissions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="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>
|
||||
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => <fieldset key={category} className="admin-permission-group"><legend>{category}</legend>{items.map((permission) => <label key={permission.scope} className="admin-selection-item"><input type="checkbox" checked={draft.permissions.includes(permission.scope)} onChange={(event) => togglePermission(permission.scope, event.target.checked)} /><span><strong>{permission.label}</strong><small>{permission.description}<code>{permission.scope}</code></small></span></label>)}</fieldset>)}</div>
|
||||
</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,6 +1,7 @@
|
||||
import { DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import type { FormGrid, ApiSettings } from "@govoplan/core-webui";
|
||||
import {
|
||||
createSystemRole,
|
||||
deleteSystemRole,
|
||||
@@ -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, 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: "",
|
||||
@@ -223,11 +225,11 @@ export default function SystemRolesPanel({
|
||||
align: "right",
|
||||
render: (row) => {
|
||||
const protectedOwner = row.slug === "system_owner";
|
||||
return <div className="admin-icon-actions">
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite || protectedOwner} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite || protectedOwner || row.user_assignments > 0} />
|
||||
</div>;
|
||||
return <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !protectedOwner, disabled: !canWrite, disabledReason: protectedOwner ? ACCESS_INTERFACE_I18N.systemManagedObject : !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !protectedOwner, disabled: !canWrite || row.user_assignments > 0, disabledReason: protectedOwner ? ACCESS_INTERFACE_I18N.systemManagedObject : !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : row.user_assignments > 0 ? ACCESS_INTERFACE_I18N.assignedObjectCannotBeDeleted : undefined, onClick: () => setDeleting(row) }
|
||||
]} />;
|
||||
}
|
||||
}],
|
||||
[canWrite]);
|
||||
@@ -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,6 +1,7 @@
|
||||
import { ContentGrid, DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Search, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import type { FormGrid, ApiSettings } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
@@ -20,7 +21,8 @@ import {
|
||||
type SystemMembershipDraft,
|
||||
type TenantAdminItem
|
||||
} from "../../api/admin";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, 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: "",
|
||||
@@ -83,8 +85,17 @@ export default function SystemUsersPanel({
|
||||
let hasMore = false;
|
||||
do {
|
||||
const response = await fetchSystemAccountsDelta(settings, { since: nextWatermark });
|
||||
nextAccounts = response.full ? response.accounts : mergeDeltaRows(nextAccounts, response.accounts, response.deleted, (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts });
|
||||
nextRoles = response.full ? response.roles : mergeDeltaRows(nextRoles, response.roles, response.deleted, (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles });
|
||||
const continuingFullSnapshot = response.full && nextWatermark?.startsWith("full:");
|
||||
nextAccounts = response.full
|
||||
? continuingFullSnapshot
|
||||
? mergeDeltaRows(nextAccounts, response.accounts, [], (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts })
|
||||
: response.accounts
|
||||
: mergeDeltaRows(nextAccounts, response.accounts, response.deleted, (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts });
|
||||
nextRoles = response.full
|
||||
? continuingFullSnapshot
|
||||
? mergeDeltaRows(nextRoles, response.roles, [], (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles })
|
||||
: response.roles
|
||||
: mergeDeltaRows(nextRoles, response.roles, response.deleted, (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles });
|
||||
nextWatermark = response.watermark ?? null;
|
||||
hasMore = response.has_more;
|
||||
} while (hasMore);
|
||||
@@ -140,16 +151,13 @@ export default function SystemUsersPanel({
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
}
|
||||
|
||||
function membership(tenantId: string) {
|
||||
return draft.memberships.find((item) => item.tenant_id === tenantId);
|
||||
}
|
||||
|
||||
function setMembership(tenantId: string, enabled: boolean) {
|
||||
function setMembershipSelection(tenantIds: string[]) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
memberships: enabled ?
|
||||
[...current.memberships.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }] :
|
||||
current.memberships.filter((item) => item.tenant_id !== tenantId)
|
||||
memberships: tenantIds.map((tenantId) =>
|
||||
current.memberships.find((item) => item.tenant_id === tenantId) ??
|
||||
{ tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -206,11 +214,11 @@ export default function SystemUsersPanel({
|
||||
{ id: "roles", header: "i18n:govoplan-access.system_roles.a9461aa6", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canAssignRoles || canManageMemberships)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.memberships.some((membership) => membership.is_last_active_owner)} />
|
||||
</div> }],
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), disabledReason: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.memberships.some((membership) => membership.is_last_active_owner), disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canSuspend ? ACCESS_INTERFACE_I18N.updatePermissionRequired : row.memberships.some((membership) => membership.is_last_active_owner) ? ACCESS_INTERFACE_I18N.lastOwnerCannotBeDeactivated : undefined, onClick: () => setDeactivating(row) }
|
||||
]} /> }],
|
||||
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
||||
|
||||
return (
|
||||
@@ -221,13 +229,13 @@ 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" &&
|
||||
@@ -241,20 +249,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><div className="admin-selection-list">{tenants.map((tenant) => <label className="admin-selection-item" key={tenant.id}><input type="checkbox" checked={Boolean(membership(tenant.id))} disabled={!canManageMemberships || Boolean(membership(tenant.id)?.is_last_active_owner)} onChange={(event) => setMembership(tenant.id, event.target.checked)} /><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span></label>)}</div></div>
|
||||
</div>
|
||||
<div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><AdminSelectionList options={tenants.map((tenant) => ({ id: tenant.id, label: tenant.name, description: tenant.slug, disabled: !canManageMemberships || Boolean(draft.memberships.find((item) => item.tenant_id === tenant.id)?.is_last_active_owner) }))} selected={draft.memberships.map((item) => item.tenant_id)} onChange={setMembershipSelection} /></div>
|
||||
</ContentGrid>
|
||||
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>}
|
||||
<p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="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,175 +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, 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 toggleLanguage(code: string, checked: boolean) {
|
||||
const enabled = new Set(draft.enabled_language_codes);
|
||||
if (checked) enabled.add(code);
|
||||
else enabled.delete(code);
|
||||
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>
|
||||
<div className="settings-list">
|
||||
{draft.system_enabled_language_codes.map((code) => {
|
||||
const language = draft.available_languages.find((item) => item.code === code);
|
||||
return (
|
||||
<label className="admin-inline-check" key={code}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.enabled_language_codes.includes(code)}
|
||||
disabled={!canWrite || busy || code === draft.default_locale}
|
||||
onChange={(event) => toggleLanguage(code, event.target.checked)} />
|
||||
<span><strong>{code.toUpperCase()}</strong> {languageOptionLabel(language ?? { code, label: code.toUpperCase() })}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<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, 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) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canUpdate} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.suspend_value.03a74b32", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setConfirmSuspend(row)} disabled={!canSuspend || !row.is_active || row.id === activeTenantId} />
|
||||
</div> }],
|
||||
[activeTenantId, canSuspend, canUpdate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="i18n:govoplan-access.tenants.1f7ae776"
|
||||
description="i18n:govoplan-access.create_and_govern_tenant_spaces_suspension_retai.1b76d377"
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_tenant.b8e32af0" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
|
||||
|
||||
<div className="admin-table-surface"><DataGrid id="admin-tenants-v3" rows={tenants} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_tenants_found.72d04cf4" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_tenant.4dbd55d9" : "i18n:govoplan-access.edit_tenant.e2ba43f9"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={!(editing === "new" ? canCreate : canUpdate) || busy || !draft.name.trim() || !draft.slug.trim() || editing === "new" && !draft.ownerAccountId}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_tenant.9eb2ac74"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new" || !canCreate} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
{editing === "new" && <FormField label="i18n:govoplan-access.initial_tenant_owner.682291a9"><select value={draft.ownerAccountId} onChange={(event) => setDraft({ ...draft, ownerAccountId: event.target.value })}>{ownerCandidates.map((candidate) => <option key={candidate.account_id} value={candidate.account_id}>{candidate.display_name ? i18nMessage("i18n:govoplan-access.value_value.c189e8bc", { value0: candidate.display_name, value1: candidate.email }) : candidate.email}</option>)}</select></FormField>}
|
||||
<FormField label="i18n:govoplan-access.default_locale.b99d021f"><input value={draft.defaultLocale} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, defaultLocale: event.target.value })} /></FormField>
|
||||
{editing !== "new" && <FormField label="i18n:govoplan-access.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={!canSuspend} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.suspended.794696a7</option></select></FormField>}
|
||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={4} value={draft.description} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</div>
|
||||
<h3>i18n:govoplan-access.system_governance_overrides.97cdf3ce</h3>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomGroups} label="i18n:govoplan-access.custom_tenant_groups.570ee603" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomRoles} label="i18n:govoplan-access.custom_tenant_roles.a738c37c" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsApiKeys} label="i18n:govoplan-access.tenant_api_keys.4b1d81f8" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
|
||||
</div>
|
||||
<p className="muted small-note">i18n:govoplan-access.inherit_follows_the_current_system_setting_expli.60d4d868</p>
|
||||
{systemDeniedGovernance && <p className="muted small-note">i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a {systemDeniedGovernance} i18n:govoplan-access.because_the_current_system_setting_denies_it.3f59c244</p>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.tenant_details.5976ba72" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <><dl className="admin-details-grid">
|
||||
<div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.suspended.794696a7"}</dd></div><div><dt>i18n:govoplan-access.default_locale.b99d021f</dt><dd>{viewing.default_locale}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-access.updated.f2f8570d</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.custom_groups.1f7b7c8f</dt><dd>{viewing.effective_governance.allow_custom_groups ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_custom_groups)})</dd></div>
|
||||
<div><dt>i18n:govoplan-access.custom_roles.e78ef63d</dt><dd>{viewing.effective_governance.allow_custom_roles ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_custom_roles)})</dd></div>
|
||||
<div><dt>i18n:govoplan-access.api_keys.94fcf3c2</dt><dd>{viewing.effective_governance.allow_api_keys ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_api_keys)})</dd></div>
|
||||
<div><dt>i18n:govoplan-access.objects.72a83add</dt><dd>{viewing.counts.users ?? 0} i18n:govoplan-access.users.81651889 {viewing.counts.groups ?? 0} i18n:govoplan-access.groups.07551586 {viewing.counts.campaigns ?? 0} i18n:govoplan-access.campaigns.2282ffeb {viewing.counts.files ?? 0} files</dd></div>
|
||||
</dl>{viewing.description && <p>{viewing.description}</p>}</>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(confirmSuspend)} title="i18n:govoplan-access.suspend_tenant.151d283a" message={i18nMessage("i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78", { value0: confirmSuspend?.name })} confirmLabel="i18n:govoplan-access.suspend_tenant.151d283a" tone="danger" busy={busy} onCancel={() => setConfirmSuspend(null)} onConfirm={() => void suspend()} />
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function GovernanceSelect({ label, value, onChange, disabled = false, allowDisabled = false }: {label: string;value: OverrideValue;onChange: (value: OverrideValue) => void;disabled?: boolean;allowDisabled?: boolean;}) {
|
||||
return <FormField label={label}><select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as OverrideValue)}><option value="inherit">i18n:govoplan-access.inherit_system_setting.7f125156</option><option value="allow" disabled={allowDisabled}>i18n:govoplan-access.allow_when_system_allows.4c5178cb</option><option value="deny">i18n:govoplan-access.explicitly_deny.17ad945a</option></select></FormField>;
|
||||
}
|
||||
|
||||
function draftKey(draft: TenantDraft): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
function sortTenants(left: TenantAdminItem, right: TenantAdminItem): number {
|
||||
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ContentGrid, DescriptionItem, DescriptionList } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { KeyRound, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { KeyRound, MonitorSmartphone, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { FormGrid, ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createUser, fetchGroupsDelta, fetchRolesDelta, fetchUserAccessExplanation, fetchUsersDelta, updateUser, type AccessRoleSourceItem, type FunctionFactExplanationItem, type GroupSummary, type RoleSummary, type UserAccessExplanationResponse, type UserAdminItem } from "../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
@@ -8,10 +9,21 @@ import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
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, 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: "",
|
||||
@@ -23,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
|
||||
|
||||
|
||||
|
||||
@@ -32,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[]>([]);
|
||||
@@ -46,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);
|
||||
@@ -179,6 +197,65 @@ 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",
|
||||
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) },
|
||||
@@ -186,22 +263,23 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
||||
{ id: "scope_count", header: "i18n:govoplan-access.permissions.d06d5557", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => hasTenantWildcard(row.effective_scopes) ? 999 : row.effective_scopes.length, render: (row) => hasTenantWildcard(row.effective_scopes) ? "i18n:govoplan-access.all.6a720856" : String(row.effective_scopes.length) },
|
||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active && row.account_is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active && row.account_is_active ? "active" : "inactive"} /> },
|
||||
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 190, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email })} icon={<KeyRound />} onClick={() => void openAccessExplanation(row)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canManageGroups || canAssignRoles)} />
|
||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.is_last_active_owner} />
|
||||
</div> }],
|
||||
[canAssignRoles, canManageGroups, canSuspend, canUpdate, settings]);
|
||||
{ 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), 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, 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" &&
|
||||
@@ -215,36 +293,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" && <label className="admin-inline-check"><input type="checkbox" checked={draft.passwordResetRequired} onChange={(event) => setDraft({ ...draft, passwordResetRequired: event.target.checked })} /> i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3</label>}
|
||||
</FormGrid>
|
||||
{editing === "new" && <ToggleSwitch label="i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3" checked={draft.passwordResetRequired} onChange={(passwordResetRequired) => setDraft({ ...draft, passwordResetRequired })} />}
|
||||
{editing && 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" onClose={() => !busy && setRevokingSession(null)} className="" footer={<><Button onClick={() => setRevokingSession(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="danger" 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">
|
||||
<PasswordField 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">
|
||||
@@ -256,19 +350,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>)}
|
||||
@@ -276,7 +367,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;
|
||||
}
|
||||
@@ -24,7 +24,12 @@ export async function loadDeltaRows<TItem, TResponse extends AdminDeltaResponse>
|
||||
do {
|
||||
const response = await fetchDelta(nextWatermark);
|
||||
const rows = rowsFromResponse(response);
|
||||
merged = response.full ? rows : mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort });
|
||||
const continuingFullSnapshot = response.full && nextWatermark?.startsWith("full:");
|
||||
merged = response.full
|
||||
? continuingFullSnapshot
|
||||
? mergeDeltaRows(merged, rows, [], getKey, { deletedResourceType, sort })
|
||||
: rows
|
||||
: mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort });
|
||||
nextWatermark = response.watermark ?? null;
|
||||
hasMore = response.has_more;
|
||||
} while (hasMore);
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
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",
|
||||
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"
|
||||
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}
|
||||
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}
|
||||
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",
|
||||
@@ -108,6 +159,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.explain_access_for_value.3af96e47": "Explain access for {value0}",
|
||||
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
|
||||
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
|
||||
"i18n:govoplan-access.available.7c62a142": "Available",
|
||||
"i18n:govoplan-access.file_connections.1e362326": "File connections",
|
||||
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Files module unavailable",
|
||||
"i18n:govoplan-access.files.6ce6c512": "Files",
|
||||
@@ -119,6 +171,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "The source module and function ID identify an accepted external function fact. Access grants the selected tenant role only while IDM reports an active accepted assignment for that fact.",
|
||||
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Function mapping updated.",
|
||||
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Function mappings",
|
||||
"i18n:govoplan-access.map_accepted_function_facts_to_tenant_roles_.7581e5cf": "Map accepted function facts to tenant roles.",
|
||||
"i18n:govoplan-access.general.9239ee2c": "General",
|
||||
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
|
||||
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
|
||||
@@ -254,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}",
|
||||
@@ -350,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",
|
||||
@@ -456,6 +560,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.explain_access_for_value.3af96e47": "Zugriff fuer {value0} erklaeren",
|
||||
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
|
||||
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
|
||||
"i18n:govoplan-access.available.7c62a142": "Verfuegbar",
|
||||
"i18n:govoplan-access.file_connections.1e362326": "Dateiverbindungen",
|
||||
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Dateimodul nicht verfügbar",
|
||||
"i18n:govoplan-access.files.6ce6c512": "Dateien",
|
||||
@@ -467,6 +572,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "Quellmodul und Funktions-ID identifizieren einen akzeptierten externen Funktionsfakt. Access gewährt die ausgewählte Mandantenrolle nur, solange IDM eine aktive akzeptierte Zuweisung für diesen Fakt meldet.",
|
||||
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Funktionszuordnung aktualisiert.",
|
||||
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Funktionszuordnungen",
|
||||
"i18n:govoplan-access.map_accepted_function_facts_to_tenant_roles_.7581e5cf": "Akzeptierte Funktionsfakten Mandantenrollen zuordnen.",
|
||||
"i18n:govoplan-access.general.9239ee2c": "Allgemein",
|
||||
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
|
||||
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
|
||||
@@ -602,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,5 +1,8 @@
|
||||
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";
|
||||
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } 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