Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
794fcf90f8 | ||
|
|
d5db5c2378 | ||
|
|
4653542247 | ||
|
|
668f7cf108 | ||
|
|
e710cf5fb8 | ||
|
|
998d47ae94 | ||
|
|
1409dbf94d | ||
|
|
fe247999e9 | ||
|
|
9776f862f8 | ||
|
|
e58dcbdd7b | ||
|
|
aeda457fb1 | ||
|
|
a70cc375c2 | ||
|
|
e8ea347654 | ||
|
|
a758c8f2da | ||
|
|
6295cfa840 | ||
|
|
b6c2c89adf | ||
|
|
6b0dd8beab | ||
|
|
984a015704 | ||
|
|
3df4fc5bff | ||
|
|
1b57df7753 | ||
|
|
bf1ecc54b3 | ||
|
|
fdfcfbb440 |
@@ -0,0 +1,270 @@
|
||||
name: Module Package Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing protected version tag to publish
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish-packages:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Select and validate protected release tag
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||
case "$tag" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||
esac
|
||||
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||
echo "Release tag is not contained in main" >&2
|
||||
exit 1
|
||||
}
|
||||
git checkout --detach "$tag"
|
||||
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||
- name: Validate package versions
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
expected = tag.removeprefix("v")
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
if project.get("version") != expected:
|
||||
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||
webui = Path("webui/package.json")
|
||||
if webui.is_file():
|
||||
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||
if package.get("version") != expected:
|
||||
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||
release = Path("webui/package.release.json")
|
||||
if release.is_file():
|
||||
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||
if (
|
||||
release_package.get("name") != package.get("name")
|
||||
or release_package.get("version") != expected
|
||||
):
|
||||
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||
PY
|
||||
- name: Build immutable package artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||
rm -rf dist .package-webui
|
||||
python -m build --wheel --outdir dist
|
||||
python -m twine check dist/*.whl
|
||||
if [[ -f webui/package.json ]]; then
|
||||
mkdir .package-webui
|
||||
cp -a webui/. .package-webui/
|
||||
rm -rf .package-webui/node_modules .package-webui/dist
|
||||
if [[ -f .package-webui/package.release.json ]]; then
|
||||
cp .package-webui/package.release.json .package-webui/package.json
|
||||
fi
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = ".package-webui/package.json";
|
||||
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||
for (const group of groups) {
|
||||
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||
if (!name.startsWith("@govoplan/")) continue;
|
||||
if (typeof specifier !== "string") {
|
||||
throw new Error(`${group}.${name} must use a string version`);
|
||||
}
|
||||
const packageSlug = name.slice("@govoplan/".length);
|
||||
if (!packageSlug.endsWith("-webui")) {
|
||||
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||
}
|
||||
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const gitTag = specifier.match(
|
||||
new RegExp(
|
||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||
),
|
||||
);
|
||||
if (gitTag) {
|
||||
packageJson[group][name] = gitTag[1];
|
||||
continue;
|
||||
}
|
||||
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||
throw new Error(
|
||||
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
delete packageJson.private;
|
||||
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
npm pkg delete private --prefix .package-webui
|
||||
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||
fi
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
artifacts = []
|
||||
for path in sorted(Path("dist").iterdir()):
|
||||
if path.suffix not in {".whl", ".tgz"}:
|
||||
continue
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||
payload = {
|
||||
"schema_version": "1",
|
||||
"repository": os.environ["GITEA_REPOSITORY"],
|
||||
"tag": os.environ["RELEASE_TAG"],
|
||||
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
Path("dist/package-artifacts.json").write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
PY
|
||||
- name: Retain package hash evidence
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||
with:
|
||||
name: module-packages-${{ gitea.ref_name }}
|
||||
path: dist/package-artifacts.json
|
||||
- name: Check immutable registry state
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||
token = os.environ["PACKAGE_TOKEN"]
|
||||
|
||||
def should_publish(kind, name, version, path):
|
||||
package_url = "/".join(
|
||||
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||
)
|
||||
request = Request(
|
||||
package_url,
|
||||
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
files = json.load(response)
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
print(f"{kind} package {name}=={version} is not published yet")
|
||||
return True
|
||||
raise
|
||||
if not isinstance(files, list) or len(files) != 1:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||
)
|
||||
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if files[0].get("sha256") != expected_sha256:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||
)
|
||||
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||
return False
|
||||
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
wheels = tuple(Path("dist").glob("*.whl"))
|
||||
if len(wheels) != 1:
|
||||
raise SystemExit("release build must contain exactly one wheel")
|
||||
publish_pypi = should_publish(
|
||||
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||
)
|
||||
|
||||
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||
if len(tarballs) > 1:
|
||||
raise SystemExit("release build must contain at most one npm package")
|
||||
publish_npm = False
|
||||
if tarballs:
|
||||
webui = json.loads(
|
||||
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||
)
|
||||
publish_npm = should_publish(
|
||||
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||
)
|
||||
|
||||
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||
PY
|
||||
- name: Publish wheel and WebUI package
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_USERNAME"
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||
python -m twine upload --non-interactive \
|
||||
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||
dist/*.whl
|
||||
else
|
||||
echo "Exact wheel is already present; skipping immutable retry."
|
||||
fi
|
||||
shopt -s nullglob
|
||||
webui_packages=(dist/*.tgz)
|
||||
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||
npmrc="$(mktemp)"
|
||||
trap 'rm -f "$npmrc"' EXIT
|
||||
chmod 600 "$npmrc"
|
||||
printf '%s\n' \
|
||||
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||
> "$npmrc"
|
||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||
--ignore-scripts --access public \
|
||||
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||
elif (( ${#webui_packages[@]} )); then
|
||||
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||
fi
|
||||
@@ -1,5 +1,11 @@
|
||||
# GovOPlaN Access Codex Guide
|
||||
|
||||
## Documentation Contract
|
||||
|
||||
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
|
||||
- Keep feature content here; `govoplan-docs` projects it without importing Access internals.
|
||||
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||
|
||||
## Scope
|
||||
|
||||
This repository owns the GovOPlaN access platform module seed: identity,
|
||||
|
||||
@@ -76,13 +76,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
|
||||
|
||||
|
||||
@@ -179,6 +179,18 @@ 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.
|
||||
|
||||
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
|
||||
|
||||
Focused verification is run from `/mnt/DATA/git/govoplan-core`.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# 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
|
||||
|
||||
- `AdminPageLayout`, `TreeSubnav`, `DataGrid`, `Dialog`, `ConfirmDialog`,
|
||||
`TableActionGroup`, `PasswordField`, `ActionBlockerHint`, and
|
||||
`DocumentationHelpLink` come from Core.
|
||||
- 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
|
||||
reusable credentials.
|
||||
- `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,41 @@
|
||||
# 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.
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -18,11 +18,11 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.11",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-access"
|
||||
version = "0.1.11"
|
||||
version = "0.1.15"
|
||||
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.11",
|
||||
"govoplan-core>=0.1.15",
|
||||
"redis>=5,<6",
|
||||
"SQLAlchemy>=2,<3",
|
||||
]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN access platform module."""
|
||||
|
||||
__version__ = "0.1.11"
|
||||
__version__ = "0.1.15"
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import case, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, Group, Role, User
|
||||
@@ -12,14 +12,99 @@ from govoplan_core.core.access import AccessAdministration
|
||||
class SqlAccessAdministration(AccessAdministration):
|
||||
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
|
||||
db = _session(session)
|
||||
users, active_users = (
|
||||
db.query(
|
||||
func.count(User.id),
|
||||
func.coalesce(
|
||||
func.sum(case((User.is_active.is_(True), 1), else_=0)),
|
||||
0,
|
||||
),
|
||||
)
|
||||
.filter(User.tenant_id == tenant_id)
|
||||
.one()
|
||||
)
|
||||
api_keys, active_api_keys = (
|
||||
db.query(
|
||||
func.count(ApiKey.id),
|
||||
func.coalesce(
|
||||
func.sum(case((ApiKey.revoked_at.is_(None), 1), else_=0)),
|
||||
0,
|
||||
),
|
||||
)
|
||||
.filter(ApiKey.tenant_id == tenant_id)
|
||||
.one()
|
||||
)
|
||||
return {
|
||||
"users": db.query(User).filter(User.tenant_id == tenant_id).count(),
|
||||
"active_users": db.query(User).filter(User.tenant_id == tenant_id, User.is_active.is_(True)).count(),
|
||||
"users": int(users),
|
||||
"active_users": int(active_users),
|
||||
"groups": db.query(Group).filter(Group.tenant_id == tenant_id).count(),
|
||||
"api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id).count(),
|
||||
"active_api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(),
|
||||
"api_keys": int(api_keys),
|
||||
"active_api_keys": int(active_api_keys),
|
||||
}
|
||||
|
||||
def tenant_counts_many(
|
||||
self,
|
||||
session: object,
|
||||
tenant_ids: Sequence[str],
|
||||
) -> Mapping[str, Mapping[str, int]]:
|
||||
ids = tuple(dict.fromkeys(str(tenant_id) for tenant_id in tenant_ids if tenant_id))
|
||||
if not ids:
|
||||
return {}
|
||||
db = _session(session)
|
||||
counts: dict[str, dict[str, int]] = {
|
||||
tenant_id: {
|
||||
"users": 0,
|
||||
"active_users": 0,
|
||||
"groups": 0,
|
||||
"api_keys": 0,
|
||||
"active_api_keys": 0,
|
||||
}
|
||||
for tenant_id in ids
|
||||
}
|
||||
user_rows = (
|
||||
db.query(
|
||||
User.tenant_id,
|
||||
func.count(User.id),
|
||||
func.coalesce(
|
||||
func.sum(case((User.is_active.is_(True), 1), else_=0)),
|
||||
0,
|
||||
),
|
||||
)
|
||||
.filter(User.tenant_id.in_(ids))
|
||||
.group_by(User.tenant_id)
|
||||
.all()
|
||||
)
|
||||
for tenant_id, users, active_users in user_rows:
|
||||
counts[tenant_id]["users"] = int(users)
|
||||
counts[tenant_id]["active_users"] = int(active_users)
|
||||
|
||||
group_rows = (
|
||||
db.query(Group.tenant_id, func.count(Group.id))
|
||||
.filter(Group.tenant_id.in_(ids))
|
||||
.group_by(Group.tenant_id)
|
||||
.all()
|
||||
)
|
||||
for tenant_id, groups in group_rows:
|
||||
counts[tenant_id]["groups"] = int(groups)
|
||||
|
||||
api_key_rows = (
|
||||
db.query(
|
||||
ApiKey.tenant_id,
|
||||
func.count(ApiKey.id),
|
||||
func.coalesce(
|
||||
func.sum(case((ApiKey.revoked_at.is_(None), 1), else_=0)),
|
||||
0,
|
||||
),
|
||||
)
|
||||
.filter(ApiKey.tenant_id.in_(ids))
|
||||
.group_by(ApiKey.tenant_id)
|
||||
.all()
|
||||
)
|
||||
for tenant_id, api_keys, active_api_keys in api_key_rows:
|
||||
counts[tenant_id]["api_keys"] = int(api_keys)
|
||||
counts[tenant_id]["active_api_keys"] = int(active_api_keys)
|
||||
return counts
|
||||
|
||||
def system_account_count(self, session: object) -> int:
|
||||
db = _session(session)
|
||||
return db.query(Account).count()
|
||||
|
||||
@@ -24,7 +24,6 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
)
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
collect_direct_user_roles,
|
||||
collect_system_roles,
|
||||
collect_user_groups,
|
||||
collect_user_scopes,
|
||||
)
|
||||
@@ -48,7 +47,11 @@ from govoplan_access.backend.db.models import (
|
||||
)
|
||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import OrganizationDirectory
|
||||
from govoplan_access.backend.permissions.catalog import effective_permission_count, expand_scopes
|
||||
from govoplan_access.backend.permissions.catalog import (
|
||||
effective_permission_count,
|
||||
expand_scopes,
|
||||
scopes_grant,
|
||||
)
|
||||
|
||||
|
||||
def _http_admin_error(exc: Exception) -> HTTPException:
|
||||
@@ -358,44 +361,249 @@ def _user_item(
|
||||
)
|
||||
|
||||
|
||||
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
|
||||
memberships = (
|
||||
def _system_membership_rows(
|
||||
session: Session,
|
||||
account_ids: list[str],
|
||||
) -> list[tuple[User, Tenant]]:
|
||||
return (
|
||||
session.query(User, Tenant)
|
||||
.join(Tenant, Tenant.id == User.tenant_id)
|
||||
.filter(User.account_id == account.id)
|
||||
.order_by(Tenant.name.asc())
|
||||
.filter(User.account_id.in_(account_ids))
|
||||
.order_by(User.account_id.asc(), Tenant.name.asc(), User.id.asc())
|
||||
.all()
|
||||
)
|
||||
owner_ids_by_tenant = {
|
||||
tenant.id: tenant_owner_user_ids(session, tenant.id)
|
||||
for _, tenant in memberships
|
||||
|
||||
|
||||
def _memberships_by_account(
|
||||
membership_rows: list[tuple[User, Tenant]],
|
||||
) -> dict[str, list[tuple[User, Tenant]]]:
|
||||
memberships_by_account: dict[str, list[tuple[User, Tenant]]] = defaultdict(list)
|
||||
for user, tenant in membership_rows:
|
||||
memberships_by_account[user.account_id].append((user, tenant))
|
||||
return memberships_by_account
|
||||
|
||||
|
||||
def _system_direct_roles_by_user(
|
||||
session: Session,
|
||||
user_ids: list[str],
|
||||
) -> dict[str, list[Role]]:
|
||||
roles_by_user: dict[str, list[Role]] = defaultdict(list)
|
||||
if not user_ids:
|
||||
return roles_by_user
|
||||
rows = (
|
||||
session.query(UserRoleAssignment.user_id, Role)
|
||||
.join(Role, Role.id == UserRoleAssignment.role_id)
|
||||
.filter(UserRoleAssignment.user_id.in_(user_ids))
|
||||
.order_by(UserRoleAssignment.user_id.asc(), Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
for user_id, role in rows:
|
||||
roles_by_user[user_id].append(role)
|
||||
return roles_by_user
|
||||
|
||||
|
||||
def _system_groups_by_user(
|
||||
session: Session,
|
||||
user_ids: list[str],
|
||||
) -> dict[str, list[Group]]:
|
||||
groups_by_user: dict[str, list[Group]] = defaultdict(list)
|
||||
if not user_ids:
|
||||
return groups_by_user
|
||||
rows = (
|
||||
session.query(UserGroupMembership.user_id, Group)
|
||||
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||
.filter(
|
||||
UserGroupMembership.user_id.in_(user_ids),
|
||||
Group.is_active.is_(True),
|
||||
)
|
||||
.order_by(UserGroupMembership.user_id.asc(), Group.name.asc())
|
||||
.all()
|
||||
)
|
||||
for user_id, group in rows:
|
||||
groups_by_user[user_id].append(group)
|
||||
return groups_by_user
|
||||
|
||||
|
||||
def _system_group_roles_by_user(
|
||||
session: Session,
|
||||
user_ids: list[str],
|
||||
) -> dict[str, list[Role]]:
|
||||
group_roles_by_user: dict[str, list[Role]] = defaultdict(list)
|
||||
if not user_ids:
|
||||
return group_roles_by_user
|
||||
rows = (
|
||||
session.query(UserGroupMembership.user_id, Role)
|
||||
.join(
|
||||
GroupRoleAssignment,
|
||||
GroupRoleAssignment.group_id == UserGroupMembership.group_id,
|
||||
)
|
||||
.join(Role, Role.id == GroupRoleAssignment.role_id)
|
||||
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||
.filter(
|
||||
UserGroupMembership.user_id.in_(user_ids),
|
||||
Group.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for user_id, role in rows:
|
||||
group_roles_by_user[user_id].append(role)
|
||||
return group_roles_by_user
|
||||
|
||||
|
||||
def _system_owner_ids_by_tenant(
|
||||
membership_rows: list[tuple[User, Tenant]],
|
||||
*,
|
||||
accounts_by_id: dict[str, Account],
|
||||
direct_roles_by_user: dict[str, list[Role]],
|
||||
group_roles_by_user: dict[str, list[Role]],
|
||||
) -> dict[str, set[str]]:
|
||||
owner_ids_by_tenant: dict[str, set[str]] = defaultdict(set)
|
||||
for user, tenant in membership_rows:
|
||||
account = accounts_by_id[user.account_id]
|
||||
if not user.is_active or not account.is_active:
|
||||
continue
|
||||
effective_permissions = [
|
||||
permission
|
||||
for role in direct_roles_by_user[user.id] + group_roles_by_user[user.id]
|
||||
for permission in (role.permissions or [])
|
||||
]
|
||||
if (
|
||||
scopes_grant(effective_permissions, "admin:roles:write")
|
||||
and scopes_grant(effective_permissions, "campaign:send")
|
||||
):
|
||||
owner_ids_by_tenant[tenant.id].add(user.id)
|
||||
return owner_ids_by_tenant
|
||||
|
||||
|
||||
def _system_roles_for_accounts(
|
||||
session: Session,
|
||||
account_ids: list[str],
|
||||
) -> tuple[dict[str, list[Role]], dict[str, int]]:
|
||||
system_roles_by_account: dict[str, list[Role]] = defaultdict(list)
|
||||
system_role_ids: set[str] = set()
|
||||
rows = (
|
||||
session.query(SystemRoleAssignment.account_id, Role)
|
||||
.join(Role, Role.id == SystemRoleAssignment.role_id)
|
||||
.filter(
|
||||
SystemRoleAssignment.account_id.in_(account_ids),
|
||||
Role.tenant_id.is_(None),
|
||||
)
|
||||
.order_by(SystemRoleAssignment.account_id.asc(), Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
for account_id, role in rows:
|
||||
system_roles_by_account[account_id].append(role)
|
||||
system_role_ids.add(role.id)
|
||||
return (
|
||||
system_roles_by_account,
|
||||
_system_role_assignment_counts(session, sorted(system_role_ids)),
|
||||
)
|
||||
|
||||
|
||||
def _system_membership_item(
|
||||
user: User,
|
||||
tenant: Tenant,
|
||||
*,
|
||||
roles_by_user: dict[str, list[Role]],
|
||||
groups_by_user: dict[str, list[Group]],
|
||||
owner_ids_by_tenant: dict[str, set[str]],
|
||||
) -> dict[str, object]:
|
||||
tenant_owner_ids = owner_ids_by_tenant[tenant.id]
|
||||
return {
|
||||
"tenant_id": tenant.id,
|
||||
"tenant_name": tenant.name,
|
||||
"user_id": user.id,
|
||||
"is_active": user.is_active and tenant.is_active,
|
||||
"role_ids": [role.id for role in roles_by_user[user.id]],
|
||||
"group_ids": [group.id for group in groups_by_user[user.id]],
|
||||
"is_owner": user.id in tenant_owner_ids,
|
||||
"is_last_active_owner": (
|
||||
user.id in tenant_owner_ids and len(tenant_owner_ids) == 1
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _system_account_response_item(
|
||||
session: Session,
|
||||
account: Account,
|
||||
*,
|
||||
memberships: list[tuple[User, Tenant]],
|
||||
roles_by_user: dict[str, list[Role]],
|
||||
groups_by_user: dict[str, list[Group]],
|
||||
owner_ids_by_tenant: dict[str, set[str]],
|
||||
system_roles: list[Role],
|
||||
system_role_counts: dict[str, int],
|
||||
) -> SystemAccountItem:
|
||||
return SystemAccountItem(
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
is_active=account.is_active,
|
||||
memberships=[
|
||||
{
|
||||
"tenant_id": tenant.id,
|
||||
"tenant_name": tenant.name,
|
||||
"user_id": user.id,
|
||||
"is_active": user.is_active and tenant.is_active,
|
||||
"role_ids": [role.id for role in collect_direct_user_roles(session, user)],
|
||||
"group_ids": [group.id for group in collect_user_groups(session, user)],
|
||||
"is_owner": user.id in owner_ids_by_tenant[tenant.id],
|
||||
"is_last_active_owner": (
|
||||
user.id in owner_ids_by_tenant[tenant.id]
|
||||
and len(owner_ids_by_tenant[tenant.id]) == 1
|
||||
),
|
||||
}
|
||||
_system_membership_item(
|
||||
user,
|
||||
tenant,
|
||||
roles_by_user=roles_by_user,
|
||||
groups_by_user=groups_by_user,
|
||||
owner_ids_by_tenant=owner_ids_by_tenant,
|
||||
)
|
||||
for user, tenant in memberships
|
||||
],
|
||||
roles=[_role_summary(session, role) for role in collect_system_roles(session, account)],
|
||||
roles=[
|
||||
_role_summary(
|
||||
session,
|
||||
role,
|
||||
system_role_assignment_counts=system_role_counts,
|
||||
)
|
||||
for role in system_roles
|
||||
],
|
||||
last_login_at=account.last_login_at,
|
||||
)
|
||||
|
||||
|
||||
def _system_account_items(
|
||||
session: Session,
|
||||
accounts: list[Account],
|
||||
) -> list[SystemAccountItem]:
|
||||
if not accounts:
|
||||
return []
|
||||
account_ids = [account.id for account in accounts]
|
||||
accounts_by_id = {account.id: account for account in accounts}
|
||||
membership_rows = _system_membership_rows(session, account_ids)
|
||||
memberships_by_account = _memberships_by_account(membership_rows)
|
||||
user_ids = [user.id for user, _tenant in membership_rows]
|
||||
roles_by_user = _system_direct_roles_by_user(session, user_ids)
|
||||
groups_by_user = _system_groups_by_user(session, user_ids)
|
||||
group_roles_by_user = _system_group_roles_by_user(session, user_ids)
|
||||
owner_ids_by_tenant = _system_owner_ids_by_tenant(
|
||||
membership_rows,
|
||||
accounts_by_id=accounts_by_id,
|
||||
direct_roles_by_user=roles_by_user,
|
||||
group_roles_by_user=group_roles_by_user,
|
||||
)
|
||||
system_roles_by_account, system_role_counts = _system_roles_for_accounts(
|
||||
session,
|
||||
account_ids,
|
||||
)
|
||||
return [
|
||||
_system_account_response_item(
|
||||
session,
|
||||
account,
|
||||
memberships=memberships_by_account[account.id],
|
||||
roles_by_user=roles_by_user,
|
||||
groups_by_user=groups_by_user,
|
||||
owner_ids_by_tenant=owner_ids_by_tenant,
|
||||
system_roles=system_roles_by_account[account.id],
|
||||
system_role_counts=system_role_counts,
|
||||
)
|
||||
for account in accounts
|
||||
]
|
||||
|
||||
|
||||
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
|
||||
return _system_account_items(session, [account])[0]
|
||||
|
||||
|
||||
def _api_key_item(session: Session, item: ApiKey, *, accounts_by_user_id: dict[str, Account] | None = None) -> ApiKeyAdminItem:
|
||||
if accounts_by_user_id is not None:
|
||||
account = accounts_by_user_id.get(item.user_id)
|
||||
|
||||
@@ -193,7 +193,7 @@ class OrganizationUnitItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OrganizationUnitListResponse(BaseModel):
|
||||
class OrganizationUnitListResponse(PagedListResponse):
|
||||
organization_units: list[OrganizationUnitItem]
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@ class FunctionAdminItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionListResponse(BaseModel):
|
||||
class FunctionListResponse(PagedListResponse):
|
||||
functions: list[FunctionAdminItem]
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ class ExternalFunctionRoleMappingItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingListResponse(BaseModel):
|
||||
class ExternalFunctionRoleMappingListResponse(PagedListResponse):
|
||||
mappings: list[ExternalFunctionRoleMappingItem]
|
||||
|
||||
|
||||
@@ -318,7 +318,7 @@ class FunctionAssignmentAdminItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionAssignmentListResponse(BaseModel):
|
||||
class FunctionAssignmentListResponse(PagedListResponse):
|
||||
assignments: list[FunctionAssignmentAdminItem]
|
||||
|
||||
|
||||
@@ -368,7 +368,7 @@ class FunctionDelegationAdminItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionDelegationListResponse(BaseModel):
|
||||
class FunctionDelegationListResponse(PagedListResponse):
|
||||
delegations: list[FunctionDelegationAdminItem]
|
||||
|
||||
|
||||
@@ -876,6 +876,113 @@ class AdminApiKeyCreateResponse(ApiKeyAdminItem):
|
||||
secret: str
|
||||
|
||||
|
||||
class ServiceAccountItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
scope_ceiling: list[str] = Field(default_factory=list)
|
||||
is_active: bool
|
||||
revision: int
|
||||
created_by_account_id: str | None = None
|
||||
updated_by_account_id: str | None = None
|
||||
retired_at: datetime | None = None
|
||||
credential_count: int = 0
|
||||
active_credential_count: int = 0
|
||||
last_credential_used_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ServiceAccountListResponse(BaseModel):
|
||||
items: list[ServiceAccountItem]
|
||||
|
||||
|
||||
class ServiceAccountCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=4000)
|
||||
scope_ceiling: list[str] = Field(
|
||||
default_factory=list,
|
||||
max_length=200,
|
||||
)
|
||||
|
||||
|
||||
class ServiceAccountUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=4000)
|
||||
scope_ceiling: list[str] | None = Field(
|
||||
default=None,
|
||||
max_length=200,
|
||||
)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class ServiceAccountRetireRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class ServiceAccountCredentialItem(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
prefix: str
|
||||
scopes: list[str] = Field(default_factory=list)
|
||||
expires_at: datetime | None = None
|
||||
last_used_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ServiceAccountCredentialListResponse(BaseModel):
|
||||
service_account_revision: int
|
||||
items: list[ServiceAccountCredentialItem]
|
||||
|
||||
|
||||
class ServiceAccountCredentialCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
scopes: list[str] = Field(min_length=1, max_length=200)
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class ServiceAccountCredentialRotateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
scopes: list[str] | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=200,
|
||||
)
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class ServiceAccountCredentialRevokeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
|
||||
|
||||
class ServiceAccountCredentialMutationResponse(BaseModel):
|
||||
service_account_revision: int
|
||||
credential: ServiceAccountCredentialItem
|
||||
|
||||
|
||||
class ServiceAccountCredentialSecretResponse(
|
||||
ServiceAccountCredentialMutationResponse
|
||||
):
|
||||
secret: str
|
||||
|
||||
|
||||
class AuditAdminItem(BaseModel):
|
||||
id: str
|
||||
scope: Literal["tenant", "system"] = "tenant"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.api.v1.schemas import (
|
||||
@@ -21,6 +23,7 @@ from govoplan_core.api.v1.schemas import (
|
||||
ProfileUpdateRequest,
|
||||
RoleInfo,
|
||||
SwitchTenantRequest,
|
||||
SwitchActingContextRequest,
|
||||
TenantInfo,
|
||||
TenantMembershipInfo,
|
||||
UserInfo,
|
||||
@@ -29,6 +32,12 @@ from govoplan_core.api.v1.schemas import (
|
||||
from govoplan_core.core.access import AuthMethod, PrincipalRef
|
||||
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||
from govoplan_core.core.idm import (
|
||||
CAPABILITY_IDM_DIRECTORY,
|
||||
IdmDirectory,
|
||||
OrganizationFunctionAssignmentRef,
|
||||
)
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.admin.settings import get_system_settings
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
@@ -57,6 +66,7 @@ from govoplan_access.backend.security.login_throttle import (
|
||||
LoginThrottleDecision,
|
||||
build_login_throttle,
|
||||
)
|
||||
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
authenticate_session_token,
|
||||
collect_user_authorization_context,
|
||||
@@ -72,6 +82,72 @@ from govoplan_access.backend.security.sessions import (
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class ActingContextInfo(BaseModel):
|
||||
assignment_id: str
|
||||
acting_for_account_id: str
|
||||
function_id: str
|
||||
organization_unit_id: str
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
|
||||
|
||||
class ActingContextListResponse(BaseModel):
|
||||
contexts: list[ActingContextInfo] = Field(default_factory=list)
|
||||
active_assignment_id: str | None = None
|
||||
|
||||
|
||||
def _acting_assignments(
|
||||
request: Request,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
if not isinstance(registry, PlatformRegistry) or not registry.has_capability(
|
||||
CAPABILITY_IDM_DIRECTORY
|
||||
):
|
||||
return ()
|
||||
directory = registry.require_capability(CAPABILITY_IDM_DIRECTORY)
|
||||
if not isinstance(directory, IdmDirectory):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Invalid capability: {CAPABILITY_IDM_DIRECTORY}",
|
||||
)
|
||||
return tuple(
|
||||
item
|
||||
for item in directory.organization_function_assignments_for_account(
|
||||
principal.account_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
)
|
||||
if item.source == "acting_for"
|
||||
and item.acting_for_account_id
|
||||
and item.status == "active"
|
||||
)
|
||||
|
||||
|
||||
def _acting_context_response(
|
||||
assignments: tuple[OrganizationFunctionAssignmentRef, ...],
|
||||
*,
|
||||
active_assignment_id: str | None,
|
||||
) -> ActingContextListResponse:
|
||||
available_ids = {item.id for item in assignments}
|
||||
return ActingContextListResponse(
|
||||
contexts=[
|
||||
ActingContextInfo(
|
||||
assignment_id=item.id,
|
||||
acting_for_account_id=str(item.acting_for_account_id),
|
||||
function_id=item.function_id,
|
||||
organization_unit_id=item.organization_unit_id,
|
||||
valid_from=item.valid_from,
|
||||
valid_until=item.valid_until,
|
||||
)
|
||||
for item in assignments
|
||||
],
|
||||
active_assignment_id=(
|
||||
active_assignment_id if active_assignment_id in available_ids else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AuthContext:
|
||||
account: Account
|
||||
@@ -484,6 +560,12 @@ def _shell_response(
|
||||
auth_method=auth_method, # type: ignore[arg-type]
|
||||
api_key_id=api_key.id if api_key else None,
|
||||
session_id=auth_session.id if auth_session else None,
|
||||
acting_assignment_id=(
|
||||
auth_session.acting_assignment_id if auth_session else None
|
||||
),
|
||||
acting_for_account_id=(
|
||||
auth_session.acting_for_account_id if auth_session else None
|
||||
),
|
||||
email=account.email,
|
||||
display_name=account.display_name or user.display_name,
|
||||
),
|
||||
@@ -603,6 +685,8 @@ def _me_response(
|
||||
api_key_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
service_account_id: str | None = None,
|
||||
acting_assignment_id: str | None = None,
|
||||
acting_for_account_id: str | None = None,
|
||||
include_system: bool = True,
|
||||
include_all_memberships: bool = True,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
@@ -675,6 +759,8 @@ def _me_response(
|
||||
api_key_id=api_key_id,
|
||||
session_id=session_id,
|
||||
service_account_id=service_account_id,
|
||||
acting_assignment_id=acting_assignment_id,
|
||||
acting_for_account_id=acting_for_account_id,
|
||||
email=account.email,
|
||||
display_name=account.display_name or user.display_name,
|
||||
).to_dict()
|
||||
@@ -794,6 +880,8 @@ def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session =
|
||||
api_key_id=principal.api_key_id,
|
||||
session_id=principal.session_id,
|
||||
service_account_id=principal.principal.service_account_id,
|
||||
acting_assignment_id=principal.acting_assignment_id,
|
||||
acting_for_account_id=principal.acting_for_account_id,
|
||||
include_system=principal.auth_session is not None,
|
||||
include_all_memberships=principal.auth_session is not None,
|
||||
identity_id=principal.principal.identity_id,
|
||||
@@ -914,6 +1002,79 @@ def switch_tenant(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/acting-contexts", response_model=ActingContextListResponse)
|
||||
def list_acting_contexts(
|
||||
request: Request,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> ActingContextListResponse:
|
||||
if principal.auth_session is None:
|
||||
return ActingContextListResponse()
|
||||
assignments = _acting_assignments(request, principal=principal)
|
||||
return _acting_context_response(
|
||||
assignments,
|
||||
active_assignment_id=principal.auth_session.acting_assignment_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/switch-acting-context", response_model=ActingContextListResponse)
|
||||
def switch_acting_context(
|
||||
payload: SwitchActingContextRequest,
|
||||
request: Request,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
) -> ActingContextListResponse:
|
||||
if principal.auth_session is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="API keys cannot select an acting context.",
|
||||
)
|
||||
assignments = _acting_assignments(request, principal=principal)
|
||||
selected = next(
|
||||
(item for item in assignments if item.id == payload.assignment_id),
|
||||
None,
|
||||
)
|
||||
if payload.assignment_id is not None and selected is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="The acting assignment is not currently available to this account.",
|
||||
)
|
||||
previous = principal.auth_session.acting_assignment_id
|
||||
principal.auth_session.acting_assignment_id = selected.id if selected else None
|
||||
principal.auth_session.acting_for_account_id = (
|
||||
selected.acting_for_account_id if selected else None
|
||||
)
|
||||
principal.auth_session.last_seen_at = utc_now()
|
||||
session.add(principal.auth_session)
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
action="access.acting_context.switched",
|
||||
object_type="idm_function_assignment",
|
||||
object_id=selected.id if selected else previous,
|
||||
details={
|
||||
"previous_assignment_id": previous,
|
||||
"selected_assignment_id": selected.id if selected else None,
|
||||
"acting_for_account_id": (
|
||||
selected.acting_for_account_id if selected else None
|
||||
),
|
||||
},
|
||||
)
|
||||
invalidate_auth_principals(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
source_module="access",
|
||||
resource_type="acting_context",
|
||||
resource_id=principal.auth_session.id,
|
||||
)
|
||||
session.commit()
|
||||
principal_summary_cache.clear()
|
||||
return _acting_context_response(
|
||||
assignments,
|
||||
active_assignment_id=selected.id if selected else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(
|
||||
response: Response,
|
||||
|
||||
@@ -57,6 +57,7 @@ from govoplan_access.backend.api.v1.admin_common import (
|
||||
_set_system_memberships,
|
||||
_system_role_assignment_counts,
|
||||
_system_account_item,
|
||||
_system_account_items,
|
||||
_tenant_role_assignment_counts,
|
||||
_user_item,
|
||||
)
|
||||
@@ -145,6 +146,7 @@ from govoplan_core.core.configuration_packages import (
|
||||
CONFIGURATION_PROVIDER_CAPABILITY,
|
||||
ConfigurationExportSelection,
|
||||
ConfigurationPreflightContext,
|
||||
ConfigurationProvider,
|
||||
apply_configuration_package,
|
||||
dry_run_configuration_package,
|
||||
export_configuration_package,
|
||||
@@ -163,6 +165,10 @@ from govoplan_core.core.configuration_control import (
|
||||
record_configuration_change_applied,
|
||||
)
|
||||
from govoplan_core.core.configuration_safety import configuration_safety_catalog, plan_configuration_change
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ExternalProviderStateContext,
|
||||
collect_external_provider_states,
|
||||
)
|
||||
from govoplan_core.core.access import CAPABILITY_ACCESS_EXPLANATION, AccessExplanationService, AccessDecisionProvenance, PrincipalRef
|
||||
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
||||
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory, OrganizationFunctionAssignmentRef
|
||||
@@ -176,6 +182,7 @@ from govoplan_core.core.change_sequence import (
|
||||
sequence_entries_since,
|
||||
sequence_watermark_is_expired,
|
||||
)
|
||||
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
@@ -357,6 +364,16 @@ def _record_access_change(
|
||||
actor_id=principal.user.id,
|
||||
payload=payload or {},
|
||||
)
|
||||
invalidate_auth_principals(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_module=ACCESS_MODULE_ID,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
actor_type="user",
|
||||
actor_id=principal.user.id,
|
||||
reason=operation,
|
||||
)
|
||||
|
||||
|
||||
def _require_any_permission(principal: ApiPrincipal, *scopes: str) -> None:
|
||||
@@ -484,14 +501,42 @@ def _validate_parent_organization_unit(
|
||||
raise AdminValidationError("Parent organization unit does not belong to the tenant.")
|
||||
|
||||
|
||||
def _function_item(session: Session, item: Function) -> FunctionAdminItem:
|
||||
role_ids = [
|
||||
row[0]
|
||||
for row in session.query(FunctionRoleAssignment.role_id)
|
||||
.filter(FunctionRoleAssignment.function_id == item.id)
|
||||
.order_by(FunctionRoleAssignment.created_at.asc())
|
||||
def _function_role_ids_by_function_id(
|
||||
session: Session,
|
||||
function_ids: Iterable[str],
|
||||
) -> dict[str, list[str]]:
|
||||
requested = tuple(dict.fromkeys(function_ids))
|
||||
result: dict[str, list[str]] = {function_id: [] for function_id in requested}
|
||||
if not requested:
|
||||
return result
|
||||
rows = (
|
||||
session.query(
|
||||
FunctionRoleAssignment.function_id,
|
||||
FunctionRoleAssignment.role_id,
|
||||
)
|
||||
.filter(FunctionRoleAssignment.function_id.in_(requested))
|
||||
.order_by(
|
||||
FunctionRoleAssignment.function_id.asc(),
|
||||
FunctionRoleAssignment.created_at.asc(),
|
||||
)
|
||||
.all()
|
||||
]
|
||||
)
|
||||
for function_id, role_id in rows:
|
||||
result.setdefault(function_id, []).append(role_id)
|
||||
return result
|
||||
|
||||
|
||||
def _function_item(
|
||||
session: Session,
|
||||
item: Function,
|
||||
*,
|
||||
role_ids_by_function_id: dict[str, list[str]] | None = None,
|
||||
) -> FunctionAdminItem:
|
||||
if role_ids_by_function_id is None:
|
||||
role_ids_by_function_id = _function_role_ids_by_function_id(
|
||||
session,
|
||||
(item.id,),
|
||||
)
|
||||
return FunctionAdminItem(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
@@ -499,7 +544,7 @@ def _function_item(session: Session, item: Function) -> FunctionAdminItem:
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
role_ids=role_ids,
|
||||
role_ids=role_ids_by_function_id.get(item.id, []),
|
||||
delegable=item.delegable,
|
||||
act_in_place_allowed=item.act_in_place_allowed,
|
||||
is_active=item.is_active,
|
||||
@@ -875,9 +920,10 @@ def configuration_package_catalog_validation(
|
||||
@router.post("/configuration-packages/dry-run", response_model=ConfigurationPackageDryRunResponse)
|
||||
def configuration_package_dry_run_endpoint(
|
||||
payload: ConfigurationPackageRunRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")),
|
||||
):
|
||||
result = dry_run_configuration_package(payload.package, _configuration_providers(), _configuration_context(principal, tenant_id=payload.tenant_id, supplied_data=payload.supplied_data))
|
||||
result = dry_run_configuration_package(payload.package, _configuration_providers(), _configuration_context(principal, tenant_id=payload.tenant_id, supplied_data=payload.supplied_data, session=session))
|
||||
return ConfigurationPackageDryRunResponse(
|
||||
diagnostics=[item.to_dict() for item in result.diagnostics],
|
||||
required_data=[item.to_dict() for item in result.required_data],
|
||||
@@ -903,7 +949,7 @@ def configuration_package_apply_endpoint(
|
||||
)
|
||||
except ConfigurationControlError as exc:
|
||||
raise _configuration_control_http_error(exc) from exc
|
||||
result = apply_configuration_package(payload.package, _configuration_providers(), _configuration_context(principal, tenant_id=payload.tenant_id, supplied_data=payload.supplied_data))
|
||||
result = apply_configuration_package(payload.package, _configuration_providers(), _configuration_context(principal, tenant_id=payload.tenant_id, supplied_data=payload.supplied_data, session=session))
|
||||
record_configuration_change_applied(
|
||||
session,
|
||||
key="configuration_packages.apply",
|
||||
@@ -934,6 +980,7 @@ def configuration_package_apply_endpoint(
|
||||
@router.post("/configuration-packages/export", response_model=ConfigurationPackageExportResponse)
|
||||
def configuration_package_export_endpoint(
|
||||
payload: ConfigurationPackageExportRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")),
|
||||
):
|
||||
selection = ConfigurationExportSelection(
|
||||
@@ -942,7 +989,7 @@ def configuration_package_export_endpoint(
|
||||
module_ids=tuple(payload.module_ids),
|
||||
object_refs=tuple(payload.object_refs),
|
||||
)
|
||||
result = export_configuration_package(_configuration_providers(), selection, _configuration_context(principal, tenant_id=payload.tenant_id))
|
||||
result = export_configuration_package(_configuration_providers(), selection, _configuration_context(principal, tenant_id=payload.tenant_id, session=session))
|
||||
return ConfigurationPackageExportResponse(
|
||||
fragments=[item.to_dict() for item in result.fragments],
|
||||
data_requirements=[item.to_dict() for item in result.data_requirements],
|
||||
@@ -952,28 +999,60 @@ def configuration_package_export_endpoint(
|
||||
|
||||
def _configuration_providers() -> tuple[object, ...]:
|
||||
registry = get_registry()
|
||||
if registry is not None and hasattr(registry, "has_capability") and registry.has_capability(ACCESS_CONFIGURATION_CAPABILITY):
|
||||
capability = registry.capability(ACCESS_CONFIGURATION_CAPABILITY)
|
||||
if capability is not None:
|
||||
return (capability,)
|
||||
return (SqlAccessConfigurationProvider(),)
|
||||
providers: dict[str, ConfigurationProvider] = {}
|
||||
if registry is not None and hasattr(registry, "capability_names"):
|
||||
for capability_name in registry.capability_names():
|
||||
if not capability_name.endswith(".configuration"):
|
||||
continue
|
||||
capability = registry.capability(capability_name)
|
||||
if isinstance(capability, ConfigurationProvider):
|
||||
providers[capability.module_id] = capability
|
||||
providers.setdefault("access", SqlAccessConfigurationProvider())
|
||||
return tuple(providers[module_id] for module_id in sorted(providers))
|
||||
|
||||
|
||||
def _configuration_context(principal: ApiPrincipal, *, tenant_id: str | None = None, supplied_data: dict[str, Any] | None = None) -> ConfigurationPreflightContext:
|
||||
def _configuration_context(
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
supplied_data: dict[str, Any] | None = None,
|
||||
session: Session | None = None,
|
||||
) -> ConfigurationPreflightContext:
|
||||
registry = get_registry()
|
||||
installed_modules: dict[str, str] = {"access": "0.1.6"}
|
||||
capabilities = {CONFIGURATION_PROVIDER_CAPABILITY, ACCESS_CONFIGURATION_CAPABILITY}
|
||||
external_provider_declarations: dict[str, dict[str, object]] = {}
|
||||
external_provider_states: dict[str, dict[str, object]] = {}
|
||||
if registry is not None and hasattr(registry, "manifests"):
|
||||
manifests = registry.manifests()
|
||||
installed_modules = {manifest.id: manifest.version for manifest in manifests}
|
||||
if hasattr(registry, "has_capability") and registry.has_capability(ACCESS_CONFIGURATION_CAPABILITY):
|
||||
capabilities.add(ACCESS_CONFIGURATION_CAPABILITY)
|
||||
if hasattr(registry, "capability_names"):
|
||||
capabilities.update(registry.capability_names())
|
||||
if hasattr(registry, "external_provider_declarations"):
|
||||
external_provider_declarations = {
|
||||
declaration.id: declaration.to_dict()
|
||||
for declaration in registry.external_provider_declarations()
|
||||
}
|
||||
if session is not None and hasattr(
|
||||
registry,
|
||||
"external_provider_state_providers",
|
||||
):
|
||||
external_provider_states = collect_external_provider_states(
|
||||
registry.external_provider_state_providers(),
|
||||
ExternalProviderStateContext(
|
||||
session=session,
|
||||
tenant_id=tenant_id or principal.tenant_id,
|
||||
principal=principal,
|
||||
),
|
||||
)
|
||||
return ConfigurationPreflightContext(
|
||||
tenant_id=tenant_id or principal.tenant_id,
|
||||
operator_user_id=principal.user.id,
|
||||
supplied_data=supplied_data or {},
|
||||
installed_modules=installed_modules,
|
||||
capabilities=frozenset(capabilities),
|
||||
external_provider_declarations=external_provider_declarations,
|
||||
external_provider_states=external_provider_states,
|
||||
)
|
||||
|
||||
|
||||
@@ -1266,17 +1345,26 @@ def deactivate_identity(
|
||||
@router.get("/organization-units", response_model=OrganizationUnitListResponse)
|
||||
def list_organization_units(
|
||||
tenant_id: str | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
items = (
|
||||
query = (
|
||||
session.query(OrganizationUnit)
|
||||
.filter(OrganizationUnit.tenant_id == tenant.id)
|
||||
.order_by(OrganizationUnit.name.asc())
|
||||
.all()
|
||||
)
|
||||
return OrganizationUnitListResponse(organization_units=[_organization_unit_item(item) for item in items])
|
||||
items, pagination = _page_query(
|
||||
query,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return OrganizationUnitListResponse(
|
||||
organization_units=[_organization_unit_item(item) for item in items],
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/organization-units", response_model=OrganizationUnitItem, status_code=status.HTTP_201_CREATED)
|
||||
@@ -1394,6 +1482,8 @@ def deactivate_organization_unit(
|
||||
def list_functions(
|
||||
tenant_id: str | None = None,
|
||||
organization_unit_id: str | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||
):
|
||||
@@ -1401,8 +1491,26 @@ def list_functions(
|
||||
query = session.query(Function).filter(Function.tenant_id == tenant.id)
|
||||
if organization_unit_id:
|
||||
query = query.filter(Function.organization_unit_id == organization_unit_id)
|
||||
functions = query.order_by(Function.name.asc()).all()
|
||||
return FunctionListResponse(functions=[_function_item(session, item) for item in functions])
|
||||
functions, pagination = _page_query(
|
||||
query.order_by(Function.name.asc()),
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
role_ids_by_function_id = _function_role_ids_by_function_id(
|
||||
session,
|
||||
(item.id for item in functions),
|
||||
)
|
||||
return FunctionListResponse(
|
||||
functions=[
|
||||
_function_item(
|
||||
session,
|
||||
item,
|
||||
role_ids_by_function_id=role_ids_by_function_id,
|
||||
)
|
||||
for item in functions
|
||||
],
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/functions", response_model=FunctionAdminItem, status_code=status.HTTP_201_CREATED)
|
||||
@@ -1535,6 +1643,8 @@ def list_external_function_role_mappings(
|
||||
tenant_id: str | None = None,
|
||||
source_module: str | None = None,
|
||||
function_id: str | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||
):
|
||||
@@ -1544,23 +1654,57 @@ def list_external_function_role_mappings(
|
||||
query = query.filter(ExternalFunctionRoleAssignment.source_module == source_module.strip())
|
||||
if function_id:
|
||||
query = query.filter(ExternalFunctionRoleAssignment.function_id == function_id.strip())
|
||||
items = query.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc()).all()
|
||||
return ExternalFunctionRoleMappingListResponse(mappings=[_external_function_role_mapping_item(item) for item in items])
|
||||
items, pagination = _page_query(
|
||||
query.order_by(
|
||||
ExternalFunctionRoleAssignment.source_module.asc(),
|
||||
ExternalFunctionRoleAssignment.function_id.asc(),
|
||||
),
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return ExternalFunctionRoleMappingListResponse(
|
||||
mappings=[_external_function_role_mapping_item(item) for item in items],
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
def _full_external_function_role_mappings_delta_response(session: Session, tenant: Tenant) -> ExternalFunctionRoleMappingListDeltaResponse:
|
||||
items = (
|
||||
def _full_external_function_role_mappings_delta_response(
|
||||
session: Session,
|
||||
tenant: Tenant,
|
||||
*,
|
||||
cursor: tuple[int, int] | None = None,
|
||||
limit: int = 500,
|
||||
) -> ExternalFunctionRoleMappingListDeltaResponse:
|
||||
snapshot_sequence = (
|
||||
cursor[1]
|
||||
if cursor is not None
|
||||
else max_sequence_id(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
module_id=ACCESS_MODULE_ID,
|
||||
collections=(ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,),
|
||||
)
|
||||
)
|
||||
page = cursor[0] if cursor is not None else 1
|
||||
query = (
|
||||
session.query(ExternalFunctionRoleAssignment)
|
||||
.filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id)
|
||||
.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc())
|
||||
.all()
|
||||
)
|
||||
items, pagination, watermark, has_more = _full_delta_page(
|
||||
query,
|
||||
page=page,
|
||||
page_size=limit,
|
||||
scope="external-function-role-mappings",
|
||||
snapshot_sequence=snapshot_sequence,
|
||||
)
|
||||
return ExternalFunctionRoleMappingListDeltaResponse(
|
||||
mappings=[_external_function_role_mapping_item(item) for item in items],
|
||||
deleted=[],
|
||||
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,)),
|
||||
has_more=False,
|
||||
watermark=watermark,
|
||||
has_more=has_more,
|
||||
full=True,
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
@@ -1620,8 +1764,17 @@ def list_external_function_role_mappings_delta(
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
if since is None:
|
||||
return _full_external_function_role_mappings_delta_response(session, tenant)
|
||||
full_cursor = _decode_full_delta_cursor(
|
||||
since,
|
||||
scope="external-function-role-mappings",
|
||||
)
|
||||
if since is None or full_cursor is not None:
|
||||
return _full_external_function_role_mappings_delta_response(
|
||||
session,
|
||||
tenant,
|
||||
cursor=full_cursor,
|
||||
limit=limit,
|
||||
)
|
||||
return _external_function_role_mappings_delta_response(session, tenant, since=since, limit=limit)
|
||||
|
||||
|
||||
@@ -1735,6 +1888,8 @@ def list_function_assignments(
|
||||
tenant_id: str | None = None,
|
||||
account_id: str | None = None,
|
||||
function_id: str | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:assign")),
|
||||
):
|
||||
@@ -1744,8 +1899,15 @@ def list_function_assignments(
|
||||
query = query.filter(FunctionAssignment.account_id == account_id)
|
||||
if function_id:
|
||||
query = query.filter(FunctionAssignment.function_id == function_id)
|
||||
assignments = query.order_by(FunctionAssignment.created_at.desc()).all()
|
||||
return FunctionAssignmentListResponse(assignments=[_function_assignment_item(item) for item in assignments])
|
||||
assignments, pagination = _page_query(
|
||||
query.order_by(FunctionAssignment.created_at.desc()),
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return FunctionAssignmentListResponse(
|
||||
assignments=[_function_assignment_item(item) for item in assignments],
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/function-assignments", response_model=FunctionAssignmentAdminItem, status_code=status.HTTP_201_CREATED)
|
||||
@@ -1888,6 +2050,8 @@ def deactivate_function_assignment(
|
||||
def list_function_delegations(
|
||||
tenant_id: str | None = None,
|
||||
account_id: str | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:delegate")),
|
||||
):
|
||||
@@ -1895,8 +2059,15 @@ def list_function_delegations(
|
||||
query = session.query(FunctionDelegation).filter(FunctionDelegation.tenant_id == tenant.id)
|
||||
if account_id:
|
||||
query = query.filter((FunctionDelegation.delegator_account_id == account_id) | (FunctionDelegation.delegate_account_id == account_id))
|
||||
delegations = query.order_by(FunctionDelegation.created_at.desc()).all()
|
||||
return FunctionDelegationListResponse(delegations=[_function_delegation_item(item) for item in delegations])
|
||||
delegations, pagination = _page_query(
|
||||
query.order_by(FunctionDelegation.created_at.desc()),
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return FunctionDelegationListResponse(
|
||||
delegations=[_function_delegation_item(item) for item in delegations],
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/function-delegations", response_model=FunctionDelegationAdminItem, status_code=status.HTTP_201_CREATED)
|
||||
@@ -2981,6 +3152,25 @@ def _system_role_summaries_for_response(session: Session, roles: list[Role]) ->
|
||||
return [_role_summary(session, role, system_role_assignment_counts=system_role_counts) for role in roles]
|
||||
|
||||
|
||||
def _system_roles_for_account_catalog(session: Session) -> list[Role]:
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.filter(Role.tenant_id.is_(None))
|
||||
.order_by(Role.name.asc(), Role.id.asc())
|
||||
.limit(1001)
|
||||
.all()
|
||||
)
|
||||
if len(roles) > 1000:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
||||
detail=(
|
||||
"The system role catalog exceeds 1000 entries. "
|
||||
"Use the paginated system roles endpoint."
|
||||
),
|
||||
)
|
||||
return roles
|
||||
|
||||
|
||||
def _full_system_roles_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> RoleListDeltaResponse:
|
||||
ensure_default_roles(session, None)
|
||||
session.commit()
|
||||
@@ -3178,13 +3368,13 @@ _SYSTEM_ACCOUNTS_DELTA_COLLECTIONS = (ACCESS_SYSTEM_ACCOUNTS_COLLECTION, ACCESS_
|
||||
def _full_system_accounts_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> SystemAccountListDeltaResponse:
|
||||
ensure_default_roles(session, None)
|
||||
session.commit()
|
||||
system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all()
|
||||
system_roles = _system_roles_for_account_catalog(session)
|
||||
snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=None, module_id=ACCESS_MODULE_ID, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS)
|
||||
page = cursor[0] if cursor is not None else 1
|
||||
query = session.query(Account).order_by(Account.email.asc(), Account.id.asc())
|
||||
accounts, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="system-accounts", snapshot_sequence=snapshot_sequence)
|
||||
return SystemAccountListDeltaResponse(
|
||||
accounts=[_system_account_item(session, account) for account in accounts],
|
||||
accounts=_system_account_items(session, accounts),
|
||||
roles=_system_role_summaries_for_response(session, system_roles),
|
||||
deleted=[],
|
||||
watermark=watermark,
|
||||
@@ -3223,7 +3413,7 @@ def _system_accounts_delta_response(session: Session, *, since: str, limit: int)
|
||||
)
|
||||
]
|
||||
return SystemAccountListDeltaResponse(
|
||||
accounts=[_system_account_item(session, account) for account in visible_accounts.values()],
|
||||
accounts=_system_account_items(session, list(visible_accounts.values())),
|
||||
roles=_system_role_summaries_for_response(session, list(visible_roles.values())),
|
||||
deleted=deleted,
|
||||
watermark=_access_delta_response_watermark(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, entries=entries, has_more=has_more),
|
||||
@@ -3255,11 +3445,11 @@ def list_system_accounts(
|
||||
):
|
||||
ensure_default_roles(session, None)
|
||||
session.commit()
|
||||
system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all()
|
||||
system_roles = _system_roles_for_account_catalog(session)
|
||||
query = session.query(Account).order_by(Account.email.asc())
|
||||
accounts, pagination = _page_query(query, page=page, page_size=page_size)
|
||||
return SystemAccountListResponse(
|
||||
accounts=[_system_account_item(session, account) for account in accounts],
|
||||
accounts=_system_account_items(session, accounts),
|
||||
roles=_system_role_summaries_for_response(session, system_roles),
|
||||
**pagination,
|
||||
)
|
||||
@@ -3590,7 +3780,14 @@ def _api_key_items_for_response(session: Session, keys: list[ApiKey]) -> list[Ap
|
||||
|
||||
|
||||
def _full_api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool, cursor: tuple[int, int] | None = None, limit: int = 500) -> ApiKeyListDeltaResponse:
|
||||
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
|
||||
query = (
|
||||
session.query(ApiKey)
|
||||
.join(User, User.id == ApiKey.user_id)
|
||||
.filter(
|
||||
ApiKey.tenant_id == tenant.id,
|
||||
User.auth_provider != "service_account",
|
||||
)
|
||||
)
|
||||
if not include_revoked:
|
||||
query = query.filter(ApiKey.revoked_at.is_(None))
|
||||
snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=tenant.id, module_id=ACCESS_MODULE_ID, collections=(ACCESS_API_KEYS_COLLECTION,))
|
||||
@@ -3617,7 +3814,14 @@ def _api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoke
|
||||
if entries is None:
|
||||
return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked, limit=limit)
|
||||
changed_ids = _changed_ids(entries, "access_api_key")
|
||||
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
|
||||
query = (
|
||||
session.query(ApiKey)
|
||||
.join(User, User.id == ApiKey.user_id)
|
||||
.filter(
|
||||
ApiKey.tenant_id == tenant.id,
|
||||
User.auth_provider != "service_account",
|
||||
)
|
||||
)
|
||||
if not include_revoked:
|
||||
query = query.filter(ApiKey.revoked_at.is_(None))
|
||||
visible = {
|
||||
@@ -3667,7 +3871,14 @@ def list_api_keys(
|
||||
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id)
|
||||
query = (
|
||||
session.query(ApiKey)
|
||||
.join(User, User.id == ApiKey.user_id)
|
||||
.filter(
|
||||
ApiKey.tenant_id == tenant.id,
|
||||
User.auth_provider != "service_account",
|
||||
)
|
||||
)
|
||||
if not include_revoked:
|
||||
query = query.filter(ApiKey.revoked_at.is_(None))
|
||||
keys, pagination = _page_query(query.order_by(ApiKey.created_at.desc()), page=page, page_size=page_size)
|
||||
@@ -3690,6 +3901,14 @@ def create_tenant_api_key(
|
||||
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id, User.is_active.is_(True)).one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active user not found")
|
||||
if user.auth_provider == "service_account":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"Use the service-account credential API so revision, scope "
|
||||
"ceiling, rotation, and audit guarantees remain enforced"
|
||||
),
|
||||
)
|
||||
user_scopes = _user_item_for_response(
|
||||
session,
|
||||
user,
|
||||
@@ -3740,7 +3959,16 @@ def revoke_api_key(
|
||||
principal: ApiPrincipal = Depends(require_scope("admin:api_keys:revoke")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
item = session.query(ApiKey).filter(ApiKey.id == api_key_id, ApiKey.tenant_id == tenant.id).one_or_none()
|
||||
item = (
|
||||
session.query(ApiKey)
|
||||
.join(User, User.id == ApiKey.user_id)
|
||||
.filter(
|
||||
ApiKey.id == api_key_id,
|
||||
ApiKey.tenant_id == tenant.id,
|
||||
User.auth_provider != "service_account",
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
|
||||
if item.revoked_at is None:
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.admin.governance import assert_api_keys_allowed
|
||||
from govoplan_access.backend.api.v1.admin_common import _resolve_tenant
|
||||
from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
ServiceAccountCreateRequest,
|
||||
ServiceAccountCredentialCreateRequest,
|
||||
ServiceAccountCredentialItem,
|
||||
ServiceAccountCredentialListResponse,
|
||||
ServiceAccountCredentialMutationResponse,
|
||||
ServiceAccountCredentialRevokeRequest,
|
||||
ServiceAccountCredentialRotateRequest,
|
||||
ServiceAccountCredentialSecretResponse,
|
||||
ServiceAccountItem,
|
||||
ServiceAccountListResponse,
|
||||
ServiceAccountRetireRequest,
|
||||
ServiceAccountUpdateRequest,
|
||||
)
|
||||
from govoplan_access.backend.service_accounts import (
|
||||
ServiceAccountConflictError,
|
||||
ServiceAccountCredentialNotFoundError,
|
||||
ServiceAccountCredentialSummary,
|
||||
ServiceAccountError,
|
||||
ServiceAccountNotFoundError,
|
||||
create_service_account,
|
||||
create_service_account_credential,
|
||||
get_service_account,
|
||||
list_service_accounts,
|
||||
list_service_account_credentials,
|
||||
revoke_service_account_credential,
|
||||
retire_service_account,
|
||||
rotate_service_account_credential,
|
||||
service_account_credential_summaries,
|
||||
update_service_account,
|
||||
)
|
||||
from govoplan_core.admin.common import AdminConflictError
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||
from govoplan_core.db.session import get_session
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/service-accounts",
|
||||
tags=["admin", "service-accounts"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ServiceAccountListResponse)
|
||||
def list_managed_service_accounts(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:read")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
items = list_service_accounts(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
summaries = service_account_credential_summaries(
|
||||
session,
|
||||
service_accounts=items,
|
||||
)
|
||||
return ServiceAccountListResponse(
|
||||
items=[
|
||||
_service_account_item(item, summaries.get(item.id))
|
||||
for item in items
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{service_account_id}",
|
||||
response_model=ServiceAccountItem,
|
||||
)
|
||||
def get_managed_service_account(
|
||||
service_account_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:read")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
item = get_service_account(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
)
|
||||
except ServiceAccountError as exc:
|
||||
raise _service_account_http_error(exc) from exc
|
||||
summary = service_account_credential_summaries(
|
||||
session,
|
||||
service_accounts=(item,),
|
||||
)[item.id]
|
||||
return _service_account_item(item, summary)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ServiceAccountItem,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_managed_service_account(
|
||||
payload: ServiceAccountCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
item = create_service_account(
|
||||
session,
|
||||
tenant=tenant,
|
||||
principal=principal,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
scope_ceiling=payload.scope_ceiling,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.created",
|
||||
scope="tenant",
|
||||
object_type="service_account",
|
||||
object_id=item.id,
|
||||
details={
|
||||
"name": item.name,
|
||||
"scope_ceiling": list(item.scope_ceiling),
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return _service_account_item(item)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{service_account_id}",
|
||||
response_model=ServiceAccountItem,
|
||||
)
|
||||
def update_managed_service_account(
|
||||
service_account_id: str,
|
||||
payload: ServiceAccountUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
changes = {
|
||||
field: getattr(payload, field)
|
||||
for field in payload.model_fields_set
|
||||
if field != "expected_revision"
|
||||
}
|
||||
for field in ("name", "scope_ceiling", "is_active"):
|
||||
if field in changes and changes[field] is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"{field} cannot be null",
|
||||
)
|
||||
try:
|
||||
item = update_service_account(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
changes=changes,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.updated",
|
||||
scope="tenant",
|
||||
object_type="service_account",
|
||||
object_id=item.id,
|
||||
details={
|
||||
"changed_fields": sorted(changes),
|
||||
"revision": item.revision,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return _service_account_item(item)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{service_account_id}/retire",
|
||||
response_model=ServiceAccountItem,
|
||||
)
|
||||
def retire_managed_service_account(
|
||||
service_account_id: str,
|
||||
payload: ServiceAccountRetireRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
item = retire_service_account(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.retired",
|
||||
scope="tenant",
|
||||
object_type="service_account",
|
||||
object_id=item.id,
|
||||
details={"revision": item.revision},
|
||||
)
|
||||
session.commit()
|
||||
return _service_account_item(item)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{service_account_id}/credentials",
|
||||
response_model=ServiceAccountCredentialListResponse,
|
||||
)
|
||||
def list_managed_service_account_credentials(
|
||||
service_account_id: str,
|
||||
include_revoked: bool = Query(default=True),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:read")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
item, credentials = list_service_account_credentials(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
include_revoked=include_revoked,
|
||||
)
|
||||
except ServiceAccountError as exc:
|
||||
raise _service_account_http_error(exc) from exc
|
||||
return ServiceAccountCredentialListResponse(
|
||||
service_account_revision=item.revision,
|
||||
items=[_credential_item(credential) for credential in credentials],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{service_account_id}/credentials",
|
||||
response_model=ServiceAccountCredentialSecretResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_managed_service_account_credential(
|
||||
service_account_id: str,
|
||||
payload: ServiceAccountCredentialCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
assert_api_keys_allowed(session, tenant)
|
||||
item, created = create_service_account_credential(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
name=payload.name,
|
||||
scopes=payload.scopes,
|
||||
expires_at=payload.expires_at,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError, AdminConflictError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.credential_created",
|
||||
scope="tenant",
|
||||
object_type="service_account_credential",
|
||||
object_id=created.model.id,
|
||||
details={
|
||||
"service_account_id": item.id,
|
||||
"prefix": created.model.prefix,
|
||||
"scopes": list(created.model.scopes),
|
||||
"service_account_revision": item.revision,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return ServiceAccountCredentialSecretResponse(
|
||||
service_account_revision=item.revision,
|
||||
credential=_credential_item(created.model),
|
||||
secret=created.secret,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{service_account_id}/credentials/{credential_id}/rotate",
|
||||
response_model=ServiceAccountCredentialSecretResponse,
|
||||
)
|
||||
def rotate_managed_service_account_credential(
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
payload: ServiceAccountCredentialRotateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
assert_api_keys_allowed(session, tenant)
|
||||
item, previous, created = rotate_service_account_credential(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
credential_id=credential_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
name=payload.name,
|
||||
scopes=payload.scopes,
|
||||
expires_at=payload.expires_at,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError, AdminConflictError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.credential_rotated",
|
||||
scope="tenant",
|
||||
object_type="service_account_credential",
|
||||
object_id=created.model.id,
|
||||
details={
|
||||
"service_account_id": item.id,
|
||||
"previous_credential_id": previous.id,
|
||||
"prefix": created.model.prefix,
|
||||
"scopes": list(created.model.scopes),
|
||||
"service_account_revision": item.revision,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return ServiceAccountCredentialSecretResponse(
|
||||
service_account_revision=item.revision,
|
||||
credential=_credential_item(created.model),
|
||||
secret=created.secret,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{service_account_id}/credentials/{credential_id}/revoke",
|
||||
response_model=ServiceAccountCredentialMutationResponse,
|
||||
)
|
||||
def revoke_managed_service_account_credential(
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
payload: ServiceAccountCredentialRevokeRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("access:service_account:write")
|
||||
),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, None)
|
||||
try:
|
||||
item, credential = revoke_service_account_credential(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
service_account_id=service_account_id,
|
||||
credential_id=credential_id,
|
||||
principal=principal,
|
||||
expected_revision=payload.expected_revision,
|
||||
)
|
||||
except (ServiceAccountError, PermissionError) as exc:
|
||||
session.rollback()
|
||||
raise _service_account_http_error(exc) from exc
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="service_account.credential_revoked",
|
||||
scope="tenant",
|
||||
object_type="service_account_credential",
|
||||
object_id=credential.id,
|
||||
details={
|
||||
"service_account_id": item.id,
|
||||
"prefix": credential.prefix,
|
||||
"service_account_revision": item.revision,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return ServiceAccountCredentialMutationResponse(
|
||||
service_account_revision=item.revision,
|
||||
credential=_credential_item(credential),
|
||||
)
|
||||
|
||||
|
||||
def _service_account_item(
|
||||
item: object,
|
||||
summary: ServiceAccountCredentialSummary | None = None,
|
||||
) -> ServiceAccountItem:
|
||||
values = ServiceAccountItem.model_validate(
|
||||
item, from_attributes=True
|
||||
)
|
||||
if summary is None:
|
||||
return values
|
||||
return values.model_copy(
|
||||
update={
|
||||
"credential_count": summary.credential_count,
|
||||
"active_credential_count": summary.active_credential_count,
|
||||
"last_credential_used_at": summary.last_credential_used_at,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _credential_item(item: object) -> ServiceAccountCredentialItem:
|
||||
return ServiceAccountCredentialItem.model_validate(
|
||||
item,
|
||||
from_attributes=True,
|
||||
)
|
||||
|
||||
|
||||
def _service_account_http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, ServiceAccountNotFoundError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
if isinstance(exc, ServiceAccountCredentialNotFoundError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
if isinstance(exc, ServiceAccountConflictError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
)
|
||||
if isinstance(exc, PermissionError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(exc),
|
||||
)
|
||||
if isinstance(exc, AdminConflictError):
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
)
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -15,15 +17,32 @@ from govoplan_core.core.access import (
|
||||
PrincipalRef,
|
||||
PrincipalResolver,
|
||||
)
|
||||
from govoplan_core.core.automation import (
|
||||
AutomationPrincipalRequest,
|
||||
AutomationPrincipalResolution,
|
||||
)
|
||||
from govoplan_core.core.identity import IdentityDirectory
|
||||
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import OrganizationDirectory
|
||||
from govoplan_core.core.principal_cache import (
|
||||
auth_principal_revision,
|
||||
)
|
||||
from govoplan_core.core.modules import AccessDecision
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
||||
from govoplan_core.db.session import get_database, get_session
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Role, Tenant, User
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
AuthSession,
|
||||
Role,
|
||||
ServiceAccount,
|
||||
Tenant,
|
||||
User,
|
||||
)
|
||||
from govoplan_access.backend.semantic import collect_external_function_roles, identity_id_for_account
|
||||
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||
from govoplan_access.backend.auth.tokens import hash_secret
|
||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
authenticate_session_token,
|
||||
@@ -32,6 +51,7 @@ from govoplan_access.backend.security.sessions import (
|
||||
verify_auth_session_csrf,
|
||||
)
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
@@ -90,6 +110,10 @@ def _build_principal_ref(
|
||||
role_ids = [role.id for role in authorization_context.tenant_roles]
|
||||
if include_system_roles:
|
||||
role_ids.extend(role.id for role in authorization_context.system_roles)
|
||||
acting_assignment = next(
|
||||
(item for item in idm_assignments if item.source == "acting_for"),
|
||||
None,
|
||||
)
|
||||
return PrincipalRef(
|
||||
account_id=account.id,
|
||||
membership_id=user.id,
|
||||
@@ -103,6 +127,10 @@ def _build_principal_ref(
|
||||
auth_method=auth_method, # type: ignore[arg-type]
|
||||
api_key_id=api_key.id if api_key else None,
|
||||
session_id=auth_session.id if auth_session else None,
|
||||
acting_assignment_id=(acting_assignment.id if acting_assignment else None),
|
||||
acting_for_account_id=(
|
||||
acting_assignment.acting_for_account_id if acting_assignment else None
|
||||
),
|
||||
email=account.email,
|
||||
display_name=account.display_name or user.display_name,
|
||||
)
|
||||
@@ -191,6 +219,15 @@ def _resolve_legacy_principal_context(
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
|
||||
|
||||
cached = _cached_principal_context(
|
||||
request,
|
||||
session,
|
||||
token=token,
|
||||
source=source,
|
||||
)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
if source != "cookie":
|
||||
context = _resolve_api_key_principal_context(
|
||||
session,
|
||||
@@ -200,7 +237,14 @@ def _resolve_legacy_principal_context(
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
if context is not None:
|
||||
return context
|
||||
return _cache_resolved_principal_context(
|
||||
session,
|
||||
token=token,
|
||||
context=context,
|
||||
idm_directory=idm_directory,
|
||||
identity_directory=identity_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
|
||||
context = _resolve_session_principal_context(
|
||||
request,
|
||||
@@ -212,11 +256,227 @@ def _resolve_legacy_principal_context(
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
if context is not None:
|
||||
return context
|
||||
return _cache_resolved_principal_context(
|
||||
session,
|
||||
token=token,
|
||||
context=context,
|
||||
idm_directory=idm_directory,
|
||||
identity_directory=identity_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
|
||||
|
||||
|
||||
def _cached_principal_context(
|
||||
request: Request,
|
||||
session: Session,
|
||||
*,
|
||||
token: str,
|
||||
source: str,
|
||||
) -> ResolvedPrincipalContext | None:
|
||||
if not settings.auth_principal_cache_enabled:
|
||||
return None
|
||||
token_digest = hash_secret(token)
|
||||
entry = principal_summary_cache.get(
|
||||
token_digest,
|
||||
session_ttl_seconds=settings.auth_principal_cache_session_ttl_seconds,
|
||||
api_key_ttl_seconds=settings.auth_principal_cache_api_key_ttl_seconds,
|
||||
)
|
||||
if entry is None:
|
||||
return None
|
||||
before = auth_principal_revision(session, tenant_id=entry.principal.tenant_id)
|
||||
if before != entry.revision:
|
||||
principal_summary_cache.discard(token_digest)
|
||||
return None
|
||||
context = _rehydrate_cached_principal(
|
||||
request,
|
||||
session,
|
||||
token_digest=token_digest,
|
||||
source=source,
|
||||
principal=entry.principal,
|
||||
)
|
||||
after = auth_principal_revision(session, tenant_id=entry.principal.tenant_id)
|
||||
if context is None or after != before:
|
||||
principal_summary_cache.discard(token_digest)
|
||||
return None
|
||||
return context
|
||||
|
||||
|
||||
def _rehydrate_cached_principal(
|
||||
request: Request,
|
||||
session: Session,
|
||||
*,
|
||||
token_digest: str,
|
||||
source: str,
|
||||
principal: PrincipalRef,
|
||||
) -> ResolvedPrincipalContext | None:
|
||||
account = session.get(Account, principal.account_id)
|
||||
user = session.get(User, principal.membership_id) if principal.membership_id else None
|
||||
tenant = session.get(Tenant, principal.tenant_id) if principal.tenant_id else None
|
||||
if (
|
||||
not account
|
||||
or not user
|
||||
or not tenant
|
||||
or not account.is_active
|
||||
or not user.is_active
|
||||
or not tenant.is_active
|
||||
or user.account_id != account.id
|
||||
or user.tenant_id != tenant.id
|
||||
):
|
||||
return None
|
||||
|
||||
if principal.auth_method == "api_key":
|
||||
api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None
|
||||
if (
|
||||
api_key is None
|
||||
or api_key.key_hash != token_digest
|
||||
or api_key.revoked_at is not None
|
||||
or api_key.user_id != user.id
|
||||
or api_key.tenant_id != tenant.id
|
||||
or _is_expired(api_key.expires_at, allow_none=True)
|
||||
):
|
||||
return None
|
||||
_touch_auth_activity(
|
||||
session,
|
||||
api_key,
|
||||
field="last_used_at",
|
||||
)
|
||||
return ResolvedPrincipalContext(
|
||||
principal=principal,
|
||||
account=account,
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
auth_session = session.get(AuthSession, principal.session_id) if principal.session_id else None
|
||||
if (
|
||||
auth_session is None
|
||||
or auth_session.token_hash != token_digest
|
||||
or auth_session.revoked_at is not None
|
||||
or auth_session.user_id != user.id
|
||||
or auth_session.account_id != account.id
|
||||
or auth_session.tenant_id != tenant.id
|
||||
or _is_expired(auth_session.expires_at)
|
||||
):
|
||||
return None
|
||||
if source == "cookie":
|
||||
_verify_session_csrf(request, auth_session)
|
||||
_touch_auth_activity(
|
||||
session,
|
||||
auth_session,
|
||||
field="last_seen_at",
|
||||
)
|
||||
return ResolvedPrincipalContext(
|
||||
principal=principal,
|
||||
account=account,
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
auth_session=auth_session,
|
||||
)
|
||||
|
||||
|
||||
def _is_expired(value, *, allow_none: bool = False) -> bool:
|
||||
expires_at = ensure_aware_utc(value)
|
||||
return (not allow_none and expires_at is None) or (
|
||||
expires_at is not None and expires_at < utc_now()
|
||||
)
|
||||
|
||||
|
||||
def _touch_auth_activity(
|
||||
session: Session,
|
||||
model: ApiKey | AuthSession,
|
||||
*,
|
||||
field: str,
|
||||
) -> None:
|
||||
now = utc_now()
|
||||
previous = ensure_aware_utc(getattr(model, field))
|
||||
interval = settings.auth_activity_touch_interval_seconds
|
||||
if interval <= 0 or previous is None or now - previous >= timedelta(seconds=interval):
|
||||
setattr(model, field, now)
|
||||
session.add(model)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _cache_resolved_principal_context(
|
||||
session: Session,
|
||||
*,
|
||||
token: str,
|
||||
context: ResolvedPrincipalContext,
|
||||
idm_directory: IdmDirectory | None,
|
||||
identity_directory: IdentityDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> ResolvedPrincipalContext:
|
||||
if not settings.auth_principal_cache_enabled:
|
||||
return context
|
||||
before = auth_principal_revision(session, tenant_id=context.principal.tenant_id)
|
||||
refreshed = _refresh_principal_context(
|
||||
session,
|
||||
context=context,
|
||||
idm_directory=idm_directory,
|
||||
identity_directory=identity_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
after = auth_principal_revision(session, tenant_id=context.principal.tenant_id)
|
||||
if before == after:
|
||||
principal_summary_cache.put(
|
||||
hash_secret(token),
|
||||
principal=refreshed.principal,
|
||||
revision=after,
|
||||
max_entries=settings.auth_principal_cache_max_entries,
|
||||
)
|
||||
return refreshed
|
||||
|
||||
|
||||
def _refresh_principal_context(
|
||||
session: Session,
|
||||
*,
|
||||
context: ResolvedPrincipalContext,
|
||||
idm_directory: IdmDirectory | None,
|
||||
identity_directory: IdentityDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> ResolvedPrincipalContext:
|
||||
idm_assignments, idm_roles = _principal_idm_context(
|
||||
session,
|
||||
user=context.user,
|
||||
account=context.account,
|
||||
tenant_id=context.tenant.id,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
auth_session=context.auth_session,
|
||||
)
|
||||
include_system = context.auth_session is not None
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
context.user,
|
||||
account=context.account,
|
||||
include_system=include_system,
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
scopes = authorization_context.scopes
|
||||
auth_method = "session"
|
||||
if context.api_key is not None:
|
||||
scopes = intersect_api_key_scopes(scopes, context.api_key.scopes or [])
|
||||
auth_method = "api_key"
|
||||
principal = _build_principal_ref(
|
||||
session,
|
||||
account=context.account,
|
||||
user=context.user,
|
||||
tenant_id=context.tenant.id,
|
||||
scopes=scopes,
|
||||
auth_method=auth_method,
|
||||
api_key=context.api_key,
|
||||
auth_session=context.auth_session,
|
||||
idm_assignments=idm_assignments,
|
||||
identity_directory=identity_directory,
|
||||
extra_roles=idm_roles,
|
||||
authorization_context=authorization_context,
|
||||
include_system_roles=include_system,
|
||||
)
|
||||
return replace(context, principal=principal)
|
||||
|
||||
|
||||
def _resolve_api_key_principal_ref(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -245,11 +505,19 @@ def _resolve_api_key_principal_context(
|
||||
) -> ResolvedPrincipalContext | None:
|
||||
# API keys remain supported for CLI/automation. Their permissions are the
|
||||
# intersection of the key grant and the owner's current tenant roles.
|
||||
api_key = authenticate_api_key(session, token)
|
||||
api_key = authenticate_api_key(
|
||||
session,
|
||||
token,
|
||||
touch_interval_seconds=settings.auth_activity_touch_interval_seconds,
|
||||
)
|
||||
if api_key is None:
|
||||
return None
|
||||
user = session.get(User, api_key.user_id)
|
||||
account = session.get(Account, user.account_id) if user else None
|
||||
activity_touch_pending = session.is_modified(
|
||||
api_key,
|
||||
include_collections=False,
|
||||
)
|
||||
user = api_key.user
|
||||
account = user.account if user else None
|
||||
tenant = session.get(Tenant, api_key.tenant_id)
|
||||
if (
|
||||
not user or not account or not tenant
|
||||
@@ -257,6 +525,18 @@ def _resolve_api_key_principal_context(
|
||||
or user.tenant_id != api_key.tenant_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal")
|
||||
if (
|
||||
account.auth_provider == "service_account"
|
||||
or user.auth_provider == "service_account"
|
||||
):
|
||||
return _resolve_service_account_credential_context(
|
||||
session,
|
||||
api_key=api_key,
|
||||
account=account,
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
activity_touch_pending=activity_touch_pending,
|
||||
)
|
||||
idm_assignments, idm_roles = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
@@ -264,6 +544,7 @@ def _resolve_api_key_principal_context(
|
||||
tenant_id=api_key.tenant_id,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
auth_session=None,
|
||||
)
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
@@ -273,7 +554,6 @@ def _resolve_api_key_principal_context(
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
effective_scopes = intersect_api_key_scopes(authorization_context.scopes, api_key.scopes or [])
|
||||
session.commit()
|
||||
principal = _build_principal_ref(
|
||||
session,
|
||||
api_key=api_key,
|
||||
@@ -287,9 +567,66 @@ def _resolve_api_key_principal_context(
|
||||
extra_roles=idm_roles,
|
||||
authorization_context=authorization_context,
|
||||
)
|
||||
if activity_touch_pending:
|
||||
session.commit()
|
||||
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key)
|
||||
|
||||
|
||||
def _resolve_service_account_credential_context(
|
||||
session: Session,
|
||||
*,
|
||||
api_key: ApiKey,
|
||||
account: Account,
|
||||
user: User,
|
||||
tenant: Tenant,
|
||||
activity_touch_pending: bool,
|
||||
) -> ResolvedPrincipalContext:
|
||||
item = (
|
||||
session.query(ServiceAccount)
|
||||
.filter(
|
||||
ServiceAccount.tenant_id == tenant.id,
|
||||
ServiceAccount.account_id == account.id,
|
||||
ServiceAccount.membership_id == user.id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if (
|
||||
item is None
|
||||
or account.auth_provider != "service_account"
|
||||
or user.auth_provider != "service_account"
|
||||
or not item.is_active
|
||||
or item.retired_at is not None
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Inactive or inconsistent service-account credential",
|
||||
)
|
||||
effective_scopes = intersect_api_key_scopes(
|
||||
item.scope_ceiling,
|
||||
api_key.scopes or [],
|
||||
)
|
||||
principal = PrincipalRef(
|
||||
account_id=account.id,
|
||||
membership_id=user.id,
|
||||
tenant_id=tenant.id,
|
||||
scopes=frozenset(effective_scopes),
|
||||
auth_method="service_account",
|
||||
api_key_id=api_key.id,
|
||||
service_account_id=item.id,
|
||||
email=None,
|
||||
display_name=item.name,
|
||||
)
|
||||
if activity_touch_pending:
|
||||
session.commit()
|
||||
return ResolvedPrincipalContext(
|
||||
principal=principal,
|
||||
account=account,
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_session_principal_ref(
|
||||
request: Request,
|
||||
session: Session,
|
||||
@@ -322,11 +659,19 @@ def _resolve_session_principal_context(
|
||||
identity_directory: IdentityDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> ResolvedPrincipalContext | None:
|
||||
auth_session = authenticate_session_token(session, token)
|
||||
auth_session = authenticate_session_token(
|
||||
session,
|
||||
token,
|
||||
touch_interval_seconds=settings.auth_activity_touch_interval_seconds,
|
||||
)
|
||||
if auth_session is None:
|
||||
return None
|
||||
user = session.get(User, auth_session.user_id)
|
||||
account = session.get(Account, auth_session.account_id)
|
||||
activity_touch_pending = session.is_modified(
|
||||
auth_session,
|
||||
include_collections=False,
|
||||
)
|
||||
user = auth_session.user
|
||||
account = auth_session.account
|
||||
tenant = session.get(Tenant, auth_session.tenant_id)
|
||||
if (
|
||||
not user or not account or not tenant
|
||||
@@ -344,6 +689,7 @@ def _resolve_session_principal_context(
|
||||
tenant_id=user.tenant_id,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
auth_session=auth_session,
|
||||
)
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
@@ -353,7 +699,6 @@ def _resolve_session_principal_context(
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
scopes = authorization_context.scopes
|
||||
session.commit()
|
||||
principal = _build_principal_ref(
|
||||
session,
|
||||
auth_session=auth_session,
|
||||
@@ -368,6 +713,8 @@ def _resolve_session_principal_context(
|
||||
authorization_context=authorization_context,
|
||||
include_system_roles=True,
|
||||
)
|
||||
if activity_touch_pending:
|
||||
session.commit()
|
||||
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, auth_session=auth_session)
|
||||
|
||||
|
||||
@@ -388,8 +735,26 @@ def _principal_idm_context(
|
||||
tenant_id: str,
|
||||
idm_directory: IdmDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
auth_session: AuthSession | None = None,
|
||||
) -> tuple[tuple[OrganizationFunctionAssignmentRef, ...], tuple[Role, ...]]:
|
||||
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=tenant_id)
|
||||
available = _idm_assignments_for_account(
|
||||
idm_directory,
|
||||
account.id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
selected_assignment_id = (
|
||||
auth_session.acting_assignment_id if auth_session is not None else None
|
||||
)
|
||||
idm_assignments = tuple(
|
||||
item
|
||||
for item in available
|
||||
if item.source != "acting_for"
|
||||
or (
|
||||
selected_assignment_id == item.id
|
||||
and auth_session is not None
|
||||
and auth_session.acting_for_account_id == item.acting_for_account_id
|
||||
)
|
||||
)
|
||||
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory))
|
||||
return idm_assignments, idm_roles
|
||||
|
||||
@@ -558,6 +923,369 @@ class AccessApiPrincipalProvider:
|
||||
)
|
||||
|
||||
|
||||
class AccessAutomationPrincipalProvider:
|
||||
"""Rebuild a trigger owner against current tenant authorization."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> None:
|
||||
self._idm_directory = idm_directory
|
||||
self._identity_directory = identity_directory
|
||||
self._organization_directory = organization_directory
|
||||
|
||||
def resolve_automation_principal(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: AutomationPrincipalRequest,
|
||||
) -> AutomationPrincipalResolution:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError(
|
||||
"Access automation principal resolution requires a "
|
||||
"SQLAlchemy session"
|
||||
)
|
||||
if request.subject_kind == "service_account":
|
||||
return _resolve_service_account_automation(
|
||||
session,
|
||||
request=request,
|
||||
)
|
||||
return _resolve_delegated_user_automation(
|
||||
session,
|
||||
request=request,
|
||||
idm_directory=self._idm_directory,
|
||||
identity_directory=self._identity_directory,
|
||||
organization_directory=self._organization_directory,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_delegated_user_automation(
|
||||
session: Session,
|
||||
*,
|
||||
request: AutomationPrincipalRequest,
|
||||
idm_directory: IdmDirectory | None,
|
||||
identity_directory: IdentityDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> AutomationPrincipalResolution:
|
||||
account = session.get(Account, request.account_id)
|
||||
user = session.get(User, request.membership_id)
|
||||
tenant = session.get(Tenant, request.tenant_id)
|
||||
if (
|
||||
account is None
|
||||
or user is None
|
||||
or tenant is None
|
||||
or not account.is_active
|
||||
or not user.is_active
|
||||
or not tenant.is_active
|
||||
or user.account_id != account.id
|
||||
or user.tenant_id != tenant.id
|
||||
):
|
||||
return _automation_denied(
|
||||
request,
|
||||
status="inactive_or_inconsistent",
|
||||
reason=(
|
||||
"The automation owner is inactive, missing, or no longer "
|
||||
"belongs to the tenant."
|
||||
),
|
||||
)
|
||||
idm_assignments, idm_roles = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=request.tenant_id,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
auth_session=None,
|
||||
)
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
user,
|
||||
account=account,
|
||||
include_system=False,
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
granted_scopes, missing_scopes = _current_trigger_grants(
|
||||
request.grant_scopes,
|
||||
current_scopes=authorization_context.scopes,
|
||||
)
|
||||
if missing_scopes:
|
||||
return _automation_denied(
|
||||
request,
|
||||
status="authorization_reduced",
|
||||
reason=(
|
||||
"The automation owner no longer has every scope granted "
|
||||
"to this trigger."
|
||||
),
|
||||
granted_scopes=granted_scopes,
|
||||
missing_scopes=missing_scopes,
|
||||
)
|
||||
principal_ref = _build_principal_ref(
|
||||
session,
|
||||
account=account,
|
||||
user=user,
|
||||
tenant_id=request.tenant_id,
|
||||
scopes=list(granted_scopes),
|
||||
auth_method="service_account",
|
||||
idm_assignments=idm_assignments,
|
||||
identity_directory=identity_directory,
|
||||
extra_roles=idm_roles,
|
||||
authorization_context=authorization_context,
|
||||
include_system_roles=False,
|
||||
)
|
||||
principal_ref = replace(
|
||||
principal_ref,
|
||||
service_account_id=None,
|
||||
acting_for_account_id=account.id,
|
||||
)
|
||||
return _automation_allowed(
|
||||
session,
|
||||
request=request,
|
||||
principal_ref=principal_ref,
|
||||
granted_scopes=granted_scopes,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_service_account_automation(
|
||||
session: Session,
|
||||
*,
|
||||
request: AutomationPrincipalRequest,
|
||||
) -> AutomationPrincipalResolution:
|
||||
item = session.get(ServiceAccount, request.service_account_id)
|
||||
tenant = session.get(Tenant, request.tenant_id)
|
||||
account = (
|
||||
session.get(Account, item.account_id)
|
||||
if item is not None
|
||||
else None
|
||||
)
|
||||
user = (
|
||||
session.get(User, item.membership_id)
|
||||
if item is not None
|
||||
else None
|
||||
)
|
||||
if (
|
||||
item is None
|
||||
or tenant is None
|
||||
or account is None
|
||||
or user is None
|
||||
or item.tenant_id != request.tenant_id
|
||||
or not item.is_active
|
||||
or not tenant.is_active
|
||||
or not account.is_active
|
||||
or not user.is_active
|
||||
or item.account_id != account.id
|
||||
or item.membership_id != user.id
|
||||
or user.account_id != account.id
|
||||
or user.tenant_id != tenant.id
|
||||
or account.auth_provider != "service_account"
|
||||
or user.auth_provider != "service_account"
|
||||
):
|
||||
return _automation_denied(
|
||||
request,
|
||||
status="inactive_or_inconsistent",
|
||||
reason=(
|
||||
"The service account is inactive, missing, or no longer "
|
||||
"belongs to the tenant."
|
||||
),
|
||||
)
|
||||
granted_scopes, missing_scopes = _current_trigger_grants(
|
||||
request.grant_scopes,
|
||||
current_scopes=item.scope_ceiling,
|
||||
)
|
||||
if missing_scopes:
|
||||
return _automation_denied(
|
||||
request,
|
||||
status="authorization_reduced",
|
||||
reason=(
|
||||
"The service account no longer has every scope granted "
|
||||
"to this trigger."
|
||||
),
|
||||
granted_scopes=granted_scopes,
|
||||
missing_scopes=missing_scopes,
|
||||
)
|
||||
principal_ref = PrincipalRef(
|
||||
account_id=account.id,
|
||||
membership_id=user.id,
|
||||
tenant_id=tenant.id,
|
||||
scopes=frozenset(granted_scopes),
|
||||
auth_method="service_account",
|
||||
service_account_id=item.id,
|
||||
email=None,
|
||||
display_name=item.name,
|
||||
)
|
||||
return _automation_allowed(
|
||||
session,
|
||||
request=request,
|
||||
principal_ref=principal_ref,
|
||||
granted_scopes=granted_scopes,
|
||||
)
|
||||
|
||||
|
||||
def _current_trigger_grants(
|
||||
trigger_scopes: tuple[str, ...],
|
||||
*,
|
||||
current_scopes: list[str] | tuple[str, ...],
|
||||
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
granted = tuple(
|
||||
sorted(
|
||||
{
|
||||
required
|
||||
for required in trigger_scopes
|
||||
if scopes_grant_compatible(
|
||||
current_scopes,
|
||||
required,
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
missing = tuple(
|
||||
sorted(set(trigger_scopes) - set(granted))
|
||||
)
|
||||
return granted, missing
|
||||
|
||||
|
||||
def _automation_allowed(
|
||||
session: Session,
|
||||
*,
|
||||
request: AutomationPrincipalRequest,
|
||||
principal_ref: PrincipalRef,
|
||||
granted_scopes: tuple[str, ...],
|
||||
) -> AutomationPrincipalResolution:
|
||||
api_principal = _api_principal_from_ref(
|
||||
session,
|
||||
principal_ref,
|
||||
permission_evaluator=LegacyPermissionEvaluator(),
|
||||
)
|
||||
provenance = _automation_provenance(
|
||||
request,
|
||||
status="current_authorization_resolved",
|
||||
current_principal={
|
||||
"kind": request.subject_kind,
|
||||
"account_id": principal_ref.account_id,
|
||||
"membership_id": principal_ref.membership_id,
|
||||
"service_account_id": principal_ref.service_account_id,
|
||||
"role_ids": sorted(principal_ref.role_ids),
|
||||
"group_ids": sorted(principal_ref.group_ids),
|
||||
"function_assignment_ids": sorted(
|
||||
principal_ref.function_assignment_ids
|
||||
),
|
||||
"delegation_ids": sorted(
|
||||
principal_ref.delegation_ids
|
||||
),
|
||||
"granted_scopes": list(granted_scopes),
|
||||
},
|
||||
)
|
||||
return AutomationPrincipalResolution(
|
||||
allowed=True,
|
||||
principal=api_principal,
|
||||
granted_scopes=granted_scopes,
|
||||
provenance=provenance,
|
||||
)
|
||||
|
||||
|
||||
def _automation_denied(
|
||||
request: AutomationPrincipalRequest,
|
||||
*,
|
||||
status: str,
|
||||
reason: str,
|
||||
granted_scopes: tuple[str, ...] = (),
|
||||
missing_scopes: tuple[str, ...] | None = None,
|
||||
) -> AutomationPrincipalResolution:
|
||||
missing = (
|
||||
tuple(sorted(set(request.grant_scopes)))
|
||||
if missing_scopes is None
|
||||
else missing_scopes
|
||||
)
|
||||
return AutomationPrincipalResolution(
|
||||
allowed=False,
|
||||
reason=reason,
|
||||
granted_scopes=granted_scopes,
|
||||
missing_scopes=missing,
|
||||
provenance=_automation_provenance(
|
||||
request,
|
||||
status=status,
|
||||
current_principal=None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _automation_provenance(
|
||||
request: AutomationPrincipalRequest,
|
||||
*,
|
||||
status: str,
|
||||
current_principal: Mapping[str, object] | None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"contract_version": request.contract_version,
|
||||
"authorization_artifact": {
|
||||
"ref": request.authorization_ref,
|
||||
},
|
||||
"trigger_owner": _automation_trigger_owner(request),
|
||||
"current_automation_principal": (
|
||||
dict(current_principal)
|
||||
if current_principal is not None
|
||||
else None
|
||||
),
|
||||
"event_actor": _automation_context_actor(
|
||||
request.context.get("event_actor")
|
||||
),
|
||||
"operator_override": _automation_context_actor(
|
||||
request.context.get("operator_override")
|
||||
),
|
||||
"trigger_ref": _optional_context_text(
|
||||
request.context.get("trigger_ref")
|
||||
),
|
||||
"delivery_ref": _optional_context_text(
|
||||
request.context.get("delivery_ref")
|
||||
),
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def _automation_trigger_owner(
|
||||
request: AutomationPrincipalRequest,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"kind": request.subject_kind,
|
||||
"tenant_id": request.tenant_id,
|
||||
"account_id": request.account_id,
|
||||
"membership_id": request.membership_id,
|
||||
"service_account_id": request.service_account_id,
|
||||
}
|
||||
|
||||
|
||||
def _automation_context_actor(
|
||||
value: object,
|
||||
) -> dict[str, str] | None:
|
||||
if not isinstance(value, Mapping):
|
||||
return None
|
||||
result = {
|
||||
key: str(value[key]).strip()
|
||||
for key in (
|
||||
"kind",
|
||||
"type",
|
||||
"id",
|
||||
"label",
|
||||
"account_id",
|
||||
"membership_id",
|
||||
"service_account_id",
|
||||
"reason",
|
||||
)
|
||||
if value.get(key) is not None
|
||||
and str(value[key]).strip()
|
||||
}
|
||||
return result or None
|
||||
|
||||
|
||||
def _optional_context_text(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
clean = str(value).strip()
|
||||
return clean[:300] or None
|
||||
|
||||
|
||||
def get_api_principal(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from threading import Lock
|
||||
from time import monotonic
|
||||
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.principal_cache import AuthPrincipalRevision
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CachedPrincipal:
|
||||
principal: PrincipalRef
|
||||
revision: AuthPrincipalRevision
|
||||
stored_at: float
|
||||
|
||||
|
||||
class PrincipalSummaryCache:
|
||||
"""A bounded process-local cache containing no ORM or secret objects."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._entries: OrderedDict[str, CachedPrincipal] = OrderedDict()
|
||||
self._lock = Lock()
|
||||
|
||||
def get(
|
||||
self,
|
||||
token_digest: str,
|
||||
*,
|
||||
session_ttl_seconds: int,
|
||||
api_key_ttl_seconds: int,
|
||||
) -> CachedPrincipal | None:
|
||||
with self._lock:
|
||||
entry = self._entries.get(token_digest)
|
||||
if entry is None:
|
||||
return None
|
||||
ttl = (
|
||||
api_key_ttl_seconds
|
||||
if entry.principal.auth_method == "api_key"
|
||||
else session_ttl_seconds
|
||||
)
|
||||
if ttl <= 0 or monotonic() - entry.stored_at > ttl:
|
||||
self._entries.pop(token_digest, None)
|
||||
return None
|
||||
self._entries.move_to_end(token_digest)
|
||||
return entry
|
||||
|
||||
def put(
|
||||
self,
|
||||
token_digest: str,
|
||||
*,
|
||||
principal: PrincipalRef,
|
||||
revision: AuthPrincipalRevision,
|
||||
max_entries: int,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._entries[token_digest] = CachedPrincipal(
|
||||
principal=principal,
|
||||
revision=revision,
|
||||
stored_at=monotonic(),
|
||||
)
|
||||
self._entries.move_to_end(token_digest)
|
||||
while len(self._entries) > max(1, max_entries):
|
||||
self._entries.popitem(last=False)
|
||||
|
||||
def discard(self, token_digest: str) -> None:
|
||||
with self._lock:
|
||||
self._entries.pop(token_digest, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._entries.clear()
|
||||
|
||||
|
||||
principal_summary_cache = PrincipalSummaryCache()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CachedPrincipal",
|
||||
"PrincipalSummaryCache",
|
||||
"principal_summary_cache",
|
||||
]
|
||||
@@ -4,7 +4,18 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, JSON, text
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase, TimestampMixin
|
||||
@@ -110,6 +121,91 @@ class User(AccessBase, TimestampMixin):
|
||||
auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ServiceAccount(AccessBase, TimestampMixin):
|
||||
"""Managed non-login principal for current-authority automation."""
|
||||
|
||||
__tablename__ = "access_service_accounts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"normalized_name",
|
||||
name="uq_access_service_accounts_tenant_name",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"account_id",
|
||||
name="uq_access_service_accounts_account",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"membership_id",
|
||||
name="uq_access_service_accounts_membership",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
default=new_uuid,
|
||||
)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
account_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("access_accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
membership_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
normalized_name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
scope_ceiling: Mapped[list[str]] = mapped_column(
|
||||
JSON,
|
||||
default=list,
|
||||
nullable=False,
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
revision: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=1,
|
||||
nullable=False,
|
||||
)
|
||||
created_by_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_accounts.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
updated_by_account_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_accounts.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
retired_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class Group(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_groups"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_groups_tenant_slug"),)
|
||||
@@ -331,6 +427,8 @@ class AuthSession(AccessBase, TimestampMixin):
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
acting_assignment_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
acting_for_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
|
||||
csrf_token_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
@@ -358,6 +456,7 @@ __all__ = [
|
||||
"IdentityAccountLink",
|
||||
"OrganizationUnit",
|
||||
"Role",
|
||||
"ServiceAccount",
|
||||
"SystemRoleAssignment",
|
||||
"Tenant",
|
||||
"User",
|
||||
|
||||
@@ -9,12 +9,14 @@ 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_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,
|
||||
@@ -26,6 +28,7 @@ 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 (
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
@@ -41,6 +44,8 @@ from govoplan_core.core.modules import (
|
||||
RoleTemplate,
|
||||
)
|
||||
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:
|
||||
@@ -71,6 +76,8 @@ 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"),
|
||||
@@ -89,8 +96,13 @@ 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:governance:read", "View governance", "Inspect managed role and group templates.", "Access", "system"),
|
||||
@@ -125,6 +137,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",
|
||||
),
|
||||
@@ -183,8 +197,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",
|
||||
),
|
||||
@@ -233,6 +251,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",
|
||||
@@ -244,9 +263,46 @@ ADMIN_READ_SCOPES = (
|
||||
"access:account:read",
|
||||
"access:governance:read",
|
||||
"access:function:read",
|
||||
"views:definition:read",
|
||||
"views:assignment:read",
|
||||
"views:system_definition:read",
|
||||
"views:system_assignment:read",
|
||||
)
|
||||
|
||||
ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
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",
|
||||
@@ -278,6 +334,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.",
|
||||
@@ -336,10 +398,22 @@ 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",
|
||||
"access.admin.api-keys",
|
||||
"access.admin.service-accounts",
|
||||
"access.credentials",
|
||||
],
|
||||
"route": "/admin",
|
||||
"screen": "Admin",
|
||||
"section": "Users, groups, roles, and API keys",
|
||||
@@ -406,6 +480,49 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
],
|
||||
},
|
||||
),
|
||||
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=32,
|
||||
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"),
|
||||
),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": ["access.admin.service-accounts"],
|
||||
"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.",
|
||||
],
|
||||
"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.external-function-role-mappings",
|
||||
title="Organization function facts and access roles",
|
||||
@@ -420,7 +537,7 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
order=32,
|
||||
order=33,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access", "organizations"),
|
||||
@@ -436,6 +553,10 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
),
|
||||
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",
|
||||
@@ -479,6 +600,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
|
||||
@@ -506,6 +639,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
|
||||
@@ -562,6 +704,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
|
||||
@@ -592,10 +741,14 @@ 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
|
||||
|
||||
|
||||
@@ -608,10 +761,18 @@ def _people_search(context: ModuleContext) -> object:
|
||||
manifest = ModuleManifest(
|
||||
id="access",
|
||||
name="Access",
|
||||
version="0.1.11",
|
||||
version="0.1.15",
|
||||
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",
|
||||
),
|
||||
),
|
||||
permissions=ACCESS_PERMISSIONS,
|
||||
role_templates=ACCESS_ROLE_TEMPLATES,
|
||||
@@ -629,6 +790,7 @@ manifest = ModuleManifest(
|
||||
access_models.User,
|
||||
access_models.Group,
|
||||
access_models.Role,
|
||||
access_models.ServiceAccount,
|
||||
access_models.OrganizationUnit,
|
||||
access_models.Function,
|
||||
access_models.FunctionRoleAssignment,
|
||||
@@ -650,9 +812,27 @@ 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),
|
||||
),
|
||||
),
|
||||
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,
|
||||
@@ -662,12 +842,26 @@ 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_PEOPLE_SEARCH: _people_search,
|
||||
CAPABILITY_ACCESS_REFERENCE_OPTIONS: _access_reference_options,
|
||||
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||
},
|
||||
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,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
|
||||
@@ -60,17 +60,36 @@ 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):
|
||||
candidate.last_used_at = now
|
||||
session.add(candidate)
|
||||
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
|
||||
return None
|
||||
|
||||
@@ -78,4 +97,3 @@ def authenticate_api_key(session: Session, secret: str) -> ApiKey | None:
|
||||
def has_scope(api_key: ApiKey, required_scope: str) -> bool:
|
||||
scopes = set(api_key.scopes or [])
|
||||
return "*" in scopes or required_scope in scopes
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
|
||||
from govoplan_access.backend.db.models import (
|
||||
@@ -104,17 +104,39 @@ 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
|
||||
model.last_seen_at = now
|
||||
session.add(model)
|
||||
last_seen_at = ensure_aware_utc(model.last_seen_at)
|
||||
if (
|
||||
touch_interval_seconds <= 0
|
||||
or last_seen_at is None
|
||||
or now - last_seen_at >= timedelta(seconds=touch_interval_seconds)
|
||||
):
|
||||
model.last_seen_at = now
|
||||
session.add(model)
|
||||
return model
|
||||
|
||||
|
||||
@@ -143,6 +165,8 @@ def switch_auth_session_tenant(session: Session, auth_session: AuthSession, tena
|
||||
raise LookupError("The account does not have an active membership in this tenant.")
|
||||
auth_session.tenant_id = membership.tenant_id
|
||||
auth_session.user_id = membership.id
|
||||
auth_session.acting_assignment_id = None
|
||||
auth_session.acting_for_account_id = None
|
||||
auth_session.last_seen_at = utc_now()
|
||||
session.add(auth_session)
|
||||
session.flush()
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.base import utcnow
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
ServiceAccount,
|
||||
Tenant,
|
||||
User,
|
||||
new_uuid,
|
||||
)
|
||||
from govoplan_access.backend.permissions.catalog import scopes_grant
|
||||
from govoplan_access.backend.security.api_keys import (
|
||||
CreatedApiKey,
|
||||
create_api_key,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
|
||||
|
||||
class ServiceAccountError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceAccountNotFoundError(ServiceAccountError):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceAccountConflictError(ServiceAccountError):
|
||||
pass
|
||||
|
||||
|
||||
class ServiceAccountCredentialNotFoundError(ServiceAccountError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ServiceAccountCredentialSummary:
|
||||
credential_count: int = 0
|
||||
active_credential_count: int = 0
|
||||
last_credential_used_at: datetime | None = None
|
||||
|
||||
|
||||
def list_service_accounts(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> list[ServiceAccount]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(ServiceAccount)
|
||||
.where(ServiceAccount.tenant_id == tenant_id)
|
||||
.order_by(
|
||||
ServiceAccount.normalized_name,
|
||||
ServiceAccount.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def service_account_credential_summaries(
|
||||
session: Session,
|
||||
*,
|
||||
service_accounts: Iterable[ServiceAccount],
|
||||
) -> dict[str, ServiceAccountCredentialSummary]:
|
||||
items = tuple(service_accounts)
|
||||
by_membership = {item.membership_id: item.id for item in items}
|
||||
usable_accounts = {
|
||||
item.id
|
||||
for item in items
|
||||
if item.is_active and item.retired_at is None
|
||||
}
|
||||
summaries = {
|
||||
item.id: ServiceAccountCredentialSummary()
|
||||
for item in items
|
||||
}
|
||||
if not by_membership:
|
||||
return summaries
|
||||
now = utc_now()
|
||||
totals: dict[str, int] = {}
|
||||
active: dict[str, int] = {}
|
||||
last_used: dict[str, datetime | None] = {}
|
||||
credentials = session.scalars(
|
||||
select(ApiKey).where(ApiKey.user_id.in_(by_membership))
|
||||
)
|
||||
for credential in credentials:
|
||||
service_account_id = by_membership[credential.user_id]
|
||||
totals[service_account_id] = totals.get(service_account_id, 0) + 1
|
||||
expires_at = ensure_aware_utc(credential.expires_at)
|
||||
if (
|
||||
service_account_id in usable_accounts
|
||||
and
|
||||
credential.revoked_at is None
|
||||
and (expires_at is None or expires_at > now)
|
||||
):
|
||||
active[service_account_id] = (
|
||||
active.get(service_account_id, 0) + 1
|
||||
)
|
||||
used_at = ensure_aware_utc(credential.last_used_at)
|
||||
if used_at is not None and (
|
||||
last_used.get(service_account_id) is None
|
||||
or used_at > last_used[service_account_id]
|
||||
):
|
||||
last_used[service_account_id] = used_at
|
||||
return {
|
||||
item.id: ServiceAccountCredentialSummary(
|
||||
credential_count=totals.get(item.id, 0),
|
||||
active_credential_count=active.get(item.id, 0),
|
||||
last_credential_used_at=last_used.get(item.id),
|
||||
)
|
||||
for item in items
|
||||
}
|
||||
|
||||
|
||||
def list_service_account_credentials(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
include_revoked: bool = True,
|
||||
) -> tuple[ServiceAccount, list[ApiKey]]:
|
||||
item = get_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
)
|
||||
query = select(ApiKey).where(
|
||||
ApiKey.tenant_id == tenant_id,
|
||||
ApiKey.user_id == item.membership_id,
|
||||
)
|
||||
if not include_revoked:
|
||||
query = query.where(ApiKey.revoked_at.is_(None))
|
||||
credentials = list(
|
||||
session.scalars(
|
||||
query.order_by(ApiKey.created_at.desc(), ApiKey.id)
|
||||
)
|
||||
)
|
||||
return item, credentials
|
||||
|
||||
|
||||
def create_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
name: str,
|
||||
scopes: Iterable[str],
|
||||
expires_at: datetime | None,
|
||||
) -> tuple[ServiceAccount, CreatedApiKey]:
|
||||
item = _locked_service_account_for_credential_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
user = _active_service_account_membership(session, item)
|
||||
credential_scopes = _service_account_credential_scopes(
|
||||
principal,
|
||||
item,
|
||||
scopes,
|
||||
)
|
||||
created = create_api_key(
|
||||
session,
|
||||
user=user,
|
||||
name=_credential_name(name),
|
||||
scopes=list(credential_scopes),
|
||||
expires_at=_future_expiry(expires_at),
|
||||
)
|
||||
_touch_service_account(item, principal)
|
||||
session.flush()
|
||||
return item, created
|
||||
|
||||
|
||||
def rotate_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
name: str | None,
|
||||
scopes: Iterable[str] | None,
|
||||
expires_at: datetime | None,
|
||||
) -> tuple[ServiceAccount, ApiKey, CreatedApiKey]:
|
||||
item = _locked_service_account_for_credential_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
user = _active_service_account_membership(session, item)
|
||||
previous = _locked_service_account_credential(
|
||||
session,
|
||||
item=item,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
if previous.revoked_at is not None:
|
||||
raise ServiceAccountConflictError(
|
||||
"The credential is already revoked; reload before rotating"
|
||||
)
|
||||
requested_scopes = previous.scopes if scopes is None else scopes
|
||||
credential_scopes = _service_account_credential_scopes(
|
||||
principal,
|
||||
item,
|
||||
requested_scopes,
|
||||
)
|
||||
created = create_api_key(
|
||||
session,
|
||||
user=user,
|
||||
name=_credential_name(name or previous.name),
|
||||
scopes=list(credential_scopes),
|
||||
expires_at=_future_expiry(expires_at),
|
||||
)
|
||||
previous.revoked_at = utc_now()
|
||||
_touch_service_account(item, principal)
|
||||
session.flush()
|
||||
return item, previous, created
|
||||
|
||||
|
||||
def revoke_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
credential_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
) -> tuple[ServiceAccount, ApiKey]:
|
||||
item = _locked_service_account_for_credential_change(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
credential = _locked_service_account_credential(
|
||||
session,
|
||||
item=item,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
if credential.revoked_at is None:
|
||||
credential.revoked_at = utc_now()
|
||||
_touch_service_account(item, principal)
|
||||
session.flush()
|
||||
return item, credential
|
||||
|
||||
|
||||
def get_service_account(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
lock: bool = False,
|
||||
) -> ServiceAccount:
|
||||
query = select(ServiceAccount).where(
|
||||
ServiceAccount.id == service_account_id,
|
||||
ServiceAccount.tenant_id == tenant_id,
|
||||
)
|
||||
if lock:
|
||||
query = query.with_for_update()
|
||||
item = session.scalar(query)
|
||||
if item is None:
|
||||
raise ServiceAccountNotFoundError(
|
||||
"Service account was not found"
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
def create_service_account(
|
||||
session: Session,
|
||||
*,
|
||||
tenant: Tenant,
|
||||
principal: ApiPrincipal,
|
||||
name: str,
|
||||
description: str | None,
|
||||
scope_ceiling: Iterable[str],
|
||||
) -> ServiceAccount:
|
||||
clean_name = _service_account_name(name)
|
||||
scopes = _service_account_scopes(
|
||||
principal,
|
||||
scope_ceiling,
|
||||
)
|
||||
service_account_id = new_uuid()
|
||||
internal_email = (
|
||||
f"service-account-{service_account_id}@govoplan.invalid"
|
||||
)
|
||||
account = Account(
|
||||
id=new_uuid(),
|
||||
email=internal_email,
|
||||
normalized_email=internal_email,
|
||||
display_name=clean_name,
|
||||
is_active=True,
|
||||
auth_provider="service_account",
|
||||
password_hash=None,
|
||||
password_reset_required=False,
|
||||
)
|
||||
membership = User(
|
||||
id=new_uuid(),
|
||||
tenant_id=tenant.id,
|
||||
account=account,
|
||||
email=internal_email,
|
||||
display_name=clean_name,
|
||||
is_active=True,
|
||||
is_tenant_admin=False,
|
||||
auth_provider="service_account",
|
||||
password_hash=None,
|
||||
settings={"managed_service_account": service_account_id},
|
||||
)
|
||||
item = ServiceAccount(
|
||||
id=service_account_id,
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
membership_id=membership.id,
|
||||
name=clean_name,
|
||||
normalized_name=_normalized_name(clean_name),
|
||||
description=_optional_text(description),
|
||||
scope_ceiling=list(scopes),
|
||||
is_active=True,
|
||||
revision=1,
|
||||
created_by_account_id=principal.account_id,
|
||||
updated_by_account_id=principal.account_id,
|
||||
settings={},
|
||||
)
|
||||
session.add_all((account, membership, item))
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError as exc:
|
||||
raise ServiceAccountConflictError(
|
||||
"A service account with this name already exists"
|
||||
) from exc
|
||||
return item
|
||||
|
||||
|
||||
def update_service_account(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
changes: Mapping[str, object],
|
||||
) -> ServiceAccount:
|
||||
item = get_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
lock=True,
|
||||
)
|
||||
if item.revision != expected_revision:
|
||||
raise ServiceAccountConflictError(
|
||||
"Service account changed on the server; reload before saving"
|
||||
)
|
||||
account = session.get(Account, item.account_id)
|
||||
membership = session.get(User, item.membership_id)
|
||||
if account is None or membership is None:
|
||||
raise ServiceAccountConflictError(
|
||||
"Service account backing identity is missing"
|
||||
)
|
||||
if "name" in changes:
|
||||
clean_name = _service_account_name(str(changes["name"]))
|
||||
item.name = clean_name
|
||||
item.normalized_name = _normalized_name(clean_name)
|
||||
account.display_name = clean_name
|
||||
membership.display_name = clean_name
|
||||
if "description" in changes:
|
||||
value = changes["description"]
|
||||
item.description = _optional_text(
|
||||
str(value) if value is not None else None
|
||||
)
|
||||
if "scope_ceiling" in changes:
|
||||
raw_scopes = changes["scope_ceiling"]
|
||||
if not isinstance(raw_scopes, Iterable) or isinstance(
|
||||
raw_scopes,
|
||||
(str, bytes),
|
||||
):
|
||||
raise ServiceAccountError(
|
||||
"Service account scope ceiling is invalid"
|
||||
)
|
||||
item.scope_ceiling = list(
|
||||
_service_account_scopes(
|
||||
principal,
|
||||
(str(scope) for scope in raw_scopes),
|
||||
)
|
||||
)
|
||||
if "is_active" in changes:
|
||||
active = bool(changes["is_active"])
|
||||
item.is_active = active
|
||||
account.is_active = active
|
||||
membership.is_active = active
|
||||
item.retired_at = None if active else utcnow()
|
||||
item.revision += 1
|
||||
item.updated_by_account_id = principal.account_id
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError as exc:
|
||||
raise ServiceAccountConflictError(
|
||||
"A service account with this name already exists"
|
||||
) from exc
|
||||
return item
|
||||
|
||||
|
||||
def retire_service_account(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_revision: int,
|
||||
) -> ServiceAccount:
|
||||
item = update_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
principal=principal,
|
||||
expected_revision=expected_revision,
|
||||
changes={"is_active": False},
|
||||
)
|
||||
now = utc_now()
|
||||
credentials = session.scalars(
|
||||
select(ApiKey).where(
|
||||
ApiKey.tenant_id == tenant_id,
|
||||
ApiKey.user_id == item.membership_id,
|
||||
ApiKey.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
for credential in credentials:
|
||||
credential.revoked_at = now
|
||||
session.flush()
|
||||
return item
|
||||
|
||||
|
||||
def _locked_service_account_for_credential_change(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
service_account_id: str,
|
||||
expected_revision: int,
|
||||
) -> ServiceAccount:
|
||||
item = get_service_account(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
service_account_id=service_account_id,
|
||||
lock=True,
|
||||
)
|
||||
if item.revision != expected_revision:
|
||||
raise ServiceAccountConflictError(
|
||||
"Service account changed on the server; reload before changing credentials"
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
def _locked_service_account_credential(
|
||||
session: Session,
|
||||
*,
|
||||
item: ServiceAccount,
|
||||
credential_id: str,
|
||||
) -> ApiKey:
|
||||
credential = session.scalar(
|
||||
select(ApiKey)
|
||||
.where(
|
||||
ApiKey.id == credential_id,
|
||||
ApiKey.tenant_id == item.tenant_id,
|
||||
ApiKey.user_id == item.membership_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if credential is None:
|
||||
raise ServiceAccountCredentialNotFoundError(
|
||||
"Service-account credential was not found"
|
||||
)
|
||||
return credential
|
||||
|
||||
|
||||
def _active_service_account_membership(
|
||||
session: Session,
|
||||
item: ServiceAccount,
|
||||
) -> User:
|
||||
user = session.get(User, item.membership_id)
|
||||
account = session.get(Account, item.account_id)
|
||||
if (
|
||||
not item.is_active
|
||||
or item.retired_at is not None
|
||||
or user is None
|
||||
or account is None
|
||||
or not user.is_active
|
||||
or not account.is_active
|
||||
):
|
||||
raise ServiceAccountConflictError(
|
||||
"Activate the service account before creating or rotating credentials"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def _service_account_credential_scopes(
|
||||
principal: ApiPrincipal,
|
||||
item: ServiceAccount,
|
||||
values: Iterable[str],
|
||||
) -> tuple[str, ...]:
|
||||
scopes = tuple(
|
||||
sorted(
|
||||
{
|
||||
str(value).strip()
|
||||
for value in values
|
||||
if str(value).strip()
|
||||
}
|
||||
)
|
||||
)
|
||||
if not scopes:
|
||||
raise ServiceAccountError(
|
||||
"A service-account credential requires at least one scope"
|
||||
)
|
||||
if len(scopes) > 200:
|
||||
raise ServiceAccountError(
|
||||
"Service-account credentials support at most 200 scopes"
|
||||
)
|
||||
denied_by_ceiling = tuple(
|
||||
scope
|
||||
for scope in scopes
|
||||
if not scopes_grant(item.scope_ceiling, scope)
|
||||
)
|
||||
if denied_by_ceiling:
|
||||
raise PermissionError(
|
||||
"Credential scopes exceed the service-account scope ceiling: "
|
||||
+ ", ".join(denied_by_ceiling)
|
||||
)
|
||||
denied_by_actor = tuple(
|
||||
scope for scope in scopes if not principal.has(scope)
|
||||
)
|
||||
if denied_by_actor:
|
||||
raise PermissionError(
|
||||
"Credential scopes exceed the current administrator authority: "
|
||||
+ ", ".join(denied_by_actor)
|
||||
)
|
||||
return scopes
|
||||
|
||||
|
||||
def _credential_name(value: str) -> str:
|
||||
clean = " ".join(value.split())
|
||||
if not 1 <= len(clean) <= 255:
|
||||
raise ServiceAccountError(
|
||||
"Credential name must contain between 1 and 255 characters"
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
def _future_expiry(value: datetime | None) -> datetime | None:
|
||||
expires_at = ensure_aware_utc(value)
|
||||
if expires_at is not None and expires_at <= utc_now():
|
||||
raise ServiceAccountError(
|
||||
"Credential expiry must be in the future"
|
||||
)
|
||||
return expires_at
|
||||
|
||||
|
||||
def _touch_service_account(
|
||||
item: ServiceAccount,
|
||||
principal: ApiPrincipal,
|
||||
) -> None:
|
||||
item.revision += 1
|
||||
item.updated_by_account_id = principal.account_id
|
||||
|
||||
|
||||
def _service_account_name(value: str) -> str:
|
||||
clean = " ".join(value.split())
|
||||
if not 1 <= len(clean) <= 255:
|
||||
raise ServiceAccountError(
|
||||
"Service account name must contain between 1 and 255 characters"
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
def _normalized_name(value: str) -> str:
|
||||
return value.casefold()
|
||||
|
||||
|
||||
def _optional_text(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
clean = value.strip()
|
||||
if len(clean) > 4000:
|
||||
raise ServiceAccountError(
|
||||
"Service account description is too long"
|
||||
)
|
||||
return clean or None
|
||||
|
||||
|
||||
def _service_account_scopes(
|
||||
principal: ApiPrincipal,
|
||||
values: Iterable[str],
|
||||
) -> tuple[str, ...]:
|
||||
scopes = tuple(
|
||||
sorted(
|
||||
{
|
||||
str(value).strip()
|
||||
for value in values
|
||||
if str(value).strip()
|
||||
}
|
||||
)
|
||||
)
|
||||
if len(scopes) > 200:
|
||||
raise ServiceAccountError(
|
||||
"Service accounts support at most 200 scope grants"
|
||||
)
|
||||
denied = tuple(
|
||||
scope for scope in scopes
|
||||
if not principal.has(scope)
|
||||
)
|
||||
if denied:
|
||||
raise PermissionError(
|
||||
"Cannot grant service-account scopes outside the current "
|
||||
f"administrator authority: {', '.join(denied)}"
|
||||
)
|
||||
return scopes
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ServiceAccountConflictError",
|
||||
"ServiceAccountCredentialNotFoundError",
|
||||
"ServiceAccountCredentialSummary",
|
||||
"ServiceAccountError",
|
||||
"ServiceAccountNotFoundError",
|
||||
"create_service_account",
|
||||
"create_service_account_credential",
|
||||
"get_service_account",
|
||||
"list_service_accounts",
|
||||
"list_service_account_credentials",
|
||||
"revoke_service_account_credential",
|
||||
"retire_service_account",
|
||||
"rotate_service_account_credential",
|
||||
"service_account_credential_summaries",
|
||||
"update_service_account",
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.administration import SqlAccessAdministration
|
||||
from govoplan_access.backend.api.v1.admin_common import (
|
||||
_accounts_by_user_id,
|
||||
_group_member_ids_by_group_id,
|
||||
@@ -13,7 +15,7 @@ from govoplan_access.backend.api.v1.admin_common import (
|
||||
_roles_by_user_id,
|
||||
_tenant_role_assignment_counts,
|
||||
)
|
||||
from govoplan_access.backend.db.models import Account, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
@@ -56,6 +58,92 @@ class AdminBatchHelperTests(unittest.TestCase):
|
||||
self.assertEqual([item.id for item in roles_by_user[user.id]], [role.id])
|
||||
self.assertEqual(role_counts, {role.id: (1, 1)})
|
||||
|
||||
def test_tenant_counts_many_uses_three_grouped_queries(self) -> None:
|
||||
accounts = [
|
||||
Account(
|
||||
id=f"account-{index}",
|
||||
email=f"user-{index}@example.test",
|
||||
normalized_email=f"user-{index}@example.test",
|
||||
)
|
||||
for index in range(3)
|
||||
]
|
||||
users = [
|
||||
User(
|
||||
id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id=accounts[0].id,
|
||||
email=accounts[0].email,
|
||||
),
|
||||
User(
|
||||
id="user-2",
|
||||
tenant_id="tenant-1",
|
||||
account_id=accounts[1].id,
|
||||
email=accounts[1].email,
|
||||
is_active=False,
|
||||
),
|
||||
User(
|
||||
id="user-3",
|
||||
tenant_id="tenant-2",
|
||||
account_id=accounts[2].id,
|
||||
email=accounts[2].email,
|
||||
),
|
||||
]
|
||||
self.session.add_all(
|
||||
[
|
||||
*accounts,
|
||||
*users,
|
||||
Group(id="group-1", tenant_id="tenant-1", slug="one", name="One"),
|
||||
Group(id="group-2", tenant_id="tenant-2", slug="two", name="Two"),
|
||||
ApiKey(
|
||||
id="key-1",
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
name="Active",
|
||||
prefix="active",
|
||||
key_hash="hash-1",
|
||||
),
|
||||
ApiKey(
|
||||
id="key-2",
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-2",
|
||||
name="Revoked",
|
||||
prefix="revoked",
|
||||
key_hash="hash-2",
|
||||
revoked_at=datetime.now(UTC),
|
||||
),
|
||||
]
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
query_count = 0
|
||||
|
||||
def count_query(*_args: object) -> None:
|
||||
nonlocal query_count
|
||||
query_count += 1
|
||||
|
||||
event.listen(self.engine, "before_cursor_execute", count_query)
|
||||
try:
|
||||
counts = SqlAccessAdministration().tenant_counts_many(
|
||||
self.session,
|
||||
["tenant-1", "tenant-2", "tenant-empty"],
|
||||
)
|
||||
finally:
|
||||
event.remove(self.engine, "before_cursor_execute", count_query)
|
||||
|
||||
self.assertEqual(3, query_count)
|
||||
self.assertEqual(
|
||||
{
|
||||
"users": 2,
|
||||
"active_users": 1,
|
||||
"groups": 1,
|
||||
"api_keys": 2,
|
||||
"active_api_keys": 1,
|
||||
},
|
||||
counts["tenant-1"],
|
||||
)
|
||||
self.assertEqual(1, counts["tenant-2"]["users"])
|
||||
self.assertEqual(0, counts["tenant-empty"]["users"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest import TestCase
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||
from govoplan_access.backend.security.sessions import authenticate_session_token
|
||||
|
||||
|
||||
class AuthenticationActivityTouchTests(TestCase):
|
||||
def test_session_activity_is_touched_only_after_the_interval(self) -> None:
|
||||
now = datetime(2026, 7, 29, 10, 0, tzinfo=timezone.utc)
|
||||
for age_seconds, should_touch in ((60, False), (301, True)):
|
||||
with self.subTest(age_seconds=age_seconds):
|
||||
model = SimpleNamespace(
|
||||
expires_at=now + timedelta(hours=1),
|
||||
last_seen_at=now - timedelta(seconds=age_seconds),
|
||||
)
|
||||
session = MagicMock()
|
||||
(
|
||||
session.query.return_value.options.return_value
|
||||
.filter.return_value.one_or_none
|
||||
).return_value = model
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_access.backend.security.sessions.hash_session_token",
|
||||
return_value="hashed",
|
||||
),
|
||||
patch(
|
||||
"govoplan_access.backend.security.sessions.utc_now",
|
||||
return_value=now,
|
||||
),
|
||||
):
|
||||
result = authenticate_session_token(
|
||||
session,
|
||||
"token",
|
||||
touch_interval_seconds=300,
|
||||
)
|
||||
|
||||
self.assertIs(model, result)
|
||||
if should_touch:
|
||||
self.assertEqual(now, model.last_seen_at)
|
||||
session.add.assert_called_once_with(model)
|
||||
else:
|
||||
self.assertEqual(
|
||||
now - timedelta(seconds=age_seconds),
|
||||
model.last_seen_at,
|
||||
)
|
||||
session.add.assert_not_called()
|
||||
|
||||
def test_api_key_activity_is_touched_only_after_the_interval(self) -> None:
|
||||
now = datetime(2026, 7, 29, 10, 0, tzinfo=timezone.utc)
|
||||
for age_seconds, should_touch in ((60, False), (301, True)):
|
||||
with self.subTest(age_seconds=age_seconds):
|
||||
model = SimpleNamespace(
|
||||
expires_at=None,
|
||||
last_used_at=now - timedelta(seconds=age_seconds),
|
||||
key_hash="hashed",
|
||||
)
|
||||
session = MagicMock()
|
||||
(
|
||||
session.query.return_value.options.return_value
|
||||
.filter.return_value.all
|
||||
).return_value = [model]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_access.backend.security.api_keys.verify_api_key",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"govoplan_access.backend.security.api_keys.utc_now",
|
||||
return_value=now,
|
||||
),
|
||||
):
|
||||
result = authenticate_api_key(
|
||||
session,
|
||||
"mm_test-token",
|
||||
touch_interval_seconds=300,
|
||||
)
|
||||
|
||||
self.assertIs(model, result)
|
||||
if should_touch:
|
||||
self.assertEqual(now, model.last_used_at)
|
||||
session.add.assert_called_once_with(model)
|
||||
else:
|
||||
self.assertEqual(
|
||||
now - timedelta(seconds=age_seconds),
|
||||
model.last_used_at,
|
||||
)
|
||||
session.add.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import unittest
|
||||
|
||||
unittest.main()
|
||||
@@ -1,13 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
from typing import Iterable
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from starlette.requests import Request
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import _extract_token, _requires_csrf, _resolve_legacy_principal_ref
|
||||
from govoplan_access.backend.auth.dependencies import (
|
||||
_extract_token,
|
||||
_principal_idm_context,
|
||||
_requires_csrf,
|
||||
_resolve_legacy_principal_context,
|
||||
_resolve_legacy_principal_ref,
|
||||
)
|
||||
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||
from govoplan_access.backend.auth.tokens import hash_secret
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import Account, AuthSession, Role, User, UserRoleAssignment
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||
|
||||
|
||||
def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ()) -> Request:
|
||||
@@ -22,6 +41,9 @@ def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ())
|
||||
|
||||
|
||||
class AuthDependencyTests(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
principal_summary_cache.clear()
|
||||
|
||||
def test_extract_token_prefers_explicit_api_key(self) -> None:
|
||||
request = request_for(headers=[("authorization", "Bearer session-token")])
|
||||
|
||||
@@ -44,6 +66,200 @@ class AuthDependencyTests(unittest.TestCase):
|
||||
self.assertEqual(raised.exception.status_code, 401)
|
||||
self.assertEqual(raised.exception.detail, "Missing API key or session token")
|
||||
|
||||
def test_permission_revision_invalidates_cached_principal(self) -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(engine)
|
||||
AccessBase.metadata.create_all(bind=engine)
|
||||
Base.metadata.create_all(
|
||||
bind=engine,
|
||||
tables=[
|
||||
ChangeSequenceEntry.__table__,
|
||||
ChangeSequenceRetentionFloor.__table__,
|
||||
],
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
try:
|
||||
with SessionLocal() as session:
|
||||
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||
account = Account(
|
||||
id="account-1",
|
||||
email="owner@example.test",
|
||||
normalized_email="owner@example.test",
|
||||
)
|
||||
user = User(
|
||||
id="user-1",
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
)
|
||||
role = Role(
|
||||
id="role-1",
|
||||
tenant_id=tenant.id,
|
||||
slug="reader",
|
||||
name="Reader",
|
||||
permissions=["files:file:read"],
|
||||
)
|
||||
assignment = UserRoleAssignment(
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
)
|
||||
token = "ms_test-session-token"
|
||||
auth_session = AuthSession(
|
||||
id="session-1",
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
token_hash=hash_secret(token),
|
||||
expires_at=utc_now() + timedelta(hours=1),
|
||||
)
|
||||
session.add_all(
|
||||
[tenant, account, user, role, assignment, auth_session]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
request = request_for()
|
||||
first = _resolve_legacy_principal_context(
|
||||
request,
|
||||
session,
|
||||
authorization=f"Bearer {token}",
|
||||
x_api_key=None,
|
||||
)
|
||||
self.assertIn("files:file:read", first.principal.scopes)
|
||||
|
||||
role.permissions = ["files:file:write"]
|
||||
session.add(role)
|
||||
invalidate_auth_principals(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
source_module="access",
|
||||
resource_type="role",
|
||||
resource_id=role.id,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
second = _resolve_legacy_principal_context(
|
||||
request,
|
||||
session,
|
||||
authorization=f"Bearer {token}",
|
||||
x_api_key=None,
|
||||
)
|
||||
self.assertNotIn("files:file:read", second.principal.scopes)
|
||||
self.assertIn("files:file:write", second.principal.scopes)
|
||||
finally:
|
||||
AccessBase.metadata.drop_all(bind=engine)
|
||||
scope_registry.metadata.drop_all(bind=engine)
|
||||
Base.metadata.drop_all(
|
||||
bind=engine,
|
||||
tables=[
|
||||
ChangeSequenceEntry.__table__,
|
||||
ChangeSequenceRetentionFloor.__table__,
|
||||
],
|
||||
)
|
||||
engine.dispose()
|
||||
|
||||
def test_acting_assignment_requires_exact_session_selection(self) -> None:
|
||||
class Directory:
|
||||
def organization_function_assignments_for_account(
|
||||
self,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at=None,
|
||||
):
|
||||
del account_id, effective_at
|
||||
return (
|
||||
OrganizationFunctionAssignmentRef(
|
||||
id="direct-1",
|
||||
tenant_id=str(tenant_id),
|
||||
identity_id="identity-1",
|
||||
account_id="account-1",
|
||||
function_id="function-direct",
|
||||
organization_unit_id="unit-1",
|
||||
),
|
||||
OrganizationFunctionAssignmentRef(
|
||||
id="acting-1",
|
||||
tenant_id=str(tenant_id),
|
||||
identity_id="identity-1",
|
||||
account_id="account-1",
|
||||
function_id="function-acting",
|
||||
organization_unit_id="unit-1",
|
||||
source="acting_for",
|
||||
acting_for_account_id="represented-1",
|
||||
),
|
||||
)
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(engine)
|
||||
AccessBase.metadata.create_all(bind=engine)
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
try:
|
||||
with SessionLocal() as session:
|
||||
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||
account = Account(
|
||||
id="account-1",
|
||||
email="actor@example.test",
|
||||
normalized_email="actor@example.test",
|
||||
)
|
||||
user = User(
|
||||
id="user-1",
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
)
|
||||
auth_session = AuthSession(
|
||||
id="session-1",
|
||||
tenant_id=tenant.id,
|
||||
user_id=user.id,
|
||||
account_id=account.id,
|
||||
token_hash="token-hash",
|
||||
expires_at=utc_now() + timedelta(hours=1),
|
||||
)
|
||||
session.add_all((tenant, account, user, auth_session))
|
||||
session.flush()
|
||||
|
||||
ordinary, _ = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=tenant.id,
|
||||
idm_directory=Directory(), # type: ignore[arg-type]
|
||||
organization_directory=None,
|
||||
)
|
||||
self.assertEqual([item.id for item in ordinary], ["direct-1"])
|
||||
|
||||
auth_session.acting_assignment_id = "acting-1"
|
||||
auth_session.acting_for_account_id = "represented-1"
|
||||
selected, _ = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=tenant.id,
|
||||
idm_directory=Directory(), # type: ignore[arg-type]
|
||||
organization_directory=None,
|
||||
auth_session=auth_session,
|
||||
)
|
||||
self.assertEqual(
|
||||
[item.id for item in selected],
|
||||
["direct-1", "acting-1"],
|
||||
)
|
||||
|
||||
auth_session.acting_for_account_id = "wrong-account"
|
||||
mismatched, _ = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=tenant.id,
|
||||
idm_directory=Directory(), # type: ignore[arg-type]
|
||||
organization_directory=None,
|
||||
auth_session=auth_session,
|
||||
)
|
||||
self.assertEqual([item.id for item in mismatched], ["direct-1"])
|
||||
finally:
|
||||
AccessBase.metadata.drop_all(bind=engine)
|
||||
scope_registry.metadata.drop_all(bind=engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import (
|
||||
AccessAutomationPrincipalProvider,
|
||||
)
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ServiceAccount,
|
||||
User,
|
||||
)
|
||||
from govoplan_access.backend.manifest import manifest
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
UserAuthorizationContext,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
)
|
||||
from govoplan_core.core.automation import AutomationPrincipalRequest
|
||||
from govoplan_core.tenancy.scope import (
|
||||
Tenant,
|
||||
create_scope_tables,
|
||||
scope_registry,
|
||||
)
|
||||
|
||||
|
||||
class AutomationPrincipalTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
AccessBase.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session = self.Session()
|
||||
self.account = Account(
|
||||
id="account-1",
|
||||
email="owner@example.test",
|
||||
normalized_email="owner@example.test",
|
||||
)
|
||||
self.tenant = Tenant(
|
||||
id="tenant-1",
|
||||
slug="tenant-1",
|
||||
name="Tenant 1",
|
||||
)
|
||||
self.user = User(
|
||||
id="user-1",
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=self.account.id,
|
||||
email=self.account.email,
|
||||
)
|
||||
self.session.add_all([self.tenant, self.account, self.user])
|
||||
self.session.commit()
|
||||
self.provider = AccessAutomationPrincipalProvider()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
AccessBase.metadata.drop_all(bind=self.engine)
|
||||
scope_registry.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def _request(self) -> AutomationPrincipalRequest:
|
||||
return AutomationPrincipalRequest(
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=self.account.id,
|
||||
membership_id=self.user.id,
|
||||
authorization_ref="dataflow-trigger:1",
|
||||
grant_scopes=(
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
),
|
||||
context={
|
||||
"trigger_ref": "dataflow-trigger:1",
|
||||
"delivery_ref": "dataflow-delivery:1",
|
||||
"event_actor": {
|
||||
"type": "user",
|
||||
"id": "event-user-1",
|
||||
},
|
||||
"operator_override": {
|
||||
"type": "user",
|
||||
"id": "operator-1",
|
||||
"reason": "approved replay",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def test_resolution_intersects_trigger_grant_with_current_scopes(self) -> None:
|
||||
context = UserAuthorizationContext(
|
||||
tenant_roles=[],
|
||||
system_roles=[],
|
||||
groups=[],
|
||||
function_assignment_ids=(),
|
||||
function_delegation_ids=(),
|
||||
scopes=[
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
"system:settings:write",
|
||||
],
|
||||
)
|
||||
with patch(
|
||||
"govoplan_access.backend.auth.dependencies."
|
||||
"collect_user_authorization_context",
|
||||
return_value=context,
|
||||
):
|
||||
result = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=self._request(),
|
||||
)
|
||||
|
||||
self.assertTrue(result.allowed)
|
||||
self.assertIsInstance(result.principal, ApiPrincipal)
|
||||
self.assertEqual(
|
||||
frozenset(
|
||||
{
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
}
|
||||
),
|
||||
result.principal.scopes,
|
||||
)
|
||||
self.assertIsNone(
|
||||
result.principal.principal.service_account_id
|
||||
)
|
||||
self.assertEqual(
|
||||
self.account.id,
|
||||
result.principal.principal.acting_for_account_id,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"system:settings:write",
|
||||
result.principal.scopes,
|
||||
)
|
||||
self.assertEqual(
|
||||
"delegated_user",
|
||||
result.provenance["trigger_owner"]["kind"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"event-user-1",
|
||||
result.provenance["event_actor"]["id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"operator-1",
|
||||
result.provenance["operator_override"]["id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.account.id,
|
||||
result.provenance[
|
||||
"current_automation_principal"
|
||||
]["account_id"],
|
||||
)
|
||||
|
||||
def test_revoked_scope_and_suspended_owner_fail_closed(self) -> None:
|
||||
context = UserAuthorizationContext(
|
||||
tenant_roles=[],
|
||||
system_roles=[],
|
||||
groups=[],
|
||||
function_assignment_ids=(),
|
||||
function_delegation_ids=(),
|
||||
scopes=["dataflow:pipeline:run"],
|
||||
)
|
||||
with patch(
|
||||
"govoplan_access.backend.auth.dependencies."
|
||||
"collect_user_authorization_context",
|
||||
return_value=context,
|
||||
):
|
||||
result = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=self._request(),
|
||||
)
|
||||
self.assertFalse(result.allowed)
|
||||
self.assertEqual(
|
||||
("datasources:catalogue:read",),
|
||||
result.missing_scopes,
|
||||
)
|
||||
|
||||
self.account.is_active = False
|
||||
self.session.flush()
|
||||
suspended = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=self._request(),
|
||||
)
|
||||
self.assertFalse(suspended.allowed)
|
||||
self.assertEqual(
|
||||
"inactive_or_inconsistent",
|
||||
suspended.provenance["status"],
|
||||
)
|
||||
|
||||
def test_service_account_resolution_uses_current_scope_ceiling(self) -> None:
|
||||
account = Account(
|
||||
id="service-account-backing",
|
||||
email="service@example.invalid",
|
||||
normalized_email="service@example.invalid",
|
||||
display_name="Import worker",
|
||||
auth_provider="service_account",
|
||||
)
|
||||
membership = User(
|
||||
id="service-membership",
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
auth_provider="service_account",
|
||||
)
|
||||
service_account = ServiceAccount(
|
||||
id="service-1",
|
||||
tenant_id=self.tenant.id,
|
||||
account_id=account.id,
|
||||
membership_id=membership.id,
|
||||
name="Import worker",
|
||||
normalized_name="import worker",
|
||||
scope_ceiling=[
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
"system:settings:write",
|
||||
],
|
||||
is_active=True,
|
||||
revision=1,
|
||||
settings={},
|
||||
)
|
||||
self.session.add_all(
|
||||
(account, membership, service_account)
|
||||
)
|
||||
self.session.flush()
|
||||
request = AutomationPrincipalRequest.service_account(
|
||||
tenant_id=self.tenant.id,
|
||||
service_account_id=service_account.id,
|
||||
authorization_ref="dataflow-trigger:service",
|
||||
grant_scopes=(
|
||||
"dataflow:pipeline:run",
|
||||
"datasources:catalogue:read",
|
||||
),
|
||||
)
|
||||
|
||||
result = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=request,
|
||||
)
|
||||
|
||||
self.assertTrue(result.allowed)
|
||||
self.assertEqual(
|
||||
service_account.id,
|
||||
result.principal.principal.service_account_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
frozenset(request.grant_scopes),
|
||||
result.principal.scopes,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"system:settings:write",
|
||||
result.principal.scopes,
|
||||
)
|
||||
self.assertEqual(
|
||||
"service_account",
|
||||
result.provenance["trigger_owner"]["kind"],
|
||||
)
|
||||
|
||||
service_account.scope_ceiling = [
|
||||
"dataflow:pipeline:run"
|
||||
]
|
||||
self.session.flush()
|
||||
reduced = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=request,
|
||||
)
|
||||
self.assertFalse(reduced.allowed)
|
||||
self.assertEqual(
|
||||
("datasources:catalogue:read",),
|
||||
reduced.missing_scopes,
|
||||
)
|
||||
|
||||
service_account.is_active = False
|
||||
self.session.flush()
|
||||
inactive = self.provider.resolve_automation_principal(
|
||||
self.session,
|
||||
request=request,
|
||||
)
|
||||
self.assertFalse(inactive.allowed)
|
||||
self.assertEqual(
|
||||
"inactive_or_inconsistent",
|
||||
inactive.provenance["status"],
|
||||
)
|
||||
|
||||
def test_manifest_registers_automation_resolution(self) -> None:
|
||||
self.assertIn(
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
manifest.capability_factories,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,104 @@
|
||||
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.provider_governance import (
|
||||
ExternalProviderRuntimeState,
|
||||
ExternalProviderStateProviderRegistration,
|
||||
)
|
||||
|
||||
|
||||
class ConfigurationPackageContextTests(unittest.TestCase):
|
||||
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,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,70 @@
|
||||
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.admin.api-keys",
|
||||
"access.admin.service-accounts",
|
||||
"access.credentials",
|
||||
},
|
||||
"access.reference.external-function-role-mappings": {
|
||||
"access.admin.function-mappings",
|
||||
"access.explanation",
|
||||
},
|
||||
"access.workflow.manage-service-account-credentials": {
|
||||
"access.admin.service-accounts",
|
||||
},
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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",
|
||||
}.issubset(surface_ids)
|
||||
)
|
||||
|
||||
|
||||
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,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("mm_"))
|
||||
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()
|
||||
+8
-5
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "@govoplan/access-webui",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
@@ -13,11 +16,11 @@
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.11",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"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,58 @@
|
||||
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 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/);
|
||||
assert.match(moduleSource, /access\.admin\.tenant-service-accounts/);
|
||||
assert.match(moduleSource, /translations,/);
|
||||
assert.match(moduleSource, /version: "0\.1\.11"/);
|
||||
|
||||
assert.doesNotMatch(allAdminSource, /window\.(alert|confirm|prompt)\s*\(/);
|
||||
assert.doesNotMatch(allAdminSource, /@govoplan\/(files|mail|organizations|idm)-webui\//);
|
||||
|
||||
console.log("Access interface pattern-language checks passed.");
|
||||
@@ -0,0 +1,31 @@
|
||||
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type ActingContext = {
|
||||
assignment_id: string;
|
||||
acting_for_account_id: string;
|
||||
function_id: string;
|
||||
organization_unit_id: string;
|
||||
valid_from?: string | null;
|
||||
valid_until?: string | null;
|
||||
};
|
||||
|
||||
export type ActingContextList = {
|
||||
contexts: ActingContext[];
|
||||
active_assignment_id?: string | null;
|
||||
};
|
||||
|
||||
export function fetchActingContexts(settings: ApiSettings): Promise<ActingContextList> {
|
||||
return apiFetch<ActingContextList>(settings, "/api/v1/auth/acting-contexts", {
|
||||
cache: "no-store"
|
||||
});
|
||||
}
|
||||
|
||||
export function switchActingContext(
|
||||
settings: ApiSettings,
|
||||
assignmentId: string | null
|
||||
): Promise<ActingContextList> {
|
||||
return apiFetch<ActingContextList>(settings, "/api/v1/auth/switch-acting-context", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ assignment_id: assignmentId })
|
||||
});
|
||||
}
|
||||
+110
-80
@@ -20,12 +20,6 @@ export type {
|
||||
TenantAdminItem
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
export type TenantOwnerCandidate = {
|
||||
account_id: string;
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
};
|
||||
|
||||
export type RoleSummary = {
|
||||
id: string;
|
||||
slug: string;
|
||||
@@ -178,24 +172,6 @@ export type LanguagePackage = {
|
||||
native_label?: string | null;
|
||||
};
|
||||
|
||||
export type TenantSettingsItem = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
default_locale: string;
|
||||
available_languages: LanguagePackage[];
|
||||
system_enabled_language_codes: string[];
|
||||
enabled_language_codes: string[];
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TenantSettingsDeltaSections = Partial<{
|
||||
identity: Pick<TenantSettingsItem, "id" | "slug" | "name">;
|
||||
locale: Pick<TenantSettingsItem, "default_locale">;
|
||||
languages: Pick<TenantSettingsItem, "available_languages" | "system_enabled_language_codes" | "enabled_language_codes">;
|
||||
settings: Pick<TenantSettingsItem, "settings">["settings"];
|
||||
}>;
|
||||
|
||||
export type GovernanceAssignment = {
|
||||
tenant_id: string;
|
||||
mode: "available" | "required";
|
||||
@@ -228,6 +204,43 @@ export type ApiKeyAdminItem = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ServiceAccountItem = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
scope_ceiling: string[];
|
||||
is_active: boolean;
|
||||
revision: number;
|
||||
credential_count: number;
|
||||
active_credential_count: number;
|
||||
last_credential_used_at?: string | null;
|
||||
retired_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ServiceAccountCredentialItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
scopes: string[];
|
||||
expires_at?: string | null;
|
||||
last_used_at?: string | null;
|
||||
revoked_at?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ServiceAccountCredentialListResponse = {
|
||||
service_account_revision: number;
|
||||
items: ServiceAccountCredentialItem[];
|
||||
};
|
||||
|
||||
export type ServiceAccountCredentialMutationResponse = {
|
||||
service_account_revision: number;
|
||||
credential: ServiceAccountCredentialItem;
|
||||
};
|
||||
|
||||
export type ExternalFunctionRoleMappingItem = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
@@ -251,14 +264,8 @@ export type GroupListDeltaResponse = { groups: GroupSummary[] } & DeltaResponseF
|
||||
export type RoleListDeltaResponse = { roles: RoleSummary[] } & DeltaResponseFields;
|
||||
export type SystemAccountListDeltaResponse = { accounts: SystemAccountItem[]; roles: RoleSummary[] } & DeltaResponseFields;
|
||||
export type ApiKeyListDeltaResponse = { api_keys: ApiKeyAdminItem[] } & DeltaResponseFields;
|
||||
export type TenantListDeltaResponse = { tenants: TenantAdminItem[] } & DeltaResponseFields;
|
||||
export type GovernanceTemplateListDeltaResponse = { templates: GovernanceTemplateItem[] } & DeltaResponseFields;
|
||||
export type ExternalFunctionRoleMappingListDeltaResponse = { mappings: ExternalFunctionRoleMappingItem[] } & DeltaResponseFields;
|
||||
export type TenantSettingsDeltaResponse = {
|
||||
item?: TenantSettingsItem | null;
|
||||
sections: TenantSettingsDeltaSections;
|
||||
changed_sections: string[];
|
||||
} & DeltaResponseFields;
|
||||
|
||||
function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
|
||||
return apiQuery(options);
|
||||
@@ -266,56 +273,6 @@ function deltaSuffix(options: { since?: string | null; limit?: number } = {}): s
|
||||
|
||||
|
||||
|
||||
export function fetchTenantsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<TenantListDeltaResponse> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/tenants/delta${suffix}`);
|
||||
}
|
||||
|
||||
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
|
||||
return apiGetList<TenantOwnerCandidate, "accounts">(settings, "/api/v1/admin/tenants/owner-candidates", "accounts");
|
||||
}
|
||||
|
||||
export function createTenant(settings: ApiSettings, payload: {
|
||||
slug: string;
|
||||
name: string;
|
||||
owner_account_id?: string | null;
|
||||
description?: string | null;
|
||||
default_locale?: string;
|
||||
settings?: Record<string, unknown>;
|
||||
allow_custom_groups?: boolean | null;
|
||||
allow_custom_roles?: boolean | null;
|
||||
allow_api_keys?: boolean | null;
|
||||
}): Promise<TenantAdminItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/tenants", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateTenant(settings: ApiSettings, tenantId: string, payload: Partial<{
|
||||
name: string;
|
||||
description: string | null;
|
||||
default_locale: string;
|
||||
settings: Record<string, unknown>;
|
||||
allow_custom_groups?: boolean | null;
|
||||
allow_custom_roles?: boolean | null;
|
||||
allow_api_keys?: boolean | null;
|
||||
effective_governance: Record<string, boolean>;
|
||||
is_active: boolean;
|
||||
}>): Promise<TenantAdminItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function fetchTenantSettings(settings: ApiSettings): Promise<TenantSettingsItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/tenant/settings");
|
||||
}
|
||||
|
||||
export function fetchTenantSettingsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<TenantSettingsDeltaResponse> {
|
||||
const suffix = deltaSuffix(options);
|
||||
return apiFetch(settings, `/api/v1/admin/tenant/settings/delta${suffix}`);
|
||||
}
|
||||
|
||||
export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string; enabled_language_codes?: string[] | null }): Promise<TenantSettingsItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/tenant/settings", { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> {
|
||||
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users");
|
||||
return response.users;
|
||||
@@ -527,6 +484,79 @@ export function revokeApiKey(settings: ApiSettings, keyId: string): Promise<ApiK
|
||||
return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" });
|
||||
}
|
||||
|
||||
export async function fetchServiceAccounts(settings: ApiSettings): Promise<ServiceAccountItem[]> {
|
||||
const response = await apiFetch<{ items: ServiceAccountItem[] }>(settings, "/api/v1/admin/service-accounts");
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export function createServiceAccount(settings: ApiSettings, payload: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
scope_ceiling: string[];
|
||||
}): Promise<ServiceAccountItem> {
|
||||
return apiFetch(settings, "/api/v1/admin/service-accounts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateServiceAccount(settings: ApiSettings, serviceAccountId: string, payload: {
|
||||
expected_revision: number;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
scope_ceiling?: string[];
|
||||
is_active?: boolean;
|
||||
}): Promise<ServiceAccountItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function retireServiceAccount(settings: ApiSettings, serviceAccountId: string, expectedRevision: number): Promise<ServiceAccountItem> {
|
||||
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/retire`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expected_revision: expectedRevision })
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchServiceAccountCredentials(settings: ApiSettings, serviceAccountId: string, includeRevoked = true): Promise<ServiceAccountCredentialListResponse> {
|
||||
return apiFetch(settings, apiPath(`/api/v1/admin/service-accounts/${serviceAccountId}/credentials`, {
|
||||
include_revoked: includeRevoked
|
||||
}));
|
||||
}
|
||||
|
||||
export function createServiceAccountCredential(settings: ApiSettings, serviceAccountId: string, payload: {
|
||||
expected_revision: number;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
expires_at?: string | null;
|
||||
}): Promise<ServiceAccountCredentialMutationResponse & { secret: string }> {
|
||||
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/credentials`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function rotateServiceAccountCredential(settings: ApiSettings, serviceAccountId: string, credentialId: string, payload: {
|
||||
expected_revision: number;
|
||||
name?: string | null;
|
||||
scopes?: string[] | null;
|
||||
expires_at?: string | null;
|
||||
}): Promise<ServiceAccountCredentialMutationResponse & { secret: string }> {
|
||||
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/credentials/${credentialId}/rotate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function revokeServiceAccountCredential(settings: ApiSettings, serviceAccountId: string, credentialId: string, expectedRevision: number): Promise<ServiceAccountCredentialMutationResponse> {
|
||||
return apiFetch(settings, `/api/v1/admin/service-accounts/${serviceAccountId}/credentials/${credentialId}/revoke`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expected_revision: expectedRevision })
|
||||
});
|
||||
}
|
||||
|
||||
export function createSystemAccount(settings: ApiSettings, payload: {
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
fetchMe,
|
||||
type ActingContextSelectorProps
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
fetchActingContexts,
|
||||
switchActingContext,
|
||||
type ActingContext
|
||||
} from "../../api/actingContext";
|
||||
|
||||
export default function ActingContextSelector({
|
||||
settings,
|
||||
auth,
|
||||
onAuthChange
|
||||
}: ActingContextSelectorProps) {
|
||||
const [contexts, setContexts] = useState<ActingContext[]>([]);
|
||||
const [activeId, setActiveId] = useState<string>("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void fetchActingContexts(settings)
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
setContexts(response.contexts);
|
||||
setActiveId(response.active_assignment_id ?? "");
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (active) setError(reason instanceof Error ? reason.message : "Acting context could not be loaded.");
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [settings.apiBaseUrl, settings.accessToken, settings.apiKey, auth.active_tenant?.id, auth.tenant.id]);
|
||||
|
||||
if (!contexts.length && !activeId) return null;
|
||||
|
||||
async function selectContext(assignmentId: string) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await switchActingContext(settings, assignmentId || null);
|
||||
setContexts(response.contexts);
|
||||
setActiveId(response.active_assignment_id ?? "");
|
||||
onAuthChange(await fetchMe(settings));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Acting context could not be changed.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="acting-context-selector" title={error || "Select whose authority is represented by this session."}>
|
||||
<span>Acting as</span>
|
||||
<select
|
||||
aria-label="Acting context"
|
||||
value={activeId}
|
||||
disabled={busy}
|
||||
onChange={(event) => void selectContext(event.target.value)}
|
||||
>
|
||||
<option value="">Own account</option>
|
||||
{contexts.map((context) => (
|
||||
<option key={context.assignment_id} value={context.assignment_id}>
|
||||
{context.function_id} / {context.organization_unit_id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useSearchParams } from "react-router";
|
||||
import type {
|
||||
AdminSectionContribution,
|
||||
AdminSectionsUiCapability,
|
||||
@@ -11,24 +11,49 @@ import type {
|
||||
OrganizationFunctionPickerUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import { fetchShellAuth } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
|
||||
import { ActionBlockerHint } from "@govoplan/core-webui";
|
||||
import { PageScrollViewport } from "@govoplan/core-webui";
|
||||
import {
|
||||
TreeSubnav,
|
||||
type TreeSubnavNode
|
||||
} from "@govoplan/core-webui";
|
||||
import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
|
||||
import SystemUsersPanel from "./SystemUsersPanel";
|
||||
import TenantSettingsPanel from "./TenantSettingsPanel";
|
||||
import SystemRolesPanel from "./SystemRolesPanel";
|
||||
import TenantsPanel from "./TenantsPanel";
|
||||
import UsersPanel from "./UsersPanel";
|
||||
import GroupsPanel from "./GroupsPanel";
|
||||
import RolesPanel from "./RolesPanel";
|
||||
import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPanel";
|
||||
import ApiKeysPanel from "./ApiKeysPanel";
|
||||
import ServiceAccountsPanel from "./ServiceAccountsPanel";
|
||||
import FileConnectorsPanel from "./FileConnectorsPanel";
|
||||
import MailProfilesPanel from "./MailProfilesPanel";
|
||||
import { usePlatformUiCapabilities, usePlatformUiCapability } from "@govoplan/core-webui";
|
||||
import CredentialEnvelopesPanel from "./CredentialEnvelopesPanel";
|
||||
import {
|
||||
ACCESS_INTERFACE_I18N,
|
||||
ACCESS_WORKFLOW_DOCUMENTATION
|
||||
} from "./interfacePatterns";
|
||||
import {
|
||||
isViewSurfaceVisible,
|
||||
useEffectiveView,
|
||||
usePlatformUiCapabilities,
|
||||
usePlatformUiCapability,
|
||||
useViewSurfaces
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
type AdminSection = string;
|
||||
type OrderedAdminNavItem = { id: AdminSection; label: string; order: number };
|
||||
type OrderedAdminNavItem = {
|
||||
id: AdminSection;
|
||||
label: string;
|
||||
order: number;
|
||||
moduleId?: string;
|
||||
kind?: "management" | "settings";
|
||||
};
|
||||
type AdminNavGroup = {
|
||||
id: string;
|
||||
title: string;
|
||||
items: OrderedAdminNavItem[];
|
||||
};
|
||||
|
||||
const handledAdminSectionIds = new Set<string>([
|
||||
"overview",
|
||||
@@ -36,27 +61,70 @@ const handledAdminSectionIds = new Set<string>([
|
||||
"system-configuration-changes",
|
||||
"system-configuration-packages",
|
||||
"system-modules",
|
||||
"system-tenants",
|
||||
"system-roles",
|
||||
"system-role-templates",
|
||||
"system-groups",
|
||||
"system-users",
|
||||
"system-file-connectors",
|
||||
"system-mail-servers",
|
||||
"tenant-settings",
|
||||
"system-credentials",
|
||||
"tenant-roles",
|
||||
"tenant-function-role-mappings",
|
||||
"tenant-groups",
|
||||
"tenant-users",
|
||||
"tenant-file-connectors",
|
||||
"tenant-mail-servers",
|
||||
"tenant-credentials",
|
||||
"tenant-api-keys",
|
||||
"tenant-service-accounts",
|
||||
"tenant-group-file-connectors",
|
||||
"tenant-group-mail-servers",
|
||||
"tenant-group-credentials",
|
||||
"tenant-user-file-connectors",
|
||||
"tenant-user-mail-servers"
|
||||
"tenant-user-mail-servers",
|
||||
"tenant-user-credentials"
|
||||
]);
|
||||
|
||||
const builtInAdminSurfaceIds: Record<string, string> = {
|
||||
"system-roles": "access.admin.system-roles",
|
||||
"system-users": "access.admin.system-users",
|
||||
"system-credentials": "access.admin.system-credentials",
|
||||
"tenant-roles": "access.admin.tenant-roles",
|
||||
"tenant-function-role-mappings": "access.admin.tenant-function-mappings",
|
||||
"tenant-groups": "access.admin.tenant-groups",
|
||||
"tenant-users": "access.admin.tenant-users",
|
||||
"tenant-credentials": "access.admin.tenant-credentials",
|
||||
"tenant-api-keys": "access.admin.tenant-api-keys",
|
||||
"tenant-service-accounts": "access.admin.tenant-service-accounts",
|
||||
"tenant-group-credentials": "access.admin.group-credentials",
|
||||
"tenant-user-credentials": "access.admin.user-credentials",
|
||||
"system-mail-servers": "mail.admin.system-servers",
|
||||
"tenant-mail-servers": "mail.admin.tenant-servers",
|
||||
"tenant-group-mail-servers": "mail.admin.group-servers",
|
||||
"tenant-user-mail-servers": "mail.admin.user-servers",
|
||||
"tenant-group-file-connectors": "files.admin.group-connectors",
|
||||
"tenant-user-file-connectors": "files.admin.user-connectors"
|
||||
};
|
||||
|
||||
const builtInAdminSectionMetadata: Record<
|
||||
string,
|
||||
Pick<OrderedAdminNavItem, "moduleId" | "kind">
|
||||
> = {
|
||||
"system-settings": { moduleId: "admin", kind: "settings" },
|
||||
"system-file-connectors": { moduleId: "files", kind: "settings" },
|
||||
"system-mail-servers": { moduleId: "mail", kind: "settings" },
|
||||
"system-credentials": { moduleId: "access", kind: "settings" },
|
||||
"tenant-file-connectors": { moduleId: "files", kind: "settings" },
|
||||
"tenant-mail-servers": { moduleId: "mail", kind: "settings" },
|
||||
"tenant-credentials": { moduleId: "access", kind: "settings" },
|
||||
"tenant-group-file-connectors": { moduleId: "files", kind: "settings" },
|
||||
"tenant-group-mail-servers": { moduleId: "mail", kind: "settings" },
|
||||
"tenant-group-credentials": { moduleId: "access", kind: "settings" },
|
||||
"tenant-user-file-connectors": { moduleId: "files", kind: "settings" },
|
||||
"tenant-user-mail-servers": { moduleId: "mail", kind: "settings" },
|
||||
"tenant-user-credentials": { moduleId: "access", kind: "settings" }
|
||||
};
|
||||
|
||||
export default function AdminPage({
|
||||
settings,
|
||||
auth,
|
||||
@@ -70,14 +138,23 @@ export default function AdminPage({
|
||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||
const organizationFunctionPicker = usePlatformUiCapability<OrganizationFunctionPickerUiCapability>("organizations.functionPicker");
|
||||
const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
|
||||
const effectiveView = useEffectiveView();
|
||||
const viewSurfaces = useViewSurfaces();
|
||||
const mailProfilesAvailable = Boolean(mailProfilesUi);
|
||||
const fileConnectorsAvailable = Boolean(fileConnectorsUi);
|
||||
const contributedSections = useMemo(
|
||||
() =>
|
||||
adminSectionCapabilities
|
||||
.flatMap((capability) => capability.sections)
|
||||
.filter((section) =>
|
||||
isViewSurfaceVisible(
|
||||
effectiveView,
|
||||
section.surfaceId,
|
||||
viewSurfaces
|
||||
)
|
||||
)
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
|
||||
[adminSectionCapabilities]
|
||||
[adminSectionCapabilities, effectiveView, viewSurfaces]
|
||||
);
|
||||
const contributionById = useMemo(() => {
|
||||
const mapped = new Map<string, AdminSectionContribution>();
|
||||
@@ -95,7 +172,9 @@ export default function AdminPage({
|
||||
if (hasScope(auth, "system:settings:read")) {
|
||||
if (mailProfilesAvailable) sections.add("system-mail-servers");
|
||||
}
|
||||
if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants");
|
||||
if (hasAnyScope(auth, ["system:settings:read", "access:system_credential:read"])) {
|
||||
sections.add("system-credentials");
|
||||
}
|
||||
if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users");
|
||||
if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles");
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-users");
|
||||
@@ -103,6 +182,7 @@ export default function AdminPage({
|
||||
if (hasScope(auth, "admin:roles:read")) sections.add("tenant-roles");
|
||||
if (organizationFunctionPicker && hasAnyScope(auth, ["admin:roles:read", "access:function:read", "access:role:read"])) sections.add("tenant-function-role-mappings");
|
||||
if (hasScope(auth, "admin:api_keys:read")) sections.add("tenant-api-keys");
|
||||
if (hasScope(auth, "access:service_account:read")) sections.add("tenant-service-accounts");
|
||||
if (mailProfilesAvailable && hasAnyScope(auth, ["mail_servers:read", "admin:policies:read"])) {
|
||||
sections.add("tenant-mail-servers");
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-mail-servers");
|
||||
@@ -112,9 +192,21 @@ export default function AdminPage({
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-file-connectors");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors");
|
||||
}
|
||||
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
|
||||
return sections;
|
||||
}, [auth, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker]);
|
||||
if (hasAnyScope(auth, ["admin:settings:read", "access:credential:read"])) {
|
||||
sections.add("tenant-credentials");
|
||||
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-credentials");
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-credentials");
|
||||
}
|
||||
return new Set(
|
||||
[...sections].filter((sectionId) =>
|
||||
isViewSurfaceVisible(
|
||||
effectiveView,
|
||||
builtInAdminSurfaceIds[sectionId],
|
||||
viewSurfaces
|
||||
)
|
||||
)
|
||||
);
|
||||
}, [auth, contributedSections, effectiveView, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker, viewSurfaces]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const requestedSection = searchParams.get("section") as AdminSection | null;
|
||||
const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview");
|
||||
@@ -137,19 +229,34 @@ export default function AdminPage({
|
||||
|
||||
if (!hasAnyScope(auth, adminReadScopes)) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
<PageScrollViewport>
|
||||
<div className="content-pad">
|
||||
<ActionBlockerHint
|
||||
tone="warning"
|
||||
reason={{
|
||||
summary: "i18n:govoplan-access.administration_unavailable.b86d4cb5",
|
||||
details: "i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69",
|
||||
requiredAction: ACCESS_INTERFACE_I18N.requestAdministrationAccess,
|
||||
actor: ACCESS_INTERFACE_I18N.accessAdministrator,
|
||||
target: ACCESS_INTERFACE_I18N.accessAdministration
|
||||
}}
|
||||
labels={{
|
||||
requiredAction: ACCESS_INTERFACE_I18N.requiredAction,
|
||||
actor: ACCESS_INTERFACE_I18N.actor,
|
||||
target: ACCESS_INTERFACE_I18N.destinationLabel
|
||||
}}
|
||||
documentation={ACCESS_WORKFLOW_DOCUMENTATION}
|
||||
/>
|
||||
</div>
|
||||
</PageScrollViewport>
|
||||
);
|
||||
}
|
||||
|
||||
const adminSubnav: ModuleSubnavGroup<AdminSection>[] = [
|
||||
const adminNavGroups: AdminNavGroup[] = [
|
||||
{
|
||||
title: "ADMINISTRATION",
|
||||
items: asSubnavItems(
|
||||
sortNavItems([
|
||||
id: "administration",
|
||||
title: "i18n:govoplan-access.admin.4e7afebc",
|
||||
items: sortNavItems([
|
||||
...contributedNavItems(contributedSections, available, "ROOT"),
|
||||
visibleNavItem(available, "system-modules", "i18n:govoplan-access.modules.04e9462c", 10),
|
||||
visibleNavItem(available, "system-configuration-packages", "i18n:govoplan-access.packages.0a999012", 20),
|
||||
@@ -157,75 +264,83 @@ 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} />
|
||||
<TreeSubnav
|
||||
active={active}
|
||||
nodes={adminTree}
|
||||
onSelect={selectSection}
|
||||
ariaLabel="i18n:govoplan-access.admin.4e7afebc"
|
||||
/>
|
||||
<section className="workspace-content">
|
||||
<div className="content-pad workspace-data-page">
|
||||
{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
|
||||
@@ -244,12 +359,15 @@ export default function AdminPage({
|
||||
{!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>
|
||||
@@ -270,17 +388,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(" ");
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ import { DateTimeField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, useDeltaWatermarks } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, useDeltaWatermarks } from "@govoplan/core-webui";
|
||||
import { scopeGrants, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
import { ACCESS_INTERFACE_I18N, ACCESS_REFERENCE_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||
|
||||
function defaultDraft(userId: string) {
|
||||
return { name: "", userId, scopes: ["campaign:read"], expiresAt: "" };
|
||||
@@ -102,7 +103,7 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
|
||||
{ id: "expires", header: "i18n:govoplan-access.expires.a99be3da", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.expires_at || "", render: (row) => row.expires_at ? formatDateTime(row.expires_at) : "i18n:govoplan-access.no_expiry.39d436aa" },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 108, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "revoke", label: i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.revoked_at, disabled: !canRevoke, onClick: () => setRevoking(row) }
|
||||
{ id: "revoke", label: i18nMessage("i18n:govoplan-access.revoke_value.34640d6a", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.revoked_at, disabled: !canRevoke, disabledReason: row.revoked_at ? "i18n:govoplan-access.revoked.85f17ac0" : !canRevoke ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => setRevoking(row) }
|
||||
]} /> }],
|
||||
[canRevoke]);
|
||||
|
||||
@@ -151,11 +152,11 @@ export default function ApiKeysPanel({ settings, auth, canCreate, canRevoke }: {
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_api_keys.4b1d81f8" description="i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae" loading={loading} error={error} success={success} actions={<><ToggleSwitch label="i18n:govoplan-access.show_revoked.b4265807" checked={showRevoked} onChange={setShowRevoked} /><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_api_key.725d9988" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate || !users.length} /></>}>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_api_keys.4b1d81f8" description="i18n:govoplan-access.tenant_scoped_automation_credentials_are_capped_.9059dcae" loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} /><ToggleSwitch label="i18n:govoplan-access.show_revoked.b4265807" checked={showRevoked} onChange={setShowRevoked} /><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_api_key.725d9988" icon={<Plus />} variant="primary" 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></>}>
|
||||
<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} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canCreate, complete: Boolean(draft.name.trim() && draft.userId && draft.scopes.length) })}>{busy ? "i18n:govoplan-access.creating.94d7d8ee" : "i18n:govoplan-access.create_key.e028cb09"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
|
||||
import { i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
import { ACCESS_INTERFACE_I18N, FUNCTION_MAPPING_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||
|
||||
const emptyDraft = {
|
||||
sourceModule: "organizations",
|
||||
@@ -258,8 +259,8 @@ export default function ExternalFunctionRoleMappingsPanel({
|
||||
resizable: false,
|
||||
align: "right",
|
||||
render: (row) => <TableActionGroup actions={[
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id }), icon: <Pencil />, disabled: !canWrite, onClick: () => openEdit(row) },
|
||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.function_id }), icon: <Trash2 />, variant: "danger", disabled: !canWrite, onClick: () => setDeleting(row) }
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id }), icon: <Pencil />, disabled: !canWrite, disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.function_id }), icon: <Trash2 />, variant: "danger", disabled: !canWrite, disabledReason: !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => setDeleting(row) }
|
||||
]} />
|
||||
}
|
||||
],
|
||||
@@ -276,8 +277,9 @@ export default function ExternalFunctionRoleMappingsPanel({
|
||||
success={success}
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button>
|
||||
<AdminIconButton label="i18n:govoplan-access.add_function_role_mapping.1bc376ac" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite || !assignableRoles.length} />
|
||||
<DocumentationHelpLink reference={FUNCTION_MAPPING_DOCUMENTATION} />
|
||||
<Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button>
|
||||
<AdminIconButton label="i18n:govoplan-access.add_function_role_mapping.1bc376ac" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canWrite || !assignableRoles.length} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.createPermissionRequired : !assignableRoles.length ? ACCESS_INTERFACE_I18N.selectAssignableRole : undefined} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -300,8 +302,8 @@ export default function ExternalFunctionRoleMappingsPanel({
|
||||
className="admin-dialog"
|
||||
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>
|
||||
</>
|
||||
|
||||
@@ -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>);
|
||||
|
||||
}
|
||||
|
||||
@@ -8,8 +8,13 @@ import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
import {
|
||||
ACCESS_INTERFACE_I18N,
|
||||
ACCESS_WORKFLOW_DOCUMENTATION,
|
||||
saveDisabledReason
|
||||
} from "./interfacePatterns";
|
||||
|
||||
const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] };
|
||||
|
||||
@@ -133,18 +138,18 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember
|
||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !(canDefine || canManageMembers || canAssignRoles), onClick: () => openEdit(row) },
|
||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canDefine || Boolean(row.system_required), onClick: () => setDeactivating(row) }
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !(canDefine || canManageMembers || canAssignRoles), disabledReason: !(canDefine || canManageMembers || canAssignRoles) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canDefine || Boolean(row.system_required), disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canDefine ? ACCESS_INTERFACE_I18N.writePermissionRequired : row.system_required ? ACCESS_INTERFACE_I18N.systemManagedObject : undefined, onClick: () => setDeactivating(row) }
|
||||
]} /> }],
|
||||
[canAssignRoles, canDefine, canManageMembers]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_groups.47e6cc05" description="i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309" loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_group.2fca464f" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_groups.47e6cc05" description="i18n:govoplan-access.groups_provide_shared_file_spaces_and_inherited_.27f05309" loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_group.2fca464f" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} disabledReason={!canDefine ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-groups-v3" rows={groups} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_groups_found.627ca913" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_group.5a0b1c17" : "i18n:govoplan-access.edit_group.edb57d8e"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !draft.name.trim() || !draft.slug.trim() || (editing === "new" ? !canDefine : !(canDefine || canManageMembers || canAssignRoles))}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_group.36ca6865"}</Button></>}>
|
||||
<Dialog 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} 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">
|
||||
<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>
|
||||
|
||||
@@ -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>);
|
||||
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage } from "@govoplan/core-webui";
|
||||
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
import { ACCESS_INTERFACE_I18N, ACCESS_WORKFLOW_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||
|
||||
const emptyDraft = { slug: "", name: "", description: "", permissions: [] as string[], isAssignable: true };
|
||||
|
||||
@@ -126,18 +127,18 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
|
||||
{ id: "type", header: "i18n:govoplan-access.type.3deb7456", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : row.system_template_id ? "i18n:govoplan-access.system.bc0792d8" : "i18n:govoplan-access.custom.081ae3fd"} /> },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine, onClick: () => openEdit(row) },
|
||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine || row.user_assignments + row.group_assignments > 0, onClick: () => setDeleting(row) }
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine, disabledReason: row.is_builtin || row.system_template_id ? ACCESS_INTERFACE_I18N.systemManagedObject : !canDefine ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine || row.user_assignments + row.group_assignments > 0, disabledReason: row.is_builtin || row.system_template_id ? ACCESS_INTERFACE_I18N.systemManagedObject : !canDefine ? ACCESS_INTERFACE_I18N.writePermissionRequired : row.user_assignments + row.group_assignments > 0 ? ACCESS_INTERFACE_I18N.assignedObjectCannotBeDeleted : undefined, onClick: () => setDeleting(row) }
|
||||
]} /> }],
|
||||
[canDefine, permissions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_roles.51aca82d" description="i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa" loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_role.d8d5d55c" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} /></>}>
|
||||
<AdminPageLayout title="i18n:govoplan-access.tenant_roles.51aca82d" description="i18n:govoplan-access.roles_are_explicit_tenant_permission_bundles_bui.ce55fcaa" loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={ACCESS_WORKFLOW_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading} disabledReason={loading ? ACCESS_INTERFACE_I18N.loading : undefined}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_role.d8d5d55c" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canDefine} disabledReason={!canDefine ? ACCESS_INTERFACE_I18N.createPermissionRequired : undefined} /></>}>
|
||||
<div className="admin-table-surface"><DataGrid id="admin-roles-v3" rows={roles} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_roles_found.70f7c0c9" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_role.db859bad" : "i18n:govoplan-access.edit_role.61dd63e9"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={!canDefine || busy || !draft.name.trim() || !draft.slug.trim()}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
|
||||
<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} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: canDefine, complete: Boolean(draft.name.trim() && draft.slug.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_role.16fe10d1"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldOff,
|
||||
Trash2
|
||||
} from "lucide-react";
|
||||
import {
|
||||
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 />, 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 />,
|
||||
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",
|
||||
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}
|
||||
actions={<>
|
||||
<DocumentationHelpLink reference={ACCESS_REFERENCE_DOCUMENTATION} />
|
||||
<Button onClick={() => void load()} disabled={loading}>Reload</Button>
|
||||
<AdminIconButton label="Add service account" icon={<Plus />} variant="primary" 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
|
||||
open={Boolean(accountEditor)}
|
||||
title={accountEditor === "create" ? "Create service account" : "Edit service account"}
|
||||
onClose={() => !busy && setAccountEditor(null)}
|
||||
className="admin-dialog admin-dialog-wide"
|
||||
footer={<><Button onClick={() => setAccountEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveAccount()} disabled={!canWrite || busy || !accountDraft.name.trim()}>{busy ? "Saving..." : "Save"}</Button></>}
|
||||
>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Name"><input value={accountDraft.name} onChange={(event) => setAccountDraft({ ...accountDraft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Description"><input value={accountDraft.description} onChange={(event) => setAccountDraft({ ...accountDraft, description: event.target.value })} /></FormField>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<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
|
||||
open={Boolean(managing)}
|
||||
title={managing?.name ?? "Service account"}
|
||||
onClose={() => !busy && setManaging(null)}
|
||||
className="admin-dialog admin-dialog-wide"
|
||||
footer={<Button onClick={() => setManaging(null)} disabled={busy}>Close</Button>}
|
||||
>
|
||||
{managing && <>
|
||||
<div className="metric-grid 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} />
|
||||
</div>
|
||||
<div className="admin-toolbar-row">
|
||||
<Button onClick={openEditAccount} disabled={!canWrite || busy}><Pencil aria-hidden="true" /> Edit</Button>
|
||||
<Button 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" onClick={() => setRetiring(true)} disabled={!canWrite || busy || !managing.is_active}><Trash2 aria-hidden="true" /> Retire</Button>
|
||||
<AdminIconButton label="Create credential" icon={<KeyRound />} variant="primary" onClick={() => openCredentialEditor("create")} disabled={!canWrite || !managing.is_active} disabledReason={!canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : !managing.is_active ? "Activate the service account first." : undefined} />
|
||||
</div>
|
||||
<div className="admin-toolbar-row">
|
||||
<ToggleSwitch label="Show revoked credentials" checked={showRevoked} onChange={setShowRevoked} />
|
||||
</div>
|
||||
<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
|
||||
open={Boolean(credentialEditor)}
|
||||
title={credentialEditor?.mode === "rotate" ? "Rotate credential" : "Create credential"}
|
||||
onClose={() => !busy && setCredentialEditor(null)}
|
||||
className="admin-dialog admin-dialog-wide"
|
||||
footer={<><Button onClick={() => setCredentialEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" 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>}
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Name"><input value={credentialDraft.name} onChange={(event) => setCredentialDraft({ ...credentialDraft, name: event.target.value })} /></FormField>
|
||||
<FormField label="Expiry"><DateTimeField value={credentialDraft.expiresAt} onChange={(value) => setCredentialDraft({ ...credentialDraft, expiresAt: value })} /></FormField>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<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 open={Boolean(secret)} title="Service-account secret" onClose={() => setSecret(null)} className="admin-dialog" footer={<Button variant="primary" 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} 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} 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";
|
||||
}
|
||||
@@ -16,8 +16,9 @@ import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
import { ACCESS_INTERFACE_I18N, ACCESS_REFERENCE_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||
|
||||
const emptyDraft = {
|
||||
slug: "",
|
||||
@@ -225,8 +226,8 @@ export default function SystemRolesPanel({
|
||||
const protectedOwner = row.slug === "system_owner";
|
||||
return <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !protectedOwner, disabled: !canWrite, onClick: () => openEdit(row) },
|
||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !protectedOwner, disabled: !canWrite || row.user_assignments > 0, onClick: () => setDeleting(row) }
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !protectedOwner, disabled: !canWrite, disabledReason: protectedOwner ? ACCESS_INTERFACE_I18N.systemManagedObject : !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !protectedOwner, disabled: !canWrite || row.user_assignments > 0, disabledReason: protectedOwner ? ACCESS_INTERFACE_I18N.systemManagedObject : !canWrite ? ACCESS_INTERFACE_I18N.writePermissionRequired : row.user_assignments > 0 ? ACCESS_INTERFACE_I18N.assignedObjectCannotBeDeleted : undefined, onClick: () => setDeleting(row) }
|
||||
]} />;
|
||||
}
|
||||
}],
|
||||
@@ -240,7 +241,7 @@ 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" />
|
||||
@@ -252,7 +253,7 @@ export default function SystemRolesPanel({
|
||||
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></>}>
|
||||
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">
|
||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
type SystemMembershipDraft,
|
||||
type TenantAdminItem
|
||||
} from "../../api/admin";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { ACCESS_INTERFACE_I18N, ACCESS_REFERENCE_DOCUMENTATION, saveDisabledReason } from "./interfacePatterns";
|
||||
|
||||
const emptyDraft = {
|
||||
email: "",
|
||||
@@ -214,8 +215,8 @@ export default function SystemUsersPanel({
|
||||
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), onClick: () => openEdit(row) },
|
||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.memberships.some((membership) => membership.is_last_active_owner), onClick: () => setDeactivating(row) }
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), disabledReason: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.memberships.some((membership) => membership.is_last_active_owner), disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canSuspend ? ACCESS_INTERFACE_I18N.updatePermissionRequired : row.memberships.some((membership) => membership.is_last_active_owner) ? ACCESS_INTERFACE_I18N.lastOwnerCannotBeDeactivated : undefined, onClick: () => setDeactivating(row) }
|
||||
]} /> }],
|
||||
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
||||
|
||||
@@ -227,12 +228,12 @@ 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></>}>
|
||||
<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} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: editing === "new" ? canCreate : canUpdate || canSuspend || canAssignRoles || canManageMemberships, complete: Boolean(draft.email.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_account.0b761f5c"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<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>
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ApiSettings } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { fetchTenantSettingsDelta, updateTenantSettings, type TenantSettingsDeltaSections, type TenantSettingsItem } from "../../api/admin";
|
||||
import { AdminPageLayout, AdminSelectionList, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
|
||||
const DELTA_KEY = "access:tenant-settings";
|
||||
|
||||
const fallback: TenantSettingsItem = {
|
||||
id: "",
|
||||
slug: "",
|
||||
name: "",
|
||||
default_locale: "en",
|
||||
available_languages: [
|
||||
{ code: "en", label: "English", native_label: "English" },
|
||||
{ code: "de", label: "German", native_label: "Deutsch" }
|
||||
],
|
||||
system_enabled_language_codes: ["en", "de"],
|
||||
enabled_language_codes: ["en", "de"],
|
||||
settings: {}
|
||||
};
|
||||
|
||||
export default function TenantSettingsPanel({
|
||||
settings,
|
||||
canWrite,
|
||||
onAuthRefresh
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;canWrite: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
const [draft, setDraft] = useState<TenantSettingsItem>(fallback);
|
||||
const [savedDraft, setSavedDraft] = useState<TenantSettingsItem>(fallback);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const defaultLocaleOptions = localeOptions(draft.default_locale, draft.enabled_language_codes);
|
||||
const dirty = tenantSettingsDraftKey(draft) !== tenantSettingsDraftKey(savedDraft);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: () => setDraft(savedDraft)
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const wasDirty = tenantSettingsDraftKey(draft) !== tenantSettingsDraftKey(savedDraft);
|
||||
const loaded = await fetchTenantSettingsDelta(settings, { since: getDeltaWatermark(DELTA_KEY) });
|
||||
setDeltaWatermark(DELTA_KEY, loaded.watermark);
|
||||
if (loaded.full && loaded.item) {
|
||||
setSavedDraft(loaded.item);
|
||||
if (!wasDirty) setDraft(loaded.item);
|
||||
} else if (loaded.changed_sections.length) {
|
||||
setSavedDraft((current) => applyTenantSettingsSections(current, loaded.sections));
|
||||
if (!wasDirty) setDraft((current) => applyTenantSettingsSections(current, loaded.sections));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
resetDeltaWatermark(DELTA_KEY);
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const saved = await updateTenantSettings(settings, { default_locale: draft.default_locale, enabled_language_codes: draft.enabled_language_codes });
|
||||
setDraft(saved);
|
||||
setSavedDraft(saved);
|
||||
resetDeltaWatermark(DELTA_KEY);
|
||||
setSuccess("i18n:govoplan-access.tenant_general_settings_saved.485e7681");
|
||||
await onAuthRefresh();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function setEnabledLanguages(selected: string[]) {
|
||||
const enabled = new Set(selected);
|
||||
const nextEnabled = draft.system_enabled_language_codes.filter((item) => enabled.has(item));
|
||||
const defaultLocale = nextEnabled.includes(draft.default_locale) ? draft.default_locale : (nextEnabled[0] ?? draft.default_locale);
|
||||
setDraft({ ...draft, enabled_language_codes: nextEnabled, default_locale: defaultLocale });
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageLayout
|
||||
title="i18n:govoplan-access.tenant_general_settings.db1c3ba8"
|
||||
description="i18n:govoplan-access.settings_for_the_active_tenant_context.ad267b86"
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><Button variant="primary" onClick={() => void save()} disabled={!canWrite || busy || !draft.default_locale.trim()}>{busy ? "i18n:govoplan-access.saving.ae7e8875" : "i18n:govoplan-access.save_general_settings.5c90f8c4"}</Button></>}>
|
||||
|
||||
<div className="admin-settings-form">
|
||||
<Card title="i18n:govoplan-access.locale.8970f0e6">
|
||||
<FormField label="i18n:govoplan-access.tenant_locale.8fc19914" help="i18n:govoplan-access.used_as_this_tenant_s_locale_default_for_tenant_.cf298b8b">
|
||||
<select value={draft.default_locale} disabled={!canWrite || busy || defaultLocaleOptions.length === 0} onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })}>
|
||||
{defaultLocaleOptions.map((code) => {
|
||||
const language = draft.available_languages.find((item) => item.code === code);
|
||||
return <option key={code} value={code}>{languageOptionLabel(language ?? { code, label: code.toUpperCase() })}</option>;
|
||||
})}
|
||||
</select>
|
||||
</FormField>
|
||||
<AdminSelectionList
|
||||
options={draft.system_enabled_language_codes.map((code) => {
|
||||
const language = draft.available_languages.find((item) => item.code === code);
|
||||
return { id: code, label: code.toUpperCase(), description: languageOptionLabel(language ?? { code, label: code.toUpperCase() }), disabled: !canWrite || busy || code === draft.default_locale };
|
||||
})}
|
||||
selected={draft.enabled_language_codes}
|
||||
onChange={setEnabledLanguages}
|
||||
/>
|
||||
<p className="muted small-note">i18n:govoplan-access.tenant_languages_help</p>
|
||||
<dl className="detail-list">
|
||||
<div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{draft.name || "-"}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{draft.slug || "-"}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.available.7c62a142</dt><dd>{draft.available_languages.map((item) => item.code.toUpperCase()).join(", ") || "-"}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
</AdminPageLayout>);
|
||||
|
||||
}
|
||||
|
||||
function languageOptionLabel(language: {code: string;label: string;native_label?: string | null}): string {
|
||||
return `${language.code.toUpperCase()} - ${language.native_label || language.label}`;
|
||||
}
|
||||
|
||||
function localeOptions(current: string, enabled: string[]): string[] {
|
||||
return [...new Set([current, ...enabled].filter((item) => item && item.trim()))];
|
||||
}
|
||||
|
||||
function tenantSettingsDraftKey(item: TenantSettingsItem): string {
|
||||
return JSON.stringify({
|
||||
default_locale: item.default_locale,
|
||||
enabled_language_codes: item.enabled_language_codes
|
||||
});
|
||||
}
|
||||
|
||||
function applyTenantSettingsSections(item: TenantSettingsItem, sections: TenantSettingsDeltaSections): TenantSettingsItem {
|
||||
return {
|
||||
...item,
|
||||
...(sections.identity ?? {}),
|
||||
...(sections.locale ?? {}),
|
||||
...(sections.languages ?? {}),
|
||||
...(sections.settings ? { settings: sections.settings } : {})
|
||||
};
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "@govoplan/core-webui";
|
||||
import { createTenant, fetchSystemSettings, fetchTenantOwnerCandidates, fetchTenantsDelta, updateTenant, type SystemSettingsItem, type TenantAdminItem, type TenantOwnerCandidate } from "../../api/admin";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
|
||||
type OverrideValue = "inherit" | "allow" | "deny";
|
||||
type TenantDraft = {
|
||||
slug: string;
|
||||
name: string;
|
||||
ownerAccountId: string;
|
||||
description: string;
|
||||
defaultLocale: string;
|
||||
isActive: boolean;
|
||||
customGroups: OverrideValue;
|
||||
customRoles: OverrideValue;
|
||||
apiKeys: OverrideValue;
|
||||
};
|
||||
|
||||
const emptyDraft: TenantDraft = {
|
||||
slug: "",
|
||||
name: "",
|
||||
ownerAccountId: "",
|
||||
description: "",
|
||||
defaultLocale: "en",
|
||||
isActive: true,
|
||||
customGroups: "inherit",
|
||||
customRoles: "inherit",
|
||||
apiKeys: "inherit"
|
||||
};
|
||||
|
||||
function fromOverride(value?: boolean | null): OverrideValue {
|
||||
if (value === true) return "allow";
|
||||
if (value === false) return "deny";
|
||||
return "inherit";
|
||||
}
|
||||
|
||||
function toOverride(value: OverrideValue): boolean | null {
|
||||
if (value === "allow") return true;
|
||||
if (value === "deny") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function TenantsPanel({
|
||||
settings,
|
||||
auth,
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canSuspend,
|
||||
onAuthRefresh
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {settings: ApiSettings;auth: AuthInfo;canCreate: boolean;canUpdate: boolean;canSuspend: boolean;onAuthRefresh: () => Promise<void>;}) {
|
||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettingsItem | null>(null);
|
||||
const [ownerCandidates, setOwnerCandidates] = useState<TenantOwnerCandidate[]>([]);
|
||||
const tenantsRef = useRef<TenantAdminItem[]>([]);
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [editing, setEditing] = useState<TenantAdminItem | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<TenantAdminItem | null>(null);
|
||||
const [draft, setDraft] = useState<TenantDraft>(emptyDraft);
|
||||
const [savedDraftKey, setSavedDraftKey] = useState(draftKey(emptyDraft));
|
||||
const [confirmSuspend, setConfirmSuspend] = useState<TenantAdminItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const dirty = editing !== null && draftKey(draft) !== savedDraftKey;
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: closeEditor
|
||||
});
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextTenants, nextOwnerCandidates, nextSystemSettings] = await Promise.all([
|
||||
loadDeltaRows(tenantsRef.current, "tenancy:tenants", getDeltaWatermark, setDeltaWatermark, (since) => fetchTenantsDelta(settings, { since }), (response) => response.tenants, (tenant) => tenant.id, "tenant", sortTenants),
|
||||
canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([]),
|
||||
fetchSystemSettings(settings).catch(() => null)]
|
||||
);
|
||||
tenantsRef.current = nextTenants;
|
||||
setTenants(nextTenants);
|
||||
setOwnerCandidates(nextOwnerCandidates);
|
||||
setSystemSettings(nextSystemSettings);
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
tenantsRef.current = [];
|
||||
resetDeltaWatermark();
|
||||
void load();
|
||||
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
|
||||
|
||||
function openCreate() {
|
||||
const nextDraft = { ...emptyDraft, ownerAccountId: auth.user.account_id };
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
setEditing("new");
|
||||
setError("");
|
||||
}
|
||||
|
||||
function openEdit(tenant: TenantAdminItem) {
|
||||
const nextDraft = {
|
||||
slug: tenant.slug,
|
||||
name: tenant.name,
|
||||
ownerAccountId: "",
|
||||
description: tenant.description || "",
|
||||
defaultLocale: tenant.default_locale || "en",
|
||||
isActive: tenant.is_active,
|
||||
customGroups: fromOverride(tenant.allow_custom_groups),
|
||||
customRoles: fromOverride(tenant.allow_custom_roles),
|
||||
apiKeys: fromOverride(tenant.allow_api_keys)
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setSavedDraftKey(draftKey(nextDraft));
|
||||
setEditing(tenant);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
setEditing(null);
|
||||
setDraft(emptyDraft);
|
||||
setSavedDraftKey(draftKey(emptyDraft));
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const governance = {
|
||||
allow_custom_groups: toOverride(draft.customGroups),
|
||||
allow_custom_roles: toOverride(draft.customRoles),
|
||||
allow_api_keys: toOverride(draft.apiKeys)
|
||||
};
|
||||
if (editing === "new") {
|
||||
const created = await createTenant(settings, {
|
||||
slug: draft.slug,
|
||||
name: draft.name,
|
||||
owner_account_id: draft.ownerAccountId || null,
|
||||
description: draft.description || null,
|
||||
default_locale: draft.defaultLocale,
|
||||
settings: {},
|
||||
...governance
|
||||
});
|
||||
const selectedOwner = ownerCandidates.find((candidate) => candidate.account_id === draft.ownerAccountId);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.tenant_value_created_with_value_as_owner.1c18b6fb", { value0: created.name, value1: selectedOwner?.display_name || selectedOwner?.email || "i18n:govoplan-access.the_selected_account.1211bfb9" }));
|
||||
await onAuthRefresh();
|
||||
} else if (editing) {
|
||||
const payload: Parameters<typeof updateTenant>[2] = {};
|
||||
if (canUpdate) {
|
||||
payload.name = draft.name;
|
||||
payload.description = draft.description || null;
|
||||
payload.default_locale = draft.defaultLocale;
|
||||
payload.allow_custom_groups = governance.allow_custom_groups;
|
||||
payload.allow_custom_roles = governance.allow_custom_roles;
|
||||
payload.allow_api_keys = governance.allow_api_keys;
|
||||
}
|
||||
if (canSuspend) payload.is_active = draft.isActive;
|
||||
await updateTenant(settings, editing.id, payload);
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.tenant_value_updated.25b2c855", { value0: draft.name }));
|
||||
await onAuthRefresh();
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function suspend() {
|
||||
if (!confirmSuspend) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateTenant(settings, confirmSuspend.id, { is_active: false });
|
||||
setSuccess(i18nMessage("i18n:govoplan-access.value_suspended.31731a28", { value0: confirmSuspend.name }));
|
||||
setConfirmSuspend(null);
|
||||
await onAuthRefresh();
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const activeTenantId = (auth.active_tenant ?? auth.tenant).id;
|
||||
const systemAllowsCustomGroups = systemSettings?.allow_tenant_custom_groups !== false;
|
||||
const systemAllowsCustomRoles = systemSettings?.allow_tenant_custom_roles !== false;
|
||||
const systemAllowsApiKeys = systemSettings?.allow_tenant_api_keys !== false;
|
||||
const systemDeniedGovernance = [
|
||||
systemAllowsCustomGroups ? "" : "i18n:govoplan-access.custom_groups.453a605c",
|
||||
systemAllowsCustomRoles ? "" : "i18n:govoplan-access.custom_roles.d48dc976",
|
||||
systemAllowsApiKeys ? "" : "i18n:govoplan-access.api_keys.94fcf3c2"].
|
||||
filter(Boolean).join(", ");
|
||||
const columns = useMemo<DataGridColumn<TenantAdminItem>[]>(() => [
|
||||
{ id: "name", header: "i18n:govoplan-access.tenant.3ca93c78", width: "minmax(210px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div> },
|
||||
{ id: "users", header: "i18n:govoplan-access.users.57f2b181", width: 100, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.users ?? 0, render: (row) => `${row.counts.active_users ?? 0}/${row.counts.users ?? 0}` },
|
||||
{ id: "groups", header: "i18n:govoplan-access.groups.ae9629f4", width: 95, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.groups ?? 0 },
|
||||
{ id: "campaigns", header: "i18n:govoplan-access.campaigns.01a23a28", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.campaigns ?? 0 },
|
||||
{ id: "files", header: "i18n:govoplan-access.files.6ce6c512", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 0 },
|
||||
{ id: "locale", header: "i18n:govoplan-access.locale.8970f0e6", width: 120, minWidth: 90, maxWidth: 220, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.default_locale },
|
||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !canUpdate, onClick: () => openEdit(row) },
|
||||
{ id: "suspend", label: i18nMessage("i18n:govoplan-access.suspend_value.03a74b32", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.id === activeTenantId, onClick: () => setConfirmSuspend(row) }
|
||||
]} /> }],
|
||||
[activeTenantId, canSuspend, canUpdate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageLayout
|
||||
title="i18n:govoplan-access.tenants.1f7ae776"
|
||||
description="i18n:govoplan-access.create_and_govern_tenant_spaces_suspension_retai.1b76d377"
|
||||
loading={loading}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={<><Button onClick={() => void load()} disabled={loading}>i18n:govoplan-access.reload.cce71553</Button><AdminIconButton label="i18n:govoplan-access.add_tenant.b8e32af0" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canCreate} /></>}>
|
||||
|
||||
<div className="admin-table-surface"><DataGrid id="admin-tenants-v3" rows={tenants} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="i18n:govoplan-access.no_tenants_found.72d04cf4" /></div>
|
||||
</AdminPageLayout>
|
||||
|
||||
<Dialog open={editing !== null} title={editing === "new" ? "i18n:govoplan-access.create_tenant.4dbd55d9" : "i18n:govoplan-access.edit_tenant.e2ba43f9"} onClose={() => !busy && setEditing(null)} className="admin-dialog admin-dialog-wide" footer={<><Button onClick={() => setEditing(null)} disabled={busy}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabled={!(editing === "new" ? canCreate : canUpdate) || busy || !draft.name.trim() || !draft.slug.trim() || editing === "new" && !draft.ownerAccountId}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_tenant.9eb2ac74"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="i18n:govoplan-access.name.709a2322"><input value={draft.name} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-access.slug.094da9b9"><input value={draft.slug} disabled={editing !== "new" || !canCreate} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} /></FormField>
|
||||
{editing === "new" && <FormField label="i18n:govoplan-access.initial_tenant_owner.682291a9"><select value={draft.ownerAccountId} onChange={(event) => setDraft({ ...draft, ownerAccountId: event.target.value })}>{ownerCandidates.map((candidate) => <option key={candidate.account_id} value={candidate.account_id}>{candidate.display_name ? i18nMessage("i18n:govoplan-access.value_value.c189e8bc", { value0: candidate.display_name, value1: candidate.email }) : candidate.email}</option>)}</select></FormField>}
|
||||
<FormField label="i18n:govoplan-access.default_locale.b99d021f"><input value={draft.defaultLocale} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, defaultLocale: event.target.value })} /></FormField>
|
||||
{editing !== "new" && <FormField label="i18n:govoplan-access.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={!canSuspend} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.suspended.794696a7</option></select></FormField>}
|
||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={4} value={draft.description} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||
</div>
|
||||
<h3>i18n:govoplan-access.system_governance_overrides.97cdf3ce</h3>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomGroups} label="i18n:govoplan-access.custom_tenant_groups.570ee603" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomRoles} label="i18n:govoplan-access.custom_tenant_roles.a738c37c" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsApiKeys} label="i18n:govoplan-access.tenant_api_keys.4b1d81f8" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
|
||||
</div>
|
||||
<p className="muted small-note">i18n:govoplan-access.inherit_follows_the_current_system_setting_expli.60d4d868</p>
|
||||
{systemDeniedGovernance && <p className="muted small-note">i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a {systemDeniedGovernance} i18n:govoplan-access.because_the_current_system_setting_denies_it.3f59c244</p>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.tenant_details.5976ba72" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||
{viewing && <><dl className="admin-details-grid">
|
||||
<div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{viewing.name}</dd></div><div><dt>i18n:govoplan-access.slug.094da9b9</dt><dd>{viewing.slug}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.status.bae7d5be</dt><dd>{viewing.is_active ? "i18n:govoplan-access.active.a733b809" : "i18n:govoplan-access.suspended.794696a7"}</dd></div><div><dt>i18n:govoplan-access.default_locale.b99d021f</dt><dd>{viewing.default_locale}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.created.accf40c8</dt><dd>{formatDateTime(viewing.created_at)}</dd></div><div><dt>i18n:govoplan-access.updated.f2f8570d</dt><dd>{formatDateTime(viewing.updated_at)}</dd></div>
|
||||
<div><dt>i18n:govoplan-access.custom_groups.1f7b7c8f</dt><dd>{viewing.effective_governance.allow_custom_groups ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_custom_groups)})</dd></div>
|
||||
<div><dt>i18n:govoplan-access.custom_roles.e78ef63d</dt><dd>{viewing.effective_governance.allow_custom_roles ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_custom_roles)})</dd></div>
|
||||
<div><dt>i18n:govoplan-access.api_keys.94fcf3c2</dt><dd>{viewing.effective_governance.allow_api_keys ? "i18n:govoplan-access.allowed.77c7b490" : "i18n:govoplan-access.denied.63b16bd4"} ({fromOverride(viewing.allow_api_keys)})</dd></div>
|
||||
<div><dt>i18n:govoplan-access.objects.72a83add</dt><dd>{viewing.counts.users ?? 0} i18n:govoplan-access.users.81651889 {viewing.counts.groups ?? 0} i18n:govoplan-access.groups.07551586 {viewing.counts.campaigns ?? 0} i18n:govoplan-access.campaigns.2282ffeb {viewing.counts.files ?? 0} files</dd></div>
|
||||
</dl>{viewing.description && <p>{viewing.description}</p>}</>}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog open={Boolean(confirmSuspend)} title="i18n:govoplan-access.suspend_tenant.151d283a" message={i18nMessage("i18n:govoplan-access.suspend_value_existing_data_remains_retained_but.19bccd78", { value0: confirmSuspend?.name })} confirmLabel="i18n:govoplan-access.suspend_tenant.151d283a" tone="danger" busy={busy} onCancel={() => setConfirmSuspend(null)} onConfirm={() => void suspend()} />
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function GovernanceSelect({ label, value, onChange, disabled = false, allowDisabled = false }: {label: string;value: OverrideValue;onChange: (value: OverrideValue) => void;disabled?: boolean;allowDisabled?: boolean;}) {
|
||||
return <FormField label={label}><select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as OverrideValue)}><option value="inherit">i18n:govoplan-access.inherit_system_setting.7f125156</option><option value="allow" disabled={allowDisabled}>i18n:govoplan-access.allow_when_system_allows.4c5178cb</option><option value="deny">i18n:govoplan-access.explicitly_deny.17ad945a</option></select></FormField>;
|
||||
}
|
||||
|
||||
function draftKey(draft: TenantDraft): string {
|
||||
return JSON.stringify(draft);
|
||||
}
|
||||
|
||||
function sortTenants(left: TenantAdminItem, right: TenantAdminItem): number {
|
||||
return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug);
|
||||
}
|
||||
@@ -10,9 +10,14 @@ import { PasswordField } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
|
||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, DocumentationHelpLink, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
|
||||
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { loadDeltaRows } from "./utils/deltaRows";
|
||||
import {
|
||||
ACCESS_INTERFACE_I18N,
|
||||
ACCESS_WORKFLOW_DOCUMENTATION,
|
||||
saveDisabledReason
|
||||
} from "./interfacePatterns";
|
||||
|
||||
const emptyDraft = {
|
||||
email: "",
|
||||
@@ -190,18 +195,18 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
||||
{ 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: "explain", label: i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email }), icon: <KeyRound />, onClick: () => void openAccessExplanation(row) },
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canManageGroups || canAssignRoles), onClick: () => openEdit(row) },
|
||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.is_last_active_owner, onClick: () => setDeactivating(row) }
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canManageGroups || canAssignRoles), disabledReason: !(canUpdate || canSuspend || canManageGroups || canAssignRoles) ? ACCESS_INTERFACE_I18N.updatePermissionRequired : undefined, onClick: () => openEdit(row) },
|
||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.is_last_active_owner, disabledReason: !row.is_active ? "i18n:govoplan-access.inactive.09af574c" : !canSuspend ? ACCESS_INTERFACE_I18N.updatePermissionRequired : row.is_last_active_owner ? ACCESS_INTERFACE_I18N.lastOwnerCannotBeDeactivated : undefined, onClick: () => setDeactivating(row) }
|
||||
]} /> }],
|
||||
[canAssignRoles, canManageGroups, canSuspend, canUpdate, settings]);
|
||||
|
||||
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></>}>
|
||||
<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} disabledReason={busy ? ACCESS_INTERFACE_I18N.operationInProgress : undefined}>i18n:govoplan-access.cancel.77dfd213</Button><Button variant="primary" onClick={() => void save()} disabledReason={saveDisabledReason({ busy, permitted: editing === "new" ? canCreate : canUpdate || canSuspend || canManageGroups || canAssignRoles, complete: Boolean(draft.email.trim()) })}>{busy ? "i18n:govoplan-access.saving.56a2285c" : "i18n:govoplan-access.save_user.0d071b89"}</Button></>}>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<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>
|
||||
@@ -235,7 +240,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
||||
</dl>}
|
||||
</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 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} 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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,6 +2,36 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
"en": {
|
||||
"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",
|
||||
@@ -352,6 +382,36 @@ 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.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",
|
||||
|
||||
+25
-5
@@ -1,6 +1,7 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformRouteContext, PlatformWebModule } from "@govoplan/core-webui";
|
||||
import type { ActingContextRuntimeUiCapability, PlatformRouteContext, PlatformWebModule } 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"));
|
||||
@@ -10,6 +11,22 @@ const translations = {
|
||||
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 }
|
||||
];
|
||||
|
||||
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 +37,17 @@ 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
|
||||
}
|
||||
};
|
||||
|
||||
export default accessModule;
|
||||
export default accessModule;
|
||||
|
||||
Reference in New Issue
Block a user